Skip to content

Commit db374e2

Browse files
committed
test(spec): discover the migrate-sentence markdown corpus instead of naming one file
The retired-key migrate-sentence pin scanned exactly one markdown file (`.claude/skills/spec-property-retirement/SKILL.md`), so the withdrawn "rewrite it automatically" claim could reach the published skill catalog — the artifacts a customer agent actually loads — with nothing scanning it. The markdown corpus is now DISCOVERED: the internal retirement playbook plus every `.md` file under `skills/`. Discovery is sorted (stable failure messages), skips symlinks rather than following one out of the repo, and is itself asserted non-vacuous — it must contain the playbook and at least one published skill, so a walk that silently reached zero published files cannot read green. The two-shape judge keeps its leading-`Run` requirement (naming the command mid-prose is not a prescription) and gains the distinction the wider population needs: a taught TEMPLATE is a double-backtick code span and must close it, while a sentence quoted in prose or a rendered transcript ends at its own period. Under a span-only anchor every quoted occurrence is an unconditional RED — the published upgrade skill quotes the house sentence verbatim inside an example parse error — which is a trap, not a property. Burying stays RED inside a template and in every `.ts` prescription. The BOTH-shapes anti-vacuity floor stays pinned to the playbook, the one file that owns both templates; a published skill that names the command once is not a regression. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
1 parent 4a37870 commit db374e2

1 file changed

Lines changed: 183 additions & 45 deletions

File tree

packages/spec/src/shared/retired-key-migrate-sentence.test.ts

Lines changed: 183 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -70,21 +70,36 @@ const SPEC_SRC_ROOT = path.resolve(HERE, '..');
7070
/** #7030: `packages/lint/src`, the one other corpus carrying this sentence. */
7171
const 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
*/
8796
const 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. */
90105
interface 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
*/
403436
const SKILL_FROM_OPERAND = /(?:\d+|<[^>`]+>)/.source;
404437
const 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

Comments
 (0)