Skip to content

Commit 3c23fd5

Browse files
committed
fix(spec): the top-level lint must not call a working onEnable dropped
Caught by running the new lint against our own shipped examples before merging: `examples/app-todo` and `examples/app-showcase` both got ⚠ stack.onEnable: 'onEnable' is not a declared stack key, so its value is dropped at load. which is false. `onEnable` is a function, so `ObjectStackDefinitionSchema` cannot declare it and `dist/objectstack.json` cannot carry it — but `AppPlugin` reads it straight off the authored bundle and calls it at `start()` (`app-plugin.ts`), and the artifact-boot path grafts it back (#4095). It is the documented place to register action handlers. "Not declared" and "dropped at load" are different claims, and this is the one surface where they come apart. New `STACK_RUNTIME_MEMBERS` names the authored top-level members the runtime honours off the bundle; `lintUnknownStackKeys` treats them as declared. The CLI's `GRAFTABLE_RUNTIME_MEMBERS` is now DERIVED from it instead of restating it — two hand-written copies could disagree, and the disagreement would be silent in exactly the direction this lint family exists to catch. `onDisable` is deliberately excluded from the exemption: it is declared in the protocol but no kernel, runtime or service calls it, so a value written there really does go nowhere. A test pins both halves of that distinction. Re-harvested after the fix: all three shipped examples validate with zero undeclared-key warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh
1 parent dd45c54 commit 3c23fd5

7 files changed

Lines changed: 94 additions & 3 deletions

File tree

.changeset/stack-storage-not-an-authoring-key.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ path and disappeared silently everywhere else. A host writing
3535
#4096. That command reconciles what records claim against what storage holds,
3636
so a disagreeing root reconciled against the wrong tree.
3737

38+
**`onEnable` is exempt, and the exemption has one owner.** `onEnable` is a
39+
function, so `ObjectStackDefinitionSchema` cannot declare it and
40+
`dist/objectstack.json` cannot carry it — but it is not lost: `AppPlugin` calls
41+
it off the authored bundle, and the artifact-boot path grafts it back (#4095).
42+
"Not declared" and "dropped at load" are different claims, and this is the
43+
surface where they come apart. New `STACK_RUNTIME_MEMBERS` in `@objectstack/spec`
44+
names the members the runtime honours off the bundle; the lint treats them as
45+
declared, and the CLI's `GRAFTABLE_RUNTIME_MEMBERS` is now **derived** from it
46+
rather than restating it, so the list that decides what gets grafted and the
47+
list that decides what the lint stays quiet about cannot drift. `onDisable` is
48+
deliberately not on it — nothing calls it, so a value written there really does
49+
go nowhere and the lint should say so.
50+
3851
Additive: `lintUnknownAuthoringKeys` keeps its signature. The new pass is a
3952
separate export rather than a fold into that walker for two reasons. The walker
4053
iterates metadata COLLECTIONS, so a stack whose only mistake is at the envelope

packages/cli/src/utils/graft-runtime-hooks.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
// invisible for so long.
2929
// ---------------------------------------------------------------------------
3030

31+
import { STACK_RUNTIME_MEMBERS } from '@objectstack/spec';
32+
3133
/**
3234
* Bundle members `AppPlugin` executes, and which therefore cannot survive a
3335
* trip through JSON:
@@ -40,8 +42,16 @@
4042
* `onDisable` is deliberately absent: it is declared in `packages/spec` but no
4143
* kernel, runtime or service ever calls it, so grafting it would wire a hook
4244
* nothing runs.
45+
*
46+
* **Derived, not restated** (framework#4167). The same list decides what the
47+
* undeclared-top-level-key lint stays quiet about: these members are not
48+
* declared by `ObjectStackDefinitionSchema`, but they are honoured off the
49+
* authored bundle, so reporting them as "dropped at load" would be false. Two
50+
* hand-written copies could disagree, and the disagreement would be silent in
51+
* exactly the direction that lint exists to catch — so the protocol owns the
52+
* list and this re-exports it.
4353
*/
44-
export const GRAFTABLE_RUNTIME_MEMBERS = ['onEnable', 'functions'] as const;
54+
export const GRAFTABLE_RUNTIME_MEMBERS = STACK_RUNTIME_MEMBERS;
4555

4656
export type GraftableRuntimeMember = (typeof GRAFTABLE_RUNTIME_MEMBERS)[number];
4757

packages/spec/api-surface.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"PredicateInputSchema (const)",
9393
"PredicateSchema (const)",
9494
"STACK_KEY_GUIDANCE (const)",
95+
"STACK_RUNTIME_MEMBERS (const)",
9596
"SemanticMigration (interface)",
9697
"Skill (type)",
9798
"SpecChanges (type)",
@@ -504,6 +505,7 @@
504505
"SSLConfig (type)",
505506
"SSLConfigSchema (const)",
506507
"STACK_KEY_GUIDANCE (const)",
508+
"STACK_RUNTIME_MEMBERS (const)",
507509
"STRING_VALUE_TYPES (const)",
508510
"STRUCTURED_JSON_TYPES (const)",
509511
"Scalar (type)",

packages/spec/src/data/authoring-key-lint.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,35 @@ export const STACK_KEY_GUIDANCE: Readonly<
159159
},
160160
});
161161

