@@ -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+ */
5771const 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
6281const 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
314542describe ( '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
356625describe ( 'normalizeDoc' , ( ) => {
0 commit comments