Skip to content

Commit bf63587

Browse files
committed
Merge origin/main (#4607 / PR #4609) into claude/issue-4580-one-schemanode-round2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
2 parents d5ce055 + f69127f commit bf63587

3 files changed

Lines changed: 369 additions & 5 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
---
3+
4+
Tooling only — a CI guard and its own test suite, no package source, so no release.
5+
6+
`check:spec-symbols` rule 2 flags an exported declaration whose doc comment claims
7+
`@objectstack/spec` alignment while the declaration references nothing spec-bound.
8+
That tie test was symbol-AGNOSTIC: it asked whether the declaration referenced ANY
9+
spec-bound identifier, so a claim about symbol X passed on an incidental reference
10+
to an unrelated symbol Y.
11+
12+
`FeedItem` (packages/types/src/views.ts) was the live specimen (objectui#4607). It
13+
cited `FeedItemSchema`, removed from `@objectstack/spec/data` in the 16.0.0 major,
14+
and never appeared in a single gate run — purely because one member is typed
15+
`FeedItemType`, the one feed symbol that removal kept. It sat four lines from a
16+
section banner making the same claim, in the same file as four declarations that
17+
WERE flagged, and it was found by reading the file rather than by any run.
18+
19+
The tie is now judged against the symbols the claim CITES: when a claim names
20+
symbols and the installed spec exports none of them, the declaration is flagged
21+
whatever else it references. A claim naming at least one live symbol stays governed
22+
by the tie test unchanged — a claim-vs-tie mismatch among LIVE symbols is a
23+
documented non-goal, since it needs a name-relatedness allowance for the legitimate
24+
`type: FeedItemType` shape.
25+
26+
Measured repo-wide with the sharpened rule: the hidden population is zero. Of the
27+
three declarations that carry a claim and pass the tie test, two cite only live
28+
symbols and one is mixed, so no verdict in the tree changes and no ledger entry
29+
moves (CLAIM_ALLOW 2, CLAIM_DEBT 18 in 5 packages, before and after).

scripts/__tests__/check-spec-symbol-derivation.test.ts

Lines changed: 270 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,29 @@ function withFixture<T>(files: Record<string, string>, run: (paths: Record<strin
5353
}
5454
}
5555

56-
/** The spec export names the fixtures below refer to. `ReactionSchema` is deliberately absent. */
56+
/**
57+
* The spec export names the fixtures below refer to, faithful to the pinned
58+
* 17.0.0-rc.6: every name a fixture CITES and the spec really exports is here,
59+
* and the retired ones (`ReactionSchema`, `FeedItemSchema`, …) are deliberately
60+
* absent.
61+
*
62+
* Faithfulness became load-bearing in objectui#4607 and was not before. Until
63+
* then `specNames` was consulted only to decorate the message of a declaration
64+
* that had ALREADY failed the tie test, so a name missing from this map changed
65+
* nothing; now it decides whether the tie test applies at all. `ListView` is the
66+
* instance — cited by two green fixtures below, really exported by
67+
* `@objectstack/spec/ui`, and absent from this map until #4607 measured it.
68+
* Omitting a live name here makes a green fixture red for a reason that exists
69+
* nowhere but this map.
70+
*/
5771
const SPEC_NAMES = new Map<string, Set<string>>([
5872
['NavigationConfig', new Set(['@objectstack/spec/ui'])],
5973
['NavigationConfigSchema', new Set(['@objectstack/spec/ui'])],
74+
['ListView', new Set(['@objectstack/spec/ui'])],
75+
// Kept when the 16.0.0 major removed the rest of the feed surface — the live
76+
// half of the objectui#4607 specimen.
77+
['FeedItemType', new Set(['@objectstack/spec/data'])],
78+
['FeedFilterMode', new Set(['@objectstack/spec/data'])],
6079
]);
6180

6281
const scan = (file: string) => scanFileForClaims(file, SPEC_NAMES);
@@ -309,6 +328,215 @@ export type { InternalNavigationConfig };
309328
});
310329
});
311330