162+
/**
163+
* Authored TOP-LEVEL members the **runtime executes off the bundle**, which the
164+
* stack schema therefore does not — and cannot — declare.
165+
*
166+
* `onEnable` is a function. It cannot survive `ObjectStackDefinitionSchema`
167+
* (which does not declare it) and it cannot survive `dist/objectstack.json`
168+
* (JSON has no functions), yet it is not lost: `AppPlugin` reads it straight
169+
* off the authored bundle and calls it at `start()`, and on the artifact-boot
170+
* path the CLI grafts it back (#4095). It is the documented place to register
171+
* action handlers, and `examples/app-todo` and `examples/app-showcase` both
172+
* ship it.
173+
*
174+
* So the lint must stay SILENT here. "Not declared" and "dropped at load" are
175+
* different claims, and this is the one surface where they come apart — telling
176+
* an author their working `onEnable` is being discarded would be a confident
177+
* lie about the pattern we ship in our own examples.
178+
*
179+
* `functions` is listed for the same reason (a name → handler map the runtime
180+
* resolves string-named hooks against); the schema happens to declare it too,
181+
* so it self-excludes. `onDisable` is deliberately ABSENT: it is declared in
182+
* the protocol but no kernel, runtime or service ever calls it, so a value
183+
* written there really does go nowhere and the lint should say so.
184+
*
185+
* Single source of truth — the CLI's `GRAFTABLE_RUNTIME_MEMBERS` is derived
186+
* from this, so the list that decides what gets grafted and the list that
187+
* decides what the lint stays quiet about cannot drift apart.
188+
*/
189+
export const STACK_RUNTIME_MEMBERS = Object.freeze(['onEnable', 'functions'] as const);
190+
162191
/** A plain object — the only shape an authored metadata item can take. */
163192
export function isPlainRecord(v: unknown): v is Record<string, unknown> {
164193
return !!v && typeof v === 'object' && !Array.isArray(v);

packages/spec/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export {
107107
FIELD_KEY_GUIDANCE,
108108
OBJECT_KEY_GUIDANCE,
109109
STACK_KEY_GUIDANCE,
110+
STACK_RUNTIME_MEMBERS,
110111
} from './data/authoring-key-lint';
111112
export type {
112113
UnknownAuthoringKeyFinding,

packages/spec/src/kernel/metadata-authoring-lint.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
lintUnknownStackKeys,
1919
listLintableAuthoringCollections,
2020
} from './metadata-authoring-lint';
21-
import { STACK_KEY_GUIDANCE } from '../data/authoring-key-lint';
21+
import { STACK_KEY_GUIDANCE, STACK_RUNTIME_MEMBERS } from '../data/authoring-key-lint';
2222
import { PLURAL_TO_SINGULAR } from '../shared/metadata-collection.zod';
2323
import { ObjectStackDefinitionSchema } from '../stack.zod';
2424
import { getMetadataTypeSchema } from './metadata-type-schemas';
@@ -174,6 +174,24 @@ describe('top-level stack keys (#4167)', () => {
174174
expect(lint({ objects: [], pages: [], manifest: { name: 'app' }, _packageId: 'p' })).toEqual([]);
175175
});
176176

177+
it('stays silent on the runtime members the schema cannot declare', () => {
178+
// The regression that shipped in review: `onEnable` is a function, so the
179+
// schema does not declare it — but `AppPlugin` calls it off the authored
180+
// bundle, and `examples/app-todo` and `examples/app-showcase` both ship it.
181+
// The first version of this lint told both of them their working handler
182+
// registration was "dropped at load".
183+
expect(lint({ onEnable: () => {}, functions: { doThing: () => {} } })).toEqual([]);
184+
});
185+
186+
it('still reports `onDisable`, which really does go nowhere', () => {
187+
// The distinction the exclusion list has to preserve: `onDisable` is
188+
// declared in the protocol but no kernel, runtime or service calls it, so
189+
// a value written there IS lost and the author should hear about it.
190+
const [finding, ...rest] = lint({ onDisable: () => {} });
191+
expect(rest).toEqual([]);
192+
expect(finding).toMatchObject({ path: 'stack.onDisable', key: 'onDisable' });
193+
});
194+
177195
it('agrees with the injected schema posture instead of asserting its own', () => {
178196
// Guarding the day `ObjectStackDefinitionSchema` graduates to `.strict()`
179197
// (ADR-0049 / #4001): the parse becomes loud, and this lint must go quiet
@@ -205,6 +223,18 @@ describe('STACK_KEY_GUIDANCE does not rot', () => {
205223
}
206224
});
207225

226+
it('no runtime member is silently excluded for a key the schema declares', () => {
227+
// `functions` legitimately appears in both lists. But if a member ever
228+
// exists ONLY here while the schema also declares it and the runtime has
229+
// stopped reading it, the exclusion is dead weight hiding a real finding.
230+
// Pin the one that carries the exclusion's whole weight instead of trusting
231+
// the list's shape: `onEnable` must be excluded AND undeclared.
232+
expect(STACK_RUNTIME_MEMBERS).toContain('onEnable');
233+
expect(declared, 'onEnable became a declared key — the exclusion is now dead').not.toContain('onEnable');
234+
expect(STACK_RUNTIME_MEMBERS, 'onDisable is honoured nowhere; excluding it would hide a real drop')
235+
.not.toContain('onDisable');
236+
});
237+
208238
it('every entry carries a rename target that exists, or a reason', () => {
209239
expect(Object.keys(STACK_KEY_GUIDANCE).length).toBeGreaterThan(0);
210240
for (const [key, hint] of Object.entries(STACK_KEY_GUIDANCE)) {

packages/spec/src/kernel/metadata-authoring-lint.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
FIELD_KEY_GUIDANCE,
5555
OBJECT_KEY_GUIDANCE,
5656
STACK_KEY_GUIDANCE,
57+
STACK_RUNTIME_MEMBERS,
5758
type UnknownAuthoringKeyFinding,
5859
} from '../data/authoring-key-lint';
5960
import { FieldSchema } from '../data/field.zod';
@@ -256,7 +257,12 @@ export function lintUnknownStackKeys(
256257
const posture = keyPosture(stackSchema);
257258
if (!posture || posture.mode !== 'strip' || posture.keys.size === 0) return [];
258259

260+
// The runtime members are honoured OFF the authored bundle, so the parse
261+
// dropping them costs nothing — treating them as declared is what keeps the
262+
// lint from calling a working `onEnable` discarded. See STACK_RUNTIME_MEMBERS.
263+
const declared = new Set([...posture.keys, ...STACK_RUNTIME_MEMBERS]);
264+
259265
const out: UnknownAuthoringKeyFinding[] = [];
260-
lintAuthoredRecordKeys(rawStack, posture.keys, STACK_KEY_GUIDANCE, 'stack', 'stack', out);
266+
lintAuthoredRecordKeys(rawStack, declared, STACK_KEY_GUIDANCE, 'stack', 'stack', out);
261267
return out;
262268
}

0 commit comments

Comments
 (0)