Skip to content

Commit 4e6b270

Browse files
tooling(pm): check-skill-id-lint scans the published skills/ catalog too (#23002)
Fixes #22983 Clause-②: no — a repository gate script; no published schema, accept set or public surface is touched ## What changes `scripts/pm/check-skill-id-lint.mjs` gains a second scan root for the published catalog: `PUBLISHED_SCAN_ROOT = 'skills'`, declared beside `SCAN_ROOT` (which stays the internal corpus `.claude/skills/pm-dispatch`), walked by the same `mdFilesUnder` walker and held to the same cannot-read-is-red rule. The header's "What it scans" paragraph names the new root and why: its reader is a customer's agent with no tool that resolves an issue number into this tracker, so the 2026-08-12 ruling applies a fortiori. `ID_PATTERN`, the `LEGACY_EXACT` mechanism, `verdictFor` and its messages, and the internal corpus's semantics are unchanged. `run` is split into `lintTree(root)` (walk + verdicts, returned as data) and a printer, so the self-test drives the real walk over throwaway fixture trees instead of only the per-file verdict. ## The premise that did not hold: "0 hits today" The card and the dispatch expected `git grep -c -E '#[0-9]{3,}' origin/main -- 'skills/**'` to answer 0 files. At this branch's base `6a40a09ed`, and at the card's own reading commit `246edf5b3`, it answers **2 files**. Both hits are CSS hex colours, not citations: - `skills/objectstack-data/rules/field-types.md:86`: `color: '#3498db'` - `skills/objectstack-ui/rules/navigation.md:20`: `primaryColor: '#4169E1'` So the widening as specified would have landed red on two colours. Ablation 1 below shows exactly that. The dispatch's own hex probe (`git grep -n -E '#[0-9a-fA-F]{6}\b' -- 'skills/**'`) answers 10 lines in 5 files at the base. The route stays inside the ruling: no change to `ID_PATTERN`, `LEGACY_EXACT` or the internal corpus's semantics, and no edit to `skills/**`. **Under `skills/` only**, a match whose digit run continues straight into a letter or underscore is not counted (`NOT_A_CITATION_TAIL`), because the digits are the head of a longer word and name no number. A citation keeps a non-word neighbour (`#1234.`, `(#1234)`, end of line) and stays red. The shape that remains is an all-decimal colour (`#000000`). The header names it as a known false positive and the self-test pins it red, so the first real one gets decided with the hit in hand. ## Derivation readings (`scripts/pm/dispatch-gates.mjs`, frozen files untouched) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack skills/objectstack-data/SKILL.md` - before, at `6a40a09ed`: `30 command(s) — 21 pnpm, 9 direct node (22 matched by path, …)`, and `pnpm check:pm-skill-id-lint` is **absent** - after, at `0a07f2f2c` and again at the final `6744bf9bc` (identical lists): `31 command(s) — 22 pnpm, 9 direct node (23 matched by path, …)`. The diff of the two lists is exactly one added line, `pnpm check:pm-skill-id-lint` The extractor refuses the bare `'skills'` as too generic (it has no separator), so the gate declares `ROOT_DIR_WATCH_HINTS = ['skills/**/*.md']` beside the constant. This is the same idiom other gates use. `extractWatchHints` over the gate now answers `.claude/skills/pm-dispatch`, `.claude/agents/os-dev.md`, `skills/**/*.md`, `AGENTS.md/**`. The hint covers 53 of the 65 tracked files under `skills/`, and that set equals the gate's own walk in both directions. It passes over the 12 eval JSON files, which the gate never opens. ## Second file: `scripts/pm/bare-root-worklist.mjs` (outside the claim's declared file surface) `PUBLISHED_SCAN_ROOT = 'skills'` is a bare-root population literal. At `0a07f2f2c`, `node scripts/pm/bare-root-worklist.mjs --self-test` therefore refused it as FRESH (2 failures), and its printed remedy is to record a verdict. The row now takes `DECLARED-NARROWER` with a new pinned spelling, `published skill markdown` (segments skills / ** / *.md). The worklist self-test holds it LIVE, PRECISE, COMPLETE and SET-EQUAL to the gate's declared array: `OK self-test: 82 live row(s), … 47 recorded verdict(s) — none stale, none missing, none contradicted`. The alternative would declare `skills/**` wholesale. That makes the row REACHABLE with no ledger entry and keeps the diff to one file. It was refused because it names the gate for 12 eval JSON files it never reads, which the worklist's own refusal text calls "the costlier error". Renaming the constant so the worklist cannot see it was not considered: that file's header refuses that dodge by name. ## Readings - **File count.** The gate before the change printed `✓ check-skill-id-lint: 34 file(s) clean`. After, it prints `✓ check-skill-id-lint: 87 file(s) clean`, a gain of **+53**. There are 53 `.md` files under `skills/` by `git ls-files skills | grep -c '\.md$'` (and by `find`). The dispatch's `git ls-files 'skills/**/*.md' | wc -l` answers **52**: under git's default pathspec, `**/` needs a separator after `skills/`, so that count misses the catalog's top-level `skills/README.md`. The walker reads that file, which is correct. - **Self-test.** `✓ check-skill-id-lint self-test: 23 cases pass.` That is the 14 existing rows plus 9 new ones, and the floor pin moves 14 to 23. The new rows: a `.md` under `skills/` carrying `#1234` is red and names that file; one carrying none is green and walked (4 scanned in the fixture); a letter-tailed colour is green; an all-decimal colour stays red; the internal corpus still reds the same colour; a missing `skills/` is red, not a skip; plus three rows pinning the watch-hint declaration (declared for the root, names no other root, not itself the walk root). ## Ablations (one-shot, on committed state, restore proven) Both mutations go through `scripts/ablation-replace.mjs` in WRAP mode, with a shell `trap` restore on top. The gate file is byte-identical at the final head (blob `9a1b0df4`). 1. **Hex rule disabled** (`if (published && NOT_A_CITATION_TAIL` changed to `if (false && NOT_A_CITATION_TAIL`; anchor count 1 to 0, blob `9a1b0df4` to `235341e2`). The self-test reds `published root: a colour whose digits run into a letter -> green`. The gate goes red on exactly the two colour lines above. Restored: blob equals HEAD and `git diff HEAD` is empty. 2. **Published root dropped from the walk** (`[SCAN_ROOT, PUBLISHED_SCAN_ROOT]` changed to `[SCAN_ROOT]`). The self-test reds 5 rows: `#1234` red, carrying none is green and walked, the colour row, the all-decimal row, and the missing-root row. The gate itself falls back to `34 file(s) clean`, so the self-test is what catches this. Restored the same way. ## Gates: union at `6744bf9bc` (final commit) The command list comes from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths (change set 2 paths vs merge base `6a40a09ed`). All 30 exited 0. Reconciled: `✓ dispatch-gates --ran: 30 derived famil(ies) accounted for — 30 run, 0 NOT-MEASURED (a DERIVED zero — all 30 recorded an exit code and none of them is 3).` - `pnpm check:pm-skill-id-lint`: `✓ check-skill-id-lint: 87 file(s) clean (pattern /#[0-9]{3,}/g).` - `pnpm check:watch-hint-literal`: `✓ check-watch-hint-literal: 73 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 49, …` - `pnpm check:pm-dispatch-gates`: `✓ dispatch-gates self-test (fast tier): 1828 cases pass; 6 slow section(s) deferred.`, exit 0 - `node scripts/pm/bare-root-worklist.mjs --self-test`: `OK self-test: 82 live row(s), 60 unreachable as spelled, 47 recorded verdict(s) — none stale, none missing, none contradicted …` - `pnpm check:nul-bytes`: `check-nul-bytes: OK (scanned 11000 text file(s) …; no raw ASCII control bytes).` - The other 25 also exited 0: ci-filter-parity, closing-keyword-parity (+ self-test), comment-mask-corpus, declaration-mirrors (+ self-test), scripts-symbol-anchors (+ self-test), self-test-wired (+ self-test), self-test-workflow-commands (+ self-test), whole-set-label-write (+ self-test), agent-test-spelling, bash32-floor, cli-command-ids, cross-package-test-inputs, driver-memory-census, entry-guard, gitlink-declared, parse-guard, pnpm-filter-targets, ratchet-remedy-authority, refd-timer-probe. - Lint, narrowed and proven at `6744bf9bc`. ESLint reports 2 files, 0 errors and 0 warnings (counted from its `--format json` output, with `--no-inline-config`). Both files are in the config's population (`isPathIgnored` answers false for each). The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. - NOT MEASURED, left to CI: the `Test Core` job these paths schedule (7 CI-only steps, no local invocation), and the four type-check lanes. This diff touches no TypeScript. ## Acceptance notes - The comment above the `PM skill issue-ID lint` step in `.github/workflows/lint.yml` still lists the scan set as the pm-dispatch skill plus `os-dev.md`, and mentions "the one self-expiring legacy waiver", which no longer exists. It already omitted `AGENTS.md` before this change. The comment defers to the script header as authoritative, and it is outside this card's file surface, so it is left as is. - No changeset: `scripts/pm/**` publishes nothing, so this takes `skip-changeset`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CinRQcE6T8SvMbhH8D3NhS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 794177d commit 4e6b270

2 files changed

Lines changed: 185 additions & 40 deletions

File tree

‎scripts/pm/bare-root-worklist.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,11 @@ const SPELLINGS = new Map([
539539
claim: "every file inside a skill's references folder, at any depth",
540540
holds: (s) => s[0] === 'skills' && s.length >= 4 && s[2] === 'references',
541541
}],
542+
['published skill markdown', {
543+
segments: ['skills', '**', '*.md'],
544+
claim: 'every `.md` file at any depth under the skills root, the README at its top included',
545+
holds: (s) => s[0] === 'skills' && s.length >= 2 && s[s.length - 1].endsWith('.md'),
546+
}],
542547
['driver package files', {
543548
// The WHOLE subtree, which is what the gate declares -- and the widest of
544549
// the entries here for that reason. Its row's `why` records the direction
@@ -775,6 +780,22 @@ const TRIAGE = new Map([
775780
+ 'can drift from the scan is worse than none. The row stays in the sweep: the bare root is '
776781
+ 'still not covered',
777782
}],
783+
['scripts/pm/check-skill-id-lint.mjs PUBLISHED_SCAN_ROOT skills', {
784+
verdict: 'DECLARED-NARROWER',
785+
spelling: 'published skill markdown',
786+
why: 'written with the constant, on 2026-10-12, when the maintainer ruled the published catalog '
787+
+ 'into this gate scan set as a widening rather than a new gate. Measured at commit 0a07f2f2c '
788+
+ 'by driving the gate own lintTree() over the repo root: its walk reads 53 markdown files '
789+
+ 'under the root, and the recorded spelling reaches exactly those 53 of the 65 tracked there, '
790+
+ '81.5%, SET-EQUAL in both directions. The 12 it passes over are the eval JSON files in each '
791+
+ 'skill evals folder, which the gate never opens — so declaring the bare root wholesale would '
792+
+ 'name it for files it does not read, the costlier error this map names. The consumer is '
793+
+ 'that ruling itself and it is measured, not argued: before the declaration the derivation '
794+
+ 'for a change set holding one skill entrypoint printed 30 commands and not this gate, after '
795+
+ 'it 31 with this gate among them. The row STAYS in the sweep because the bare root is still '
796+
+ 'not covered — no arbitrary file at the top of the root is reached — which is what this '
797+
+ 'verdict says and is correct, not outstanding debt',
798+
}],
778799
['scripts/check-dual-build-cjs-loads.mjs SCAN_ROOT packages', {
779800
verdict: 'DECLARED-NARROWER',
780801
spelling: 'dual-build manifests and configs',

‎scripts/pm/check-skill-id-lint.mjs‎

Lines changed: 164 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,44 @@
2020
*
2121
* ## What it scans
2222
*
23-
* Every .md file under .claude/skills/pm-dispatch/ (the PM operating protocol,
24-
* including its references/), plus .claude/agents/os-dev.md (the dev-side twin)
25-
* and AGENTS.md (the repo-wide agent instruction file, rewritten to the same
26-
* principles-only standard). The pattern is /#[0-9]{3,}/ — real issue/PR numbers
27-
* in this repo are 3+ digits, while placeholders (`#<n>`, `#N`, `epic:#<n>`),
28-
* Prime-Directive cross-references (`#14`) and small literals (`#12 #34` in the
23+
* Two roots and two files. The internal corpus: every .md file under
24+
* .claude/skills/pm-dispatch/ (the PM operating protocol, including its
25+
* references/), plus .claude/agents/os-dev.md (the dev-side twin) and AGENTS.md
26+
* (the repo-wide agent instruction file, rewritten to the same principles-only
27+
* standard). The published catalog: every .md file under skills/ — the text
28+
* `npx skills add` and `@objectstack/skills` ship into customer projects. The
29+
* ruling above applies there a fortiori: that reader is a customer's agent, with
30+
* no tool that resolves an issue number into this repository's tracker, so a
31+
* citation is a lookup it cannot even attempt, in the one text it was handed to
32+
* act on. The maintainer added it on 2026-10-12 as a widening of THIS gate's
33+
* scan set, not as a new gate.
34+
*
35+
* The pattern is /#[0-9]{3,}/ — real issue/PR numbers in this repo are 3+
36+
* digits, while placeholders (`#<n>`, `#N`, `epic:#<n>`), Prime-Directive
37+
* cross-references (`#14`) and small literals (`#12 #34` in the
2938
* argument-syntax example) stay legal.
3039
*
40+
* ## The hex-colour boundary — published root only
41+
*
42+
* The pattern cannot tell a citation from a CSS hex colour whose first three
43+
* characters are digits, and the published catalog teaches colours. On the tree
44+
* that widened the scan set it carried two, and no citation at all:
45+
* `color: '#3498db'` (a select option, skills/objectstack-data) and
46+
* `primaryColor: '#4169E1'` (branding, skills/objectstack-ui). The bare pattern
47+
* would have landed the widening red on both.
48+
*
49+
* So under skills/ ONLY, a match whose digit run continues straight into a
50+
* letter or an underscore is not counted (`NOT_A_CITATION_TAIL`): the digits
51+
* are the head of a longer word and name no number. A citation keeps a
52+
* non-word neighbour (`#1234.`, `(#1234)`, `#1234,`, end of line) and stays
53+
* red. The internal corpus keeps the bare pattern — it teaches no colours, and
54+
* its semantics are not this widening's to change.
55+
*
56+
* ⚠️ The boundary that remains, named rather than excluded: a colour whose hex
57+
* digits are ALL decimal (`#000000`, `#333`) is byte-for-byte a citation and
58+
* stays red. The first real one in a published skill is the moment to decide
59+
* how to treat that shape, with the hit in hand. The self-test pins it red.
60+
*
3161
* ## The legacy-waiver mechanism (LEGACY_EXACT), currently unused
3262
*
3363
* A file rewritten to this standard in a DIFFERENT PR than the one that adds it
@@ -40,21 +70,44 @@
4070
* its scan-set addition travel in the same PR. The mechanism stays for the next
4171
* split rewrite; the self-test exercises it with a synthetic entry.
4272
*
43-
* Missing file or empty read is RED, never a pass — a gate that cannot find its
44-
* input must fail, not skip.
73+
* Missing file, unreadable root or empty read is RED, never a pass — a gate
74+
* that cannot find its input must fail, not skip. Both roots are held to it.
4575
*/
4676

47-
import { readFileSync, readdirSync, statSync } from 'node:fs';
48-
import { join, relative } from 'node:path';
77+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
78+
import { tmpdir } from 'node:os';
79+
import { dirname, join, relative } from 'node:path';
4980
import { fileURLToPath } from 'node:url';
5081
import process from 'node:process';
5182
import { isEntrypoint } from '../invoked-as.mjs';
5283

5384
const REPO_ROOT = join(fileURLToPath(import.meta.url), '..', '..', '..');
5485

5586
export const SCAN_ROOT = '.claude/skills/pm-dispatch';
87+
// The published catalog (see "What it scans"). Walked with the same walker.
88+
export const PUBLISHED_SCAN_ROOT = 'skills';
5689
export const EXTRA_FILES = ['.claude/agents/os-dev.md', 'AGENTS.md'];
5790
export const ID_PATTERN = /#[0-9]{3,}/g;
91+
// Under PUBLISHED_SCAN_ROOT only: the character right after a match that makes
92+
// it the head of a longer word (a hex colour), not a citation. See the header.
93+
export const NOT_A_CITATION_TAIL = /^[A-Za-z_]$/;
94+
95+
/**
96+
* The published root, declared for `scripts/pm/dispatch-gates.mjs`.
97+
*
98+
* That tool derives a card's gate list from the path literals in each gate's
99+
* own source, and refuses a literal with no separator as too generic to match
100+
* with — so `PUBLISHED_SCAN_ROOT` above, a bare top-level word, reaches no card
101+
* at all. A declared subtree is the form that does, and this one names exactly
102+
* the population `run` reads: .md files under skills/, at any depth (the
103+
* catalog's README.md at its top included), and not the catalog's .json.
104+
*
105+
* ⚠️ Provenance, NOT a lookup key: nothing reads this array to walk; the glob
106+
* form handed to the walker would name a directory that does not exist. The
107+
* literal is written out because a computed one is invisible to the text scan
108+
* it exists for. The self-test pins both halves.
109+
*/
110+
export const ROOT_DIR_WATCH_HINTS = ['skills/**/*.md'];
58111

59112
/**
60113
* The repo-ROOT files above, declared for `scripts/pm/dispatch-gates.mjs`
@@ -115,53 +168,70 @@ export function verdictFor(rel, matchCount) {
115168
return { ok: true };
116169
}
117170

118-
function run() {
171+
// The citations in `text`, a file (or one line of a file) at repo path `rel`.
172+
// The hex-colour boundary applies under the published root and nowhere else.
173+
export function citationCount(text, rel) {
174+
const published = rel.startsWith(`${PUBLISHED_SCAN_ROOT}/`);
175+
let count = 0;
176+
for (const m of text.matchAll(ID_PATTERN)) {
177+
if (published && NOT_A_CITATION_TAIL.test(text.charAt(m.index + m[0].length))) continue;
178+
count++;
179+
}
180+
return count;
181+
}
182+
183+
// The scan over any tree laid out like this repository: `run` hands it the
184+
// repo root, the self-test a fixture tree — so the self-test drives the walk of
185+
// each root, not only the per-file verdict.
186+
export function lintTree(root) {
187+
const failures = []; // { msg, lines } — `lines` names the offending lines
188+
const scanned = [];
119189
const targets = [];
120-
try {
121-
targets.push(...mdFilesUnder(join(REPO_ROOT, SCAN_ROOT)));
122-
} catch {
123-
console.error(`✗ check-skill-id-lint: cannot read ${SCAN_ROOT} — red, not a skip.`);
124-
process.exit(1);
190+
for (const dir of [SCAN_ROOT, PUBLISHED_SCAN_ROOT]) {
191+
try {
192+
targets.push(...mdFilesUnder(join(root, dir)));
193+
} catch {
194+
failures.push({ msg: `cannot read ${dir} — red, not a skip.`, lines: [] });
195+
}
125196
}
126-
for (const extra of EXTRA_FILES) targets.push(join(REPO_ROOT, extra));
197+
for (const extra of EXTRA_FILES) targets.push(join(root, extra));
127198

128-
let failed = 0;
129-
let scanned = 0;
130199
for (const abs of targets) {
131-
const rel = relative(REPO_ROOT, abs).replace(/\\/g, '/');
200+
const rel = relative(root, abs).replace(/\\/g, '/');
132201
let text;
133202
try {
134203
text = readFileSync(abs, 'utf8');
135204
} catch {
136-
console.error(`✗ check-skill-id-lint: cannot read ${rel} — red, not a skip.`);
137-
failed++;
205+
failures.push({ msg: `cannot read ${rel} — red, not a skip.`, lines: [] });
138206
continue;
139207
}
140208
if (text.length === 0) {
141-
console.error(`✗ check-skill-id-lint: ${rel} read as empty — red, not a skip.`);
142-
failed++;
209+
failures.push({ msg: `${rel} read as empty — red, not a skip.`, lines: [] });
143210
continue;
144211
}
145-
scanned++;
146-
const count = (text.match(ID_PATTERN) ?? []).length;
147-
const v = verdictFor(rel, count);
212+
scanned.push(rel);
213+
const v = verdictFor(rel, citationCount(text, rel));
148214
if (!v.ok) {
149-
failed++;
150-
console.error(`✗ check-skill-id-lint: ${v.msg}`);
151215
// name the first few offending lines so the fix needs no second scan
152-
let shown = 0;
153-
const lines = text.split('\n');
154-
for (let i = 0; i < lines.length && shown < 5; i++) {
155-
if (ID_PATTERN.test(lines[i])) {
156-
console.error(` ${rel}:${i + 1}: ${lines[i].trim().slice(0, 120)}`);
157-
shown++;
158-
}
159-
ID_PATTERN.lastIndex = 0;
216+
const lines = [];
217+
const all = text.split('\n');
218+
for (let i = 0; i < all.length && lines.length < 5; i++) {
219+
if (citationCount(all[i], rel) > 0) lines.push(`${rel}:${i + 1}: ${all[i].trim().slice(0, 120)}`);
160220
}
221+
failures.push({ msg: v.msg, lines });
161222
}
162223
}
163-
if (failed) process.exit(1);
164-
console.log(`✓ check-skill-id-lint: ${scanned} file(s) clean (pattern ${ID_PATTERN}).`);
224+
return { failures, scanned };
225+
}
226+
227+
function run() {
228+
const { failures, scanned } = lintTree(REPO_ROOT);
229+
for (const f of failures) {
230+
console.error(`✗ check-skill-id-lint: ${f.msg}`);
231+
for (const line of f.lines) console.error(` ${line}`);
232+
}
233+
if (failures.length) process.exit(1);
234+
console.log(`✓ check-skill-id-lint: ${scanned.length} file(s) clean (pattern ${ID_PATTERN}).`);
165235
}
166236

167237
// Returned by `selfTest()` only after its verdict is printed. The dispatch
@@ -211,6 +281,15 @@ const SELF_TEST_BATTERIES = Object.freeze({
211281
'and the declaration names no file this gate does not scan': 1,
212282
'AGENTS.md is the root file it declares': 1,
213283
'the declared form is NOT an EXTRA_FILES entry': 1,
284+
'published root: a .md carrying #1234 -> red': 1,
285+
'published root: a .md carrying none -> green, and walked': 1,
286+
'published root: a colour whose digits run into a letter -> green': 1,
287+
'published root: an all-decimal colour stays red (the named boundary)': 1,
288+
'internal corpus keeps the bare pattern: the same colour -> red': 1,
289+
'published root missing -> red, not a skip': 1,
290+
'the published root declares a root-dir watch hint': 1,
291+
'and that declaration names no root this gate does not walk': 1,
292+
'the declared form is NOT the walk root itself': 1,
214293
});
215294

216295
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -219,9 +298,40 @@ const SELF_TEST_BATTERIES = Object.freeze({
219298
// the literal above, so the roster falls below this number; the table
220299
// cross-check in the floor block is the other half, and names WHICH label
221300
// collided.
222-
const SELF_TEST_BATTERY_FLOOR = 14;
301+
const SELF_TEST_BATTERY_FLOOR = 23;
223302

224303
function selfTest() {
304+
// The walk over each root, driven through `lintTree` on throwaway trees: a
305+
// verdict on a bare count cannot show that skills/ is walked at all.
306+
const lintFixture = (files) => {
307+
const root = mkdtempSync(join(tmpdir(), 'skill-id-lint-'));
308+
try {
309+
for (const [rel, text] of Object.entries(files)) {
310+
mkdirSync(dirname(join(root, rel)), { recursive: true });
311+
writeFileSync(join(root, rel), text);
312+
}
313+
return lintTree(root);
314+
} finally {
315+
rmSync(root, { recursive: true, force: true });
316+
}
317+
};
318+
const internal = {
319+
'.claude/skills/pm-dispatch/SKILL.md': 'the protocol\n',
320+
'.claude/agents/os-dev.md': 'the dev twin\n',
321+
'AGENTS.md': 'the repo rules\n',
322+
};
323+
const redFor = (result, rel) => result.failures.some((f) => f.msg.startsWith(`${rel}:`));
324+
const cited = lintFixture({ ...internal, 'skills/demo/SKILL.md': 'clean\n', 'skills/demo/rules/x.md': 'fixed in #1234.\n' });
325+
const clean = lintFixture({ ...internal, 'skills/demo/rules/x.md': 'the lesson, self-contained\n' });
326+
const colour = lintFixture({ ...internal, 'skills/demo/SKILL.md': "color: '#3498db', primaryColor: '#4169E1'\n" });
327+
const decimal = lintFixture({ ...internal, 'skills/demo/SKILL.md': "color: '#000000'\n" });
328+
const internalColour = lintFixture({
329+
...internal,
330+
'.claude/skills/pm-dispatch/SKILL.md': "color: '#3498db'\n",
331+
'skills/demo/SKILL.md': 'clean\n',
332+
});
333+
const missing = lintFixture(internal);
334+
225335
// The waiver mechanism is tested with a synthetic entry so it stays covered
226336
// while LEGACY_EXACT is empty (see header).
227337
LEGACY_EXACT.set('synthetic-legacy.md', 81);
@@ -247,6 +357,20 @@ function selfTest() {
247357
// the glob form appearing there would make this gate read a path that does
248358
// not exist — red on a missing input, for a file that is fine.
249359
['the declared form is NOT an EXTRA_FILES entry', EXTRA_FILES.some((f) => ROOT_FILE_WATCH_HINTS.includes(f)), false],
360+
// The published root, walked for real. The red row names the file, so a
361+
// red raised by some other fixture file cannot stand in for it.
362+
['published root: a .md carrying #1234 -> red', redFor(cited, 'skills/demo/rules/x.md') && cited.failures.length === 1, true],
363+
['published root: a .md carrying none -> green, and walked', clean.failures.length === 0 && clean.scanned.includes('skills/demo/rules/x.md') && clean.scanned.length === 4, true],
364+
['published root: a colour whose digits run into a letter -> green', colour.failures.length === 0 && colour.scanned.includes('skills/demo/SKILL.md'), true],
365+
['published root: an all-decimal colour stays red (the named boundary)', redFor(decimal, 'skills/demo/SKILL.md'), true],
366+
['internal corpus keeps the bare pattern: the same colour -> red', redFor(internalColour, '.claude/skills/pm-dispatch/SKILL.md') && internalColour.failures.length === 1, true],
367+
['published root missing -> red, not a skip', missing.failures.some((f) => f.msg === `cannot read ${PUBLISHED_SCAN_ROOT} — red, not a skip.`), true],
368+
// The dispatch-gates declaration for the published root. Read by another
369+
// tool entirely, so a wrong one runs green here and shows up only as a
370+
// skills/ card dispatched with this gate absent from the brief.
371+
['the published root declares a root-dir watch hint', ROOT_DIR_WATCH_HINTS.includes(`${PUBLISHED_SCAN_ROOT}/**/*.md`), true],
372+
['and that declaration names no root this gate does not walk', ROOT_DIR_WATCH_HINTS.every((h) => h.startsWith(`${PUBLISHED_SCAN_ROOT}/`)), true],
373+
['the declared form is NOT the walk root itself', ROOT_DIR_WATCH_HINTS.includes(PUBLISHED_SCAN_ROOT), false],
250374
];
251375
// The ledger this self-test's floor is evaluated against (#13489).
252376
const batterySeen = new Map();

0 commit comments

Comments
 (0)