@@ -70,21 +70,36 @@ const SPEC_SRC_ROOT = path.resolve(HERE, '..');
7070/** #7030: `packages/lint/src`, the one other corpus carrying this sentence. */
7171const LINT_SRC_ROOT = path . resolve ( HERE , '../../../lint/src' ) ;
7272/**
73- * [#10848] The population widens by EXACTLY ONE governed file (maintainer
74- * ruling 2026-08-22, deliberately not all of `.claude/** `): the retirement
73+ * [#10848] The population widened by EXACTLY ONE governed file (maintainer
74+ * ruling 2026-08-22, deliberately not all of `.claude/`): the retirement
7575 * playbook every new tombstone's guidance string is authored from. It sat
7676 * outside both corpora and prescribed the withdrawn sentence, so the skill
7777 * taught authors to red this very pin — and a red pin over a skill-taught
7878 * sentence invites weakening the PIN rather than the skill. It cannot ride
7979 * the corpus walk: it is markdown (the walk yields `.ts` only), its `--from`
8080 * operand is a placeholder like `<N-1>` (never `\d+`), its sentences end at a
8181 * code-span close (never at a string-literal quote), and `reconstruct()`
82- * would drop every markdown line that opens with `*`/`**` . So it is judged
82+ * would drop every markdown line that opens with an asterisk . So it is judged
8383 * below as its own corpus: raw text, whitespace-normalised, with
8484 * placeholder-aware anchors — the withdrawn-claim direction reuses
8585 * `WITHDRAWN_CLAIM` verbatim.
86+ *
87+ * [#13859] ONE hard-coded path is a population of one, and the sentence does
88+ * not stay inside it: the withdrawn automatic-rewrite claim reached the
89+ * PUBLISHED skill catalog — the artifacts a customer agent actually loads, and
90+ * that land in codebases this repo cannot see — with nothing scanning them.
91+ * The corpus below is therefore DISCOVERED rather than named: this playbook
92+ * plus every `.md` file under `skills/`. Discovery is the input, so it is
93+ * itself asserted non-vacuous — a walk that silently reached zero published
94+ * files would leave this pin exactly as narrow as it was, while reading green.
8695 */
8796const RETIREMENT_SKILL_MD = path . resolve ( HERE , '../../../../.claude/skills/spec-property-retirement/SKILL.md' ) ;
97+ /**
98+ * [#13859] The published catalog root. Discovered, never listed: a checked-in
99+ * file list is a second population to keep in sync, and the one that rots is
100+ * always the list.
101+ */
102+ const PUBLISHED_SKILLS_ROOT = path . resolve ( HERE , '../../../../skills' ) ;
88103
89104/** One scanned corpus: a root directory, plus its own out-of-scope exemptions. */
90105interface Corpus {
@@ -388,62 +403,185 @@ describe('`os migrate meta` sentences are the house sentence, across corpora (#6
388403} ) ;
389404
390405/**
391- * [#10848] Markdown-corpus anchors for the retirement skill (see the
392- * `RETIREMENT_SKILL_MD` docblock). Same two legal shapes as
393- * `HOUSE_AT_MARKER`/`MIXED_AT_MARKER`, adapted on exactly three axes: the
394- * `--from` operand may be a placeholder (`<N-1>`, `<N>`) as well as a literal
395- * major; the judged text is the whole file with runs of whitespace collapsed
396- * (markdown wraps sentences mid-clause); and "last sentence of its literal"
397- * becomes "last sentence of its double-backtick code span" (`.``), so prose
398- * cannot bury the command mid-span either. The marker requires the leading
399- * `Run` on purpose: the skill legitimately NAMES the command mid-prose
400- * (`migrate meta --from <old>` in §3's `retiredFromLoadPath` bullet) without
401- * prescribing a sentence — only taught sentence templates are judged.
406+ * [#10848] Markdown-corpus anchors (see the `RETIREMENT_SKILL_MD` docblock).
407+ * Same two legal shapes as `HOUSE_AT_MARKER`/`MIXED_AT_MARKER`, adapted on
408+ * exactly three axes: the `--from` operand may be a placeholder (`<N-1>`,
409+ * `<N>`) as well as a literal major; the judged text is the whole file with
410+ * runs of whitespace collapsed (markdown wraps sentences mid-clause); and
411+ * "last sentence of its literal" becomes "last sentence of its container".
412+ *
413+ * The marker requires the leading `Run` on purpose: a skill legitimately NAMES
414+ * the command mid-prose (`migrate meta --from <old>` in the playbook's §3
415+ * `retiredFromLoadPath` bullet; a dozen bare `os migrate meta …` invocations in
416+ * the published upgrade skill) without prescribing a sentence — only sentences
417+ * are judged. That property is what makes the widened population safe, so it
418+ * stays.
419+ *
420+ * [#13859] "Container" is TWO things once the corpus is more than the playbook,
421+ * and one anchor for both would be a false-positive machine. A taught TEMPLATE
422+ * is written as a double-backtick code span, so its sentence must close that
423+ * span (`.` then the span's two backticks) — prose cannot bury the command
424+ * mid-span, which is the property the source corpus gets from its closing
425+ * quote. A sentence QUOTED in prose or inside a fenced transcript has no span
426+ * to close: the published upgrade skill quotes the house sentence verbatim
427+ * inside a rendered parse error, whose next line is `expected: never`. Under a
428+ * span-only anchor every such occurrence is an unconditional RED — not a
429+ * property, since nothing but a code span could ever satisfy it, but a trap
430+ * that fires the moment the corpus stops being one file. So a non-span
431+ * occurrence is judged for its WORDING, ending at its own period. What that
432+ * gives up, deliberately and only in prose: burying (`… apply them by hand.
433+ * Also do X.`) passes there, while it stays RED inside a template and in every
434+ * `.ts` prescription above — the two places a reader copies text from.
402435 */
403436const SKILL_FROM_OPERAND = / (?: \d + | < [ ^ > ` ] + > ) / . source ;
404437const SKILL_MARKER = new RegExp (
405438 `Run \`os migrate meta --from ${ SKILL_FROM_OPERAND } \`` ,
406439 'g' ,
407440) ;
408- const SKILL_HOUSE_AT_MARKER = new RegExp (
409- `^Run \`os migrate meta --from ${ SKILL_FROM_OPERAND } \` to list the mechanical edits for existing sources; apply them by hand\\.\`\`` ,
410- ) ;
411- const SKILL_MIXED_AT_MARKER = new RegExp (
412- `^Run \`os migrate meta --from ${ SKILL_FROM_OPERAND } \` to list the mechanical edits for the [^;]+ case[^;]*; [^;]+\\.\`\`` ,
413- ) ;
441+ /** The two legal wordings, from the marker up to the sentence's final period. */
442+ const SKILL_HOUSE_BODY = `^Run \`os migrate meta --from ${ SKILL_FROM_OPERAND } \` to list the mechanical edits for existing sources; apply them by hand\\.` ;
443+ const SKILL_MIXED_BODY = `^Run \`os migrate meta --from ${ SKILL_FROM_OPERAND } \` to list the mechanical edits for the [^;]+ case[^;]*; [^;]+?\\.` ;
444+ /** Container-final anchors: a code span closes; a quoted sentence just ends. */
445+ const SKILL_TEMPLATE_END = '``' ;
446+ const SKILL_PROSE_END = '(?:\\s|$)' ;
447+ const SKILL_HOUSE_TEMPLATE = new RegExp ( `${ SKILL_HOUSE_BODY } ${ SKILL_TEMPLATE_END } ` ) ;
448+ const SKILL_MIXED_TEMPLATE = new RegExp ( `${ SKILL_MIXED_BODY } ${ SKILL_TEMPLATE_END } ` ) ;
449+ const SKILL_HOUSE_PROSE = new RegExp ( `${ SKILL_HOUSE_BODY } ${ SKILL_PROSE_END } ` ) ;
450+ const SKILL_MIXED_PROSE = new RegExp ( `${ SKILL_MIXED_BODY } ${ SKILL_PROSE_END } ` ) ;
451+
452+ /** Label prefix for the internal playbook, mirroring the `spec:`/`lint:` convention. */
453+ const PLAYBOOK_LABEL = 'internal:spec-property-retirement/SKILL.md' ;
454+
455+ interface MarkdownFile {
456+ /** Corpus-prefixed path, e.g. `skills:objectstack-upgrade/SKILL.md`. */
457+ file : string ;
458+ /** Whole file, runs of whitespace collapsed. */
459+ flat : string ;
460+ }
414461
415- describe ( 'the spec-property-retirement skill agrees with this pin (#10848 — the one-file population widening)' , ( ) => {
416- const raw = fs . readFileSync ( RETIREMENT_SKILL_MD , 'utf8' ) ;
417- const flat = raw . replace ( / \s + / g, ' ' ) ;
418-
419- it ( 'every prescription sentence the skill teaches is house-form or MIXED two-clause' , ( ) => {
420- const bad = [ ...flat . matchAll ( SKILL_MARKER ) ]
421- . filter ( ( m ) => {
422- const rest = flat . slice ( m . index ?? 0 ) ;
423- return ! ( SKILL_HOUSE_AT_MARKER . test ( rest ) || SKILL_MIXED_AT_MARKER . test ( rest ) ) ;
424- } )
425- . map ( ( m ) => flat . slice ( m . index ?? 0 , ( m . index ?? 0 ) + 120 ) ) ;
426- expect ( bad , bad . join ( '\n' ) ) . toEqual ( [ ] ) ;
462+ /**
463+ * [#13859] Deterministic markdown discovery. `readdirSync(…).sort()` so the
464+ * corpus order — and therefore every failure message — is identical on every
465+ * machine and every run. `lstatSync` so a symlink is SKIPPED rather than
466+ * followed: a link pointing outside the repo would make the corpus depend on
467+ * the checkout around it, and following one back inside would judge a file
468+ * twice. Nothing else is skipped, and nothing is skipped silently — the
469+ * discovery anti-vacuity assertion below is what notices when it is.
470+ */
471+ function * walkMarkdown ( dir : string ) : Generator < string > {
472+ for ( const name of fs . readdirSync ( dir ) . sort ( ) ) {
473+ const p = path . join ( dir , name ) ;
474+ const st = fs . lstatSync ( p ) ;
475+ if ( st . isSymbolicLink ( ) ) continue ;
476+ if ( st . isDirectory ( ) ) yield * walkMarkdown ( p ) ;
477+ else if ( st . isFile ( ) && p . endsWith ( '.md' ) ) yield p ;
478+ }
479+ }
480+
481+ /** The internal retirement playbook, then the published catalog in sorted order. */
482+ function markdownCorpus ( ) : MarkdownFile [ ] {
483+ const files : Array < { file : string ; abs : string } > = [ { file : PLAYBOOK_LABEL , abs : RETIREMENT_SKILL_MD } ] ;
484+ for ( const abs of walkMarkdown ( PUBLISHED_SKILLS_ROOT ) ) {
485+ files . push ( { file : `skills:${ path . relative ( PUBLISHED_SKILLS_ROOT , abs ) } ` , abs } ) ;
486+ }
487+ return files . map ( ( { file, abs } ) => ( { file, flat : fs . readFileSync ( abs , 'utf8' ) . replace ( / \s + / g, ' ' ) } ) ) ;
488+ }
489+
490+ interface MarkdownSite {
491+ file : string ;
492+ /** True when the marker opens a double-backtick code span — a taught template. */
493+ template : boolean ;
494+ excerpt : string ;
495+ ok : boolean ;
496+ }
497+
498+ /** Every `os migrate meta` prescription sentence in one markdown file, with its verdict. */
499+ function judgeMarkdownSentences ( { file, flat } : MarkdownFile ) : MarkdownSite [ ] {
500+ return [ ...flat . matchAll ( SKILL_MARKER ) ] . map ( ( m ) => {
501+ const at = m . index ?? 0 ;
502+ const rest = flat . slice ( at ) ;
503+ const template = flat . slice ( 0 , at ) . endsWith ( '``' ) ;
504+ const ok = template
505+ ? SKILL_HOUSE_TEMPLATE . test ( rest ) || SKILL_MIXED_TEMPLATE . test ( rest )
506+ : SKILL_HOUSE_PROSE . test ( rest ) || SKILL_MIXED_PROSE . test ( rest ) ;
507+ return { file, template, excerpt : rest . slice ( 0 , 120 ) , ok } ;
508+ } ) ;
509+ }
510+
511+ describe ( 'the retirement playbook and the published skill catalog agree with this pin (#10848, corpus widened #13859)' , ( ) => {
512+ const corpus = markdownCorpus ( ) ;
513+
514+ it ( '[#13859] anti-vacuity for the DISCOVERY: the playbook and at least one published skill' , ( ) => {
515+ // The corpus is this suite's input, so a walk that reached zero published
516+ // files would restore the exact one-file blindness #13859 is about — and
517+ // every assertion below would stay green while it did. Assert the shape of
518+ // the population itself, not a count that ages out with the catalog.
519+ expect ( corpus . length ) . toBeGreaterThan ( 0 ) ;
520+ const labels = corpus . map ( ( c ) => c . file ) ;
521+ expect ( labels ) . toContain ( PLAYBOOK_LABEL ) ;
522+ const published = labels . filter ( ( l ) => l . startsWith ( 'skills:' ) ) ;
523+ expect ( published . length ) . toBeGreaterThanOrEqual ( 1 ) ;
524+ // Deterministic ordering: the published tail is sorted, so a failure names
525+ // its sites in a stable order rather than in readdir order.
526+ expect ( published ) . toEqual ( [ ...published ] . sort ( ) ) ;
527+ } ) ;
528+
529+ it ( 'every prescription sentence in the markdown corpus is house-form or MIXED two-clause' , ( ) => {
530+ const bad = corpus . flatMap ( judgeMarkdownSentences ) . filter ( ( s ) => ! s . ok ) ;
531+ expect (
532+ bad ,
533+ bad . map ( ( s ) => `${ s . file } [${ s . template ? 'template' : 'prose' } ] — "${ s . excerpt } "` ) . join ( '\n' ) ,
534+ ) . toEqual ( [ ] ) ;
427535 } ) ;
428536
429- it ( 'anti-vacuity: the skill teaches BOTH shapes, so the scan judges at least two sites' , ( ) => {
430- // Convention 5 carries the house template and its one allowed variant
431- // (the partial-conversion two-clause shape). Zero or one marker means the
432- // skill stopped teaching a shape — or this scan went blind on the file.
433- const rests = [ ...flat . matchAll ( SKILL_MARKER ) ] . map ( ( m ) => flat . slice ( m . index ?? 0 ) ) ;
537+ it ( 'anti-vacuity: the PLAYBOOK teaches BOTH shapes, so the scan judges at least two sites' , ( ) => {
538+ // Pinned to the one file that OWNS both templates — never a whole-corpus
539+ // claim. Convention 5 carries the house template and its one allowed
540+ // variant (the partial-conversion two-clause shape); zero or one marker
541+ // means the playbook stopped teaching a shape, or this scan went blind on
542+ // it. A published skill that names the command once is not a regression,
543+ // so widening this floor to the corpus would assert nothing and fail on
544+ // the catalog's own editorial choices.
545+ const playbook = corpus . find ( ( c ) => c . file === PLAYBOOK_LABEL ) ;
546+ expect ( playbook , PLAYBOOK_LABEL ) . toBeDefined ( ) ;
547+ const rests = [ ...playbook ! . flat . matchAll ( SKILL_MARKER ) ] . map ( ( m ) => playbook ! . flat . slice ( m . index ?? 0 ) ) ;
434548 expect ( rests . length ) . toBeGreaterThanOrEqual ( 2 ) ;
435- expect ( rests . some ( ( r ) => SKILL_HOUSE_AT_MARKER . test ( r ) ) ) . toBe ( true ) ;
436- expect ( rests . some ( ( r ) => SKILL_MIXED_AT_MARKER . test ( r ) ) ) . toBe ( true ) ;
549+ expect ( rests . some ( ( r ) => SKILL_HOUSE_TEMPLATE . test ( r ) ) ) . toBe ( true ) ;
550+ expect ( rests . some ( ( r ) => SKILL_MIXED_TEMPLATE . test ( r ) ) ) . toBe ( true ) ;
437551 } ) ;
438552
439- it ( '[#9529] the withdrawn automatic-rewrite claim is absent from the skill , in every spelling' , ( ) => {
553+ it ( '[#9529] the withdrawn automatic-rewrite claim is absent from the corpus , in every spelling' , ( ) => {
440554 // Judged over the raw text rather than reconstruct(): a markdown line
441- // opening with `*`/`**` would be dropped as a comment line, hiding a
442- // claim. WITHDRAWN_CLAIM is English-only, so the skill's Chinese prose
443- // cannot fabricate a match; a hit is a real regression of the ruling.
444- const claims = [ ...flat . matchAll ( WITHDRAWN_CLAIM ) ] . map (
445- ( m ) => flat . slice ( Math . max ( 0 , ( m . index ?? 0 ) - 60 ) , ( m . index ?? 0 ) + 60 ) ,
555+ // opening with an asterisk would be dropped as a comment line, hiding a
556+ // claim. WITHDRAWN_CLAIM is English-only BY DESIGN — the playbook's
557+ // Chinese prose (and any that follows it into the catalog) cannot
558+ // fabricate a match, so a hit is a real regression of the ruling. This is
559+ // the direction #13859 widened for: the claim is red wherever it appears,
560+ // and "wherever" now includes the published catalog.
561+ const claims = corpus . flatMap ( ( { file, flat } ) =>
562+ [ ...flat . matchAll ( WITHDRAWN_CLAIM ) ] . map (
563+ ( m ) => `${ file } — "${ flat . slice ( Math . max ( 0 , ( m . index ?? 0 ) - 60 ) , ( m . index ?? 0 ) + 60 ) } "` ,
564+ ) ,
446565 ) ;
447566 expect ( claims , claims . join ( '\n' ) ) . toEqual ( [ ] ) ;
448567 } ) ;
568+
569+ it ( '[#13859] the markdown judge is not vacuous — template and prose anchors each hold' , ( ) => {
570+ const judge = ( flat : string ) : MarkdownSite [ ] => judgeMarkdownSentences ( { file : 'synthetic.md' , flat } ) ;
571+ const house = 'Run `os migrate meta --from <N-1>` to list the mechanical edits for existing sources; apply them by hand.' ;
572+ const mixed = 'Run `os migrate meta --from 16` to list the mechanical edits for the `1y` case; the rest are reported.' ;
573+ // A taught template must close its code span, in both legal shapes.
574+ expect ( judge ( `5. \`\`${ house } \`\`` ) . map ( ( s ) => [ s . template , s . ok ] ) ) . toEqual ( [ [ true , true ] ] ) ;
575+ expect ( judge ( `\`\`${ mixed } \`\`` ) . map ( ( s ) => [ s . template , s . ok ] ) ) . toEqual ( [ [ true , true ] ] ) ;
576+ // …and a template that buries the command mid-span stays RED.
577+ expect ( judge ( `\`\`${ house } Also do X.\`\`` ) . map ( ( s ) => s . ok ) ) . toEqual ( [ false ] ) ;
578+ // A sentence quoted in prose or a transcript is judged on wording alone.
579+ expect ( judge ( `error text … ${ house } expected: never` ) . map ( ( s ) => [ s . template , s . ok ] ) ) . toEqual ( [ [ false , true ] ] ) ;
580+ expect ( judge ( 'Run `os migrate meta --from 16` to rewrite it automatically.' ) . map ( ( s ) => s . ok ) ) . toEqual ( [ false ] ) ;
581+ expect ( judge ( 'Run `os migrate meta --from 16` to remove it.' ) . map ( ( s ) => s . ok ) ) . toEqual ( [ false ] ) ;
582+ // Naming the command mid-prose without the leading `Run` is not a sentence.
583+ expect ( judge ( 'Stored flows convert with `os migrate meta --from 16`.' ) ) . toEqual ( [ ] ) ;
584+ // The withdrawn claim trips wherever it appears, fence or prose.
585+ expect ( [ ...'``os migrate meta --from 16` rewrites your source files.``' . matchAll ( WITHDRAWN_CLAIM ) ] ) . not . toEqual ( [ ] ) ;
586+ } ) ;
449587} ) ;
0 commit comments