diff --git a/.changeset/permissions-alias-hosts-justification.md b/.changeset/permissions-alias-hosts-justification.md new file mode 100644 index 0000000000..83235a96ae --- /dev/null +++ b/.changeset/permissions-alias-hosts-justification.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +Correct the `permissions` alias table's justification for `hosts`, and pin the two aliases nothing measured. + +`PluginPermissionsSchema` (`kernel/manifest.zod.ts`) curates three aliases — `filesystem` and `paths` point at `fs`, `hosts` points at `network`. The block's only comment said edit distance cannot reach any of them, and it sat directly above all three. That is true of the two `fs` entries and false of `hosts`. + +The fallback budget is `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), so a 5-character key gets 2, and `hosts` differs from the declared `hooks` by exactly 2. Measured against the real `findClosestMatches` with the alias table out of the picture: `filesystem` and `paths` return nothing, `hosts` returns `hooks`. So without the alias an author writing `hosts` is answered ``Did you mean `hosts` → `hooks`?`` — pointed at lifecycle hooks on the one block that also grants network access. + +The alias is therefore better justified than the comment claimed: it overrules a confident wrong suggestion rather than filling a silent gap. Only the justification moves — the alias stays, the declared keys, the strictness and the union are untouched, and no message an author reads changes. + +`hosts` is also the only one of the three whose absence would be invisible, since it is the only one that changes a live suggestion, so `manifest-unknown-keys.test.ts` now pins both it and `paths` alongside the `filesystem` pin that was already there, asserting the offending key and the rename — and, for `hosts`, that `hooks` is not what comes back. diff --git a/packages/spec/src/kernel/manifest-unknown-keys.test.ts b/packages/spec/src/kernel/manifest-unknown-keys.test.ts index 215bf0fe69..7345261bd9 100644 --- a/packages/spec/src/kernel/manifest-unknown-keys.test.ts +++ b/packages/spec/src/kernel/manifest-unknown-keys.test.ts @@ -387,6 +387,24 @@ describe('#16328 — the `permissions` union door names the surface and the rena // the `devPlugins[]` guard above. const near = () => ({ ...legal(), permissions: { services: ['object'], hoooks: ['x'] } }); + /** + * The object arm's own `unrecognized_keys` issue, carried inside the union + * issue at `['permissions']` — the shape the first pin below measures raw. + */ + const permissionsRefusal = (result: ReturnType) => { + if (result.success) throw new Error('expected the manifest to be refused'); + const union = result.error.issues.find((i) => i.code === 'invalid_union') as + | { path: (string | number)[]; errors: Array> } + | undefined; + expect(union, 'the refusal is a union issue at `permissions`').toBeDefined(); + expect(union!.path).toEqual(['permissions']); + const nested = union!.errors.flat().find((i) => i.code === 'unrecognized_keys') as + | { code: string; keys: string[] } + | undefined; + expect(nested, 'the named refusal is carried inside the union issue').toBeDefined(); + return nested!; + }; + it('the author reads the key, the surface and the rename — through `formatZodError`', () => { const result = ManifestSchema.safeParse(near()); expect(result.success).toBe(false); @@ -445,6 +463,38 @@ describe('#16328 — the `permissions` union door names the surface and the rena expect(formatZodError(result.error)).toContain('Did you mean `filesystem` → `fs`?'); }); + it('`paths` reaches `fs` too — the second half of the same unreachable pair', () => { + // `paths` is 5 characters, so the fallback budget is + // `Math.max(2, Math.floor(5 / 3))` = 2, and its nearest declared key is 4 + // edits away (`hooks` and `fs` tie). Nothing to overrule; the entry buys a + // suggestion where the fallback offers none. + const result = ManifestSchema.safeParse({ ...legal(), permissions: { paths: ['/tmp'] } }); + expect(result.success).toBe(false); + if (result.success) return; + const nested = permissionsRefusal(result); + expect(nested.keys).toEqual(['paths']); + expect(formatZodError(result.error)).toContain('Did you mean `paths` → `fs`?'); + }); + + it('`hosts` overrules a LIVE wrong suggestion — without the alias the fallback answers `hooks`', () => { + // The one alias of the three whose absence would be invisible: it does not + // fill a silent gap, it overrides a confident wrong answer. `hosts` is 5 + // characters, so the budget is `Math.max(2, Math.floor(5 / 3))` = 2, and + // `hosts` differs from the declared `hooks` by exactly 2 — so the bare + // fallback reaches `hooks` and sends the author to lifecycle hooks on the + // one block that also grants network access. Dropping the alias line makes + // the assertion below read ``Did you mean `hosts` → `hooks`?``, which is + // why the negative half is asserted and not only the positive one. + const result = ManifestSchema.safeParse({ ...legal(), permissions: { hosts: ['api.acme.com'] } }); + expect(result.success).toBe(false); + if (result.success) return; + const nested = permissionsRefusal(result); + expect(nested.keys).toEqual(['hosts']); + const rendered = formatZodError(result.error); + expect(rendered, 'the curated target is offered').toContain('Did you mean `hosts` → `network`?'); + expect(rendered, 'the reachable wrong answer is not').not.toContain('`hosts` → `hooks`'); + }); + it('the five doors that already named their key are untouched by this change', () => { // The hard acceptance limb: this change adds an error map to ONE block, so // no other door's message may move. Each of these is asserted in full diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 786ec80424..5be325e1a0 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -48,11 +48,19 @@ export const PluginPermissionsSchema = strictObject({ + 'and this block decides which services, hooks, network hosts and filesystem paths the ' + 'plugin may touch. The declared keys are `services`, `hooks`, `network` and `fs`.', aliases: { - // Edit distance cannot reach a two-letter abbreviation from the word it - // abbreviates, and `fs` is the one key here an author is most likely to - // spell out in full. + // These two are the unreachable case: edit distance cannot reach a + // two-letter abbreviation from the word it abbreviates, and `fs` is the + // one key here an author is most likely to spell out in full. filesystem: 'fs', paths: 'fs', + // `hosts` is the opposite case, and the stronger reason to curate an + // entry: it IS within budget of `hooks`. The fallback budget is + // `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), + // so a 5-character key gets 2, and `hosts`/`hooks` differ by exactly 2. + // Without this line the fallback answers `hosts` -> `hooks`, pointing the + // author at lifecycle hooks on the one block that also grants network + // access. This alias overrides a confident WRONG suggestion rather than + // filling a silent gap, and `manifest-unknown-keys.test.ts` pins that. hosts: 'network', }, }, {