331+
// ── The tie is judged against the symbols the claim CITES (objectui#4607) ────
332+
333+
/**
334+
* The tie test above is symbol-AGNOSTIC: it asks whether the declaration
335+
* references ANY spec-bound identifier. So a claim about symbol X passed on an
336+
* incidental reference to unrelated symbol Y — and the more spec-integrated a
337+
* declaration was, the weaker the check on its prose became.
338+
*
339+
* `FeedItem` (packages/types/src/views.ts) was the live specimen: it cited
340+
* `FeedItemSchema`, removed from `@objectstack/spec/data` in the 16.0.0 major,
341+
* and never appeared in a single gate run because one member is typed
342+
* `FeedItemType` — the one feed symbol the removal kept. Measured on
343+
* origin/main@92876f097 before this change, the scanner returned `0 findings`
344+
* for the fixture below; it returns the finding asserted here after it.
345+
*/
346+
describe('a claim citing only symbols the spec does not export is flagged despite a live tie', () => {
347+
/** The objectui#4607 specimen: dangling citation, live tie to a DIFFERENT symbol. */
348+
const FEED_ITEM_SPECIMEN = `
349+
import type { FeedItemType } from '@objectstack/spec/data';
350+
351+
/**
352+
* FeedItem — A single item in the unified activity feed.
353+
* Aligned with @objectstack/spec FeedItemSchema.
354+
*/
355+
export interface FeedItem {
356+
id: string;
357+
type: FeedItemType;
358+
body?: string;
359+
createdAt: string;
360+
}
361+
`;
362+
363+
it('(a) flags the specimen, and names the symbol the spec has dropped', () => {
364+
withFixture({ 'views.ts': FEED_ITEM_SPECIMEN }, ({ 'views.ts': file }) => {
365+
const found = scan(file);
366+
expect(found).toHaveLength(1);
367+
expect(found[0].name).toBe('FeedItem');
368+
expect(found[0].phrase.toLowerCase()).toBe('aligned with');
369+
expect(found[0].dangling).toEqual(['FeedItemSchema']);
370+
});
371+
});
372+
373+
it('(a) the tie itself is real — only the CITATION differs from a green declaration', () => {
374+
// The discrimination proof's other half, and the reason this rule is not
375+
// just "flag anything with a retired name in the comment": the fixture is
376+
// byte-identical to the one above except that the claim cites the symbol the
377+
// declaration is actually tied to. Same import, same member, same claim
378+
// phrase — green.
379+
withFixture(
380+
{ 'views.ts': FEED_ITEM_SPECIMEN.replace('FeedItemSchema', 'FeedItemType') },
381+
({ 'views.ts': file }) => expect(scan(file)).toEqual([])
382+
);
383+
});
384+
385+
it('(c) a claim citing a LIVE symbol the declaration is tied to stays green', () => {
386+
withFixture(
387+
{
388+
'tied.ts': `
389+
import type { NavigationConfig } from '@objectstack/spec/ui';
390+
391+
/** Navigation node. Aligned with @objectstack/spec NavigationConfig. */
392+
export interface TiedNavigationNode {
393+
navigation?: NavigationConfig;
394+
columns?: string[];
395+
}
396+
`,
397+
},
398+
({ 'tied.ts': file }) => expect(scan(file)).toEqual([])
399+
);
400+
});
401+
402+
it('(d) a claim citing a LIVE symbol while tied to a DIFFERENT live one stays green', () => {
403+
// The KNOWN NON-GOAL recorded on objectui#4607. This is a claim-vs-tie
404+
// MISMATCH, not a dangling citation: both symbols exist, so the claim points
405+
// at something real and the reader can check it. Judging these needs a
406+
// name-relatedness allowance for the legitimate `type: FeedItemType` shape,
407+
// where citation and tie are genuinely different-but-related symbols — a
408+
// different instrument, deliberately not built here.
409+
withFixture(
410+
{
411+
'mismatch.ts': `
412+
import type { NavigationConfig } from '@objectstack/spec/ui';
413+
414+
/** List view node. Aligned with @objectstack/spec ListView. */
415+
export interface MismatchedListViewNode {
416+
navigation?: NavigationConfig;
417+
columns?: string[];
418+
}
419+
`,
420+
},
421+
({ 'mismatch.ts': file }) => expect(scan(file)).toEqual([])
422+
);
423+
});
424+
425+
it('a claim naming no symbol at all is still governed by the tie test', () => {
426+
withFixture(
427+
{
428+
'unnamed.ts': `
429+
import type { NavigationConfig } from '@objectstack/spec/ui';
430+
431+
/** Navigation node, aligned with @objectstack/spec. */
432+
export interface UnnamedClaimNode {
433+
navigation?: NavigationConfig;
434+
}
435+
`,
436+
},
437+
({ 'unnamed.ts': file }) => expect(scan(file)).toEqual([])
438+
);
439+
});
440+
441+
it('with no spec export set to check against, the rule stays out of the way', () => {
442+
// "Dangling" is a statement about the installed spec. Given no export set,
443+
// every citation would read as dangling and the rule would flag every claim
444+
// in the repo at once — a verdict manufactured from ignorance of the spec.
445+
withFixture({ 'views.ts': FEED_ITEM_SPECIMEN }, ({ 'views.ts': file }) => {
446+
expect(scanFileForClaims(file, new Map())).toEqual([]);
447+
});
448+
});
449+
});
450+
451+
// ── The retirement-record idiom must never re-trigger the gate (#4597/#4606) ─
452+
453+
describe('(b) an honest provenance note is not a claim', () => {
454+
/**
455+
* PR #4606 rewrote eight comments that cited retired spec symbols so they
456+
* RECORD the retirement instead of vouching for the symbol. That idiom names
457+
* the dead symbol on purpose — it is the provenance a reader needs — so a rule
458+
* that turned on it would punish exactly the fix it is meant to produce. What
459+
* makes these green is that they claim nothing: no alignment phrase sits next
460+
* to a `@objectstack/spec` mention, so there is no claim to have anything
461+
* behind.
462+
*/
463+
it('the @object-ui/i18n idiom — "authored against the protocol\'s X, retired in …"', () => {
464+
withFixture(
465+
{
466+
'spec-formatters.ts': `
467+
/**
468+
* Plural forms for a single translation key, in CLDR categories.
469+
*
470+
* Local shape — authored against the protocol's \`PluralRuleSchema\`, retired in
471+
* 17.0.0-rc.6 (see the module doc), so there is nothing upstream to derive from.
472+
*/
473+
export interface SpecPluralRule {
474+
key: string;
475+
zero?: string;
476+
one?: string;
477+
other: string;
478+
}
479+
`,
480+
},
481+
({ 'spec-formatters.ts': file }) => expect(scan(file)).toEqual([])
482+
);
483+
});
484+
485+
it('the @object-ui/types idiom — "its cited X went with the 16.0.0 feed removal"', () => {
486+
withFixture(
487+
{
488+
'views.ts': `
489+
import type { FeedItemType } from '@objectstack/spec/data';
490+
491+
/**
492+
* FeedItem — A single item in the unified activity feed.
493+
*
494+
* Local shape; its cited \`FeedItemSchema\` went with the 16.0.0 feed removal
495+
* (see the section banner). Only \`type\` is still protocol-bound, through the
496+
* \`FeedItemType\` import above.
497+
*/
498+
export interface FeedItem {
499+
id: string;
500+
type: FeedItemType;
501+
createdAt: string;
502+
}
503+
`,
504+
},
505+
({ 'views.ts': file }) => expect(scan(file)).toEqual([])
506+
);
507+
});
508+
509+
/**
510+
* The fixtures above are a copy of the idiom; these two are the REAL FILES.
511+
* A copy can drift from what shipped, and the guarantee #4607 owes #4606 is
512+
* about the tree, not about a paraphrase of it: the sharpened rule must be
513+
* green on the very comments that card wrote.
514+
*
515+
* If one of these ever fails, read it as a defect in the RULE first. The
516+
* rewordings are the honest retirement record the guard exists to produce, so
517+
* a rule that flags them has turned on its own remedy.
518+
*/
519+
const REWORDED_BY_4606 = [
520+
'../../packages/types/src/views.ts',
521+
'../../packages/i18n/src/utils/spec-formatters.ts',
522+
];
523+
524+
it.each(REWORDED_BY_4606)('stays green on the real tree: %s', (rel) => {
525+
expect(scan(path.join(here, rel))).toEqual([]);
526+
});
527+
528+
it('and those files still carry the provenance the pin is about', () => {
529+
// Deleting the comments outright would satisfy the pin above while throwing
530+
// away the record. Pin the idiom's load-bearing phrases too.
531+
const views = fs.readFileSync(path.join(here, REWORDED_BY_4606[0]), 'utf8');
532+
const i18n = fs.readFileSync(path.join(here, REWORDED_BY_4606[1]), 'utf8');
533+
expect(views).toContain('went with the 16.0.0 feed removal');
534+
expect(views).toContain('`FeedItemType` and `FeedFilterMode` were deliberately KEPT');
535+
expect(i18n).toContain('authored against the protocol');
536+
expect(i18n).toContain('retired in');
537+
});
538+
});
539+
312540
// ── The claim detector itself ────────────────────────────────────────────────
313541

