Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/permissions-alias-hosts-justification.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 50 additions & 0 deletions packages/spec/src/kernel/manifest-unknown-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ManifestSchema.safeParse>) => {
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<Array<{ code: string; keys?: string[] }>> }
| 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);
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions packages/spec/src/kernel/manifest.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
}, {
Expand Down
Loading