314542
describe('findClaim', () => {
@@ -351,6 +579,47 @@ describe('findClaim', () => {
351579
it('every documented pattern is case-insensitive', () => {
352580
for (const pattern of CLAIM_PATTERNS) expect(pattern.flags, String(pattern)).toContain('i');
353581
});
582+
583+
it("takes cited symbols from the mention's own sentence, not the next one", () => {
584+
// Live case: `ActionDef` (packages/core/src/actions/ActionRunner.ts) reads
585+
// "…mirroring `@objectstack/spec`'s `ActionSchema`. Open key set on a data
586+
// bag is correct". `Open` opens the NEXT sentence and is prose, not a
587+
// citation. This was harmless while `symbols` only decorated the failure
588+
// message; since objectui#4607 it decides whether the tie test applies, so a
589+
// scraped prose word could make a green declaration read as citing nothing
590+
// but symbols the spec does not export.
591+
const claim = findClaim(
592+
"/** A declared metadata contract mirroring `@objectstack/spec`'s `ActionSchema`. Open key set on a data bag is correct. */"
593+
);
594+
expect(claim).not.toBeNull();
595+
expect(claim!.symbols).toEqual(['ActionSchema']);
596+
});
597+
598+
it('still reads a symbol followed by a dotted member path', () => {
599+
// The truncation must not fire on `ListView.navigation` — the `.` there is a
600+
// member separator, not a sentence end, which is why the test is
601+
// "terminator followed by whitespace or end", the same shape the
602+
// claim/mention sentence test uses.
603+
expect(findClaim('/** Aligned with @objectstack/spec ListView.navigation. */')!.symbols).toEqual(['ListView']);
604+
});
605+
606+
it('KNOWN LIMITATION: a sentence that never terminates still donates prose words', () => {
607+
// `PageNodeSchema` (packages/types/src/layout.ts) is the live instance: the
608+
// claim line ends without punctuation and the next line continues "This is
609+
// the SDUI NODE, not the authored page DOCUMENT", so `normalizeDoc` joins
610+
// them into ONE sentence and the window scrapes three prose words.
611+
//
612+
// Pinned rather than fixed, and it costs nothing today: the claim also cites
613+
// `PageSchema`, which the spec DOES export, so the declaration is governed
614+
// by the tie test exactly as before. It would only matter for a comment that
615+
// cites no real symbol at all AND has an incidental live tie — measured at
616+
// zero instances repo-wide (objectui#4607). Tightening it further means
617+
// deciding what a citation LOOKS like, which is a different instrument.
618+
const claim = findClaim(
619+
'/**\n * Aligned with @objectstack/spec PageSchema\n *\n * This is the SDUI NODE, not the authored page DOCUMENT\n */'
620+
);
621+
expect(claim!.symbols).toEqual(['PageSchema', 'This', 'SDUI', 'NODE']);
622+
});
354623
});
355624

356625
describe('normalizeDoc', () => {

0 commit comments

Comments
 (0)