Skip to content

Commit 340eab7

Browse files
ci(release): the post-version guard admits the SKILL.md stamps through stampedSkillPaths() (#22980)
Fixes #22970 Clause-②: no Refs #22085 (the version-PR lane: the first scheduled `release.yml` run after this lands is the reading owed there). ## What changed - **The export.** `scripts/check-skill-compatibility-version.mjs` exports `stampedSkillPaths({ root })`: every repo-relative `skills/NAME/SKILL.md` path its `--fix` may write, in walk order, derived from `readSkillFiles()`. The `--fix` leg itself moved into an exported `applyFix(scanned, pkgs, root)` that writes from that same walk, so the list and the writes come from one walk. Zero skills throws, the same way `stampedPaths()` does. The one EXEMPT file (`skills/objectstack-upgrade/SKILL.md`) is in the list, because the exemption covers its pin and not its `metadata.version` stamp. `main()` prints the same lines as before. - **The fourth list, `release.yml`** (the `Validate the post-version tree` step only): `SKILL_LIST` is resolved by `node --input-type=module -e 'import { stampedSkillPaths } ...'`. It has the siblings' two refusals: could not resolve, and resolved EMPTY. It is added as a fifth whole-line exact filter, `grep -vxF -f "${SKILL_LIST}"`, after the three existing lists. - **The judging gate.** `run_gate pnpm check:skill-compatibility` (self-test plus check, no `--fix`) now runs in the content half, after `check:release-index-currency-sync`. - **The fourth list, `cut-rc.yml`** (`Build the single version commit`): the same resolve and the same two refusals. `SKILL_SURFACES` joins the `git add -A` pathspec, and `SKILL_LIST` joins the `BAD` re-check as the fifth filter. - Not touched: `skills/**`, the root `version` script, `.changeset/**`, `content/docs/releases/**`. There is no regex widening: the skill paths are admitted only by exact match against the exported list. ## The dispatch's hypotheses, measured - **H1 (where the guard sits, parity checkers).** The guard is at `release.yml` `Validate the post-version tree` and at `cut-rc.yml`'s `BAD` filter, as stated. I ran `git grep` for `TEMPLATE_LIST|SURFACE_LIST|INDEX_LIST|post-version-paths|stampedPaths|syncedPaths` outside the two workflows and the two rewriters. It hits only: - create-objectstack's own `stampedPaths()` test; - a comment in `scripts/cross-package-test-inputs.mjs`; - two unrelated `H43_`/`H48_SURFACE_LIST_CAP` constants in `scripts/pm/check-half-states.mjs`. **No parity checker pins the list count or the three names, so none had to learn the fourth list.** `check-self-test-wired` credits the new `./scripts/...` import the same way it credits the existing ones, and it stays green. - **H2 (import safety).** The script already carried the entry-point guard (`if (isEntrypoint(import.meta.url)) main();`). Importing it the workflow way prints the 10 paths, writes nothing (`git status` stays clean), and exits 0. `pnpm check:entry-guard` is green. - **H3 (the list).** On this tree the list is the 10 published skills. They are exactly the 10 `unexpected:` paths of run 38212422606, `objectstack-upgrade` included. - **H4 (the judging gate).** `check:skill-compatibility` was absent from `release.yml` and is now added to its content half. `cut-rc.yml` carries no post-version content half: its gates are pre-version and read `.changeset/`. So no `run_gate` was added there. Its `Version the workspace` step already goes red on a wrong stamp, because `--fix` re-checks what it wrote and exits 1. - **H5 (the content half on a real `18.0.0-next.0` tree).** Measured below. - **H6 (size).** +187/-20 over 3 files. The workflows are +45/-7. The script is +142/-13, about 95 of which are the self-test rows and their fixture. ## H5: the version pass and the step, run on a scratch tree I made a scratch worktree at this branch's `9540d87a8` (`origin/main` `246edf5b3` plus the two commits here) and ran `pnpm install --frozen-lockfile`. I then ran the `Render the post-version tree` and `Validate the post-version tree` step bodies, extracted verbatim from this branch's `release.yml`, with `RUNNER_TEMP` and `GITHUB_STEP_SUMMARY` pointed at scratch files. The scratch tree was discarded afterwards, and nothing from the version pass was committed. - **Render:** 426 pending changesets consumed and 1021 paths moved. `check-skill-compatibility-version --fix` reported "10 of 10 SKILL.md file(s) rewritten" (`@objectstack/spec 17.x → 18.x`, `metadata.version "17.7.0" → "18.0.0-next.0"`; the upgrade skill's stamp only), then its own check passed. `protocol-version.ts` did not move. - **Control: the guard as it is on `main`** (the step extracted from `246edf5b3`), run on the same rendered tree: 10 lines of `unexpected: skills/...SKILL.md`, then the treadmill refusal, exit 1. This is run 38212422606's signature. - **This branch's guard on the same tree:** the shape check passed and the content half ran in full. Step summary: 1021 paths, all inside the reviewed shape; template stamps moved: 3; content gates failed: 0; unvalidated: 0. It ended with `Post-version tree validated.` (verify-lock `VERDICT command-exit 0`). | content gate (`run_gate`) | verdict on the `18.0.0-next.0` tree | |---|---| | `pnpm check:docs-image-tag` | green (3/3 surfaces, pins at the newest GA 17.7.0, pre mode) | | `pnpm check:docs-image-tag-sync` | green | | `pnpm check:template-version-sync` | green | | `pnpm check:release-index-currency-sync` | green | | `pnpm check:skill-compatibility` (new) | green: 10 files, 11 pins at 18.x, 10 stamps at 18.0.0-next.0, 1 exemption | | `pnpm check:nul-bytes` | green (10995 text files) | | `pnpm check:release-notes` | green | | `pnpm check:release-page-status` | green (v16, v17 in scope) | | `node scripts/check-release-section-coverage.mjs` | green | | `pnpm check:published-readme-links` | green | | `pnpm --filter create-objectstack --fail-if-no-match test` | green (18 files, 261 tests) | | `pnpm --filter @objectstack/spec --fail-if-no-match check:template-manifests` | green | | `pnpm --filter @objectstack/spec ... vitest run --project local src/kernel/protocol-version.test.ts` | green | | `pnpm --filter @objectstack/spec --fail-if-no-match check:spec-changes` | green | | `pnpm --filter @objectstack/spec --fail-if-no-match check:upgrade-guide` | green | Two more readings on the same rendered tree: - **The judging gate can fail.** I restored `skills/objectstack-ai/SKILL.md` to its pre-version bytes. `node scripts/check-skill-compatibility-version.mjs` then exited 1 with 2 problems naming that file: the `17.7.0` stamp and the `17.x` pin. `--fix` put it back. - **`cut-rc.yml`'s staging and allowlist half** (from the `SURFACE_LIST` resolve down to the `LEFT` check, with no git config, reset or commit): - the step as it is on `main` refuses with 10 `unstaged: skills/...SKILL.md` lines (exit 1), so an RC cut would fail the same way; - this branch stages 595 paths, 10 of them the skill stamps, and `BAD` and `LEFT` are both empty. - Not part of the step, read because AGENTS.md ties SKILL.md edits to it: `pnpm --filter @objectstack/spec check:skill-docs` is green on the rendered tree. ## Self-test pin and ablation `check:skill-compatibility`'s self-test gains three registered rows. The battery floor moves 27 → 30. - **The export equals the set `--fix` writes.** The fixture is all-stale: two skills plus the exempt-shaped one, a skill directory with no SKILL.md, `skills/README.md`, and an internal `.claude/skills/internal/SKILL.md`. The export is compared against the set `applyFix` writes on that fixture, in walk order. The row also checks that the writes landed under the fixture root and that the internal skill was not touched. - **A SKILL.md write outside the export stays unexpected.** The row writes the list the way the workflows print it and spawns the guard's own `grep -vxF -f LIST MOVED`. Three outside paths (`.claude/skills/internal/SKILL.md`, `skills/a/nested/SKILL.md`, `skills/no-skill-md/SKILL.md`) come back as unexpected, exit 0. Control: the listed set alone is all admitted (exit 1, no output). - **An empty tree throws.** It never yields an empty list. **Ablation** (one-off, after the commit, through `scripts/ablation-replace.mjs`). The mutation widened the export's return to append `.claude/skills/internal/SKILL.md`, and the tool confirmed it landed (anchor 1 → 0, blob `42eb51200996` → `be0d19170cee`). The self-test then exited 1 with exactly the first two rows red: - "--fix wrote [3 paths], the export lists [4 paths]"; - "unexpected [2 paths], wanted [3 paths]". The tool restored the file and confirmed the restore: blob equals HEAD `42eb51200996`, and `git diff HEAD` is empty. ## Gates (at `9540d87a8`) I derived the gate list with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the actual diff. It returned 50 commands, identical to the dispatch's list. I ran all 50 and recorded each exit code before any pipe. All 50 exited 0. `check:pm-dispatch-gates` ended with "1828 cases pass" (fast tier). Reconciled with `--ran`: "50 derived, 50 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero because all 50 lines carry an exit code. `node scripts/check-skill-compatibility-version.mjs --self-test` reports "22 check cases and 8 --fix derivation cases pass". A control-byte self-scan of the three files found none. ## Acceptance notes - `release.yml`'s `version-pr` header comment sits outside the `Validate the post-version tree` step, so I did not edit it. Two parts of it are now stale: - the CONTENT gate list does not name `check:skill-compatibility`; - the note above `Create or update the "chore: version packages" PR` counts the version script's rewriters as four. The script has five after `changeset version`, and the skill stamper is the fifth. Carrier: the next change to that header; none is named yet. - The self-test's success line counts the three new rows inside "--fix derivation cases". They are registered and floored like every other row; only the label is loose. - The H5 reading is for `9540d87a8`. The scheduled run will render `main` at whatever sha it reads, so `main` landing more before then can still move a verdict. That run is the done-when reading on #22085. - `cut-rc.yml` requires pre-mode tag `rc`, and `.changeset/pre.json` currently says `next`. So the cut-rc reading above is a simulation of its staging and allowlist half on the `next.0` tree, not a dispatch. --- _Generated by [Claude Code](https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eede467 commit 340eab7

3 files changed

Lines changed: 187 additions & 20 deletions

File tree

‎.github/workflows/cut-rc.yml‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,24 @@ jobs:
665665
echo "release-index surfaces declared by syncedPaths() (${#INDEX_SURFACES[@]}):"
666666
sed 's/^/ /' "$INDEX_LIST"
667667
668+
# The SKILL.md stamps `check-skill-compatibility-version.mjs --fix` writes
669+
# (the `version` script's last step), read from `stampedSkillPaths()` —
670+
# derived from the walk that `--fix` writes from — on the same terms.
671+
SKILL_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-skill-surfaces.txt"
672+
if ! node --input-type=module \
673+
-e 'import { stampedSkillPaths } from "./scripts/check-skill-compatibility-version.mjs"; for (const p of stampedSkillPaths()) console.log(p);' \
674+
> "$SKILL_LIST"; then
675+
echo "::error::could not resolve stampedSkillPaths() from scripts/check-skill-compatibility-version.mjs, so the skill half of the release file surface is unknown. Refusing to push."
676+
exit 1
677+
fi
678+
if [ ! -s "$SKILL_LIST" ]; then
679+
echo "::error::stampedSkillPaths() in scripts/check-skill-compatibility-version.mjs resolved EMPTY, so no SKILL.md stamp would be staged even though the version pass writes them. Refusing to push."
680+
exit 1
681+
fi
682+
mapfile -t SKILL_SURFACES < "$SKILL_LIST"
683+
echo "skill surfaces declared by stampedSkillPaths() (${#SKILL_SURFACES[@]}):"
684+
sed 's/^/ /' "$SKILL_LIST"
685+
668686
git add -A -- \
669687
'*package.json' \
670688
'*CHANGELOG.md' \
@@ -673,7 +691,8 @@ jobs:
673691
packages/spec/src/kernel/protocol-version.ts \
674692
"${TEMPLATE_SURFACES[@]}" \
675693
"${DOCS_SURFACES[@]}" \
676-
"${INDEX_SURFACES[@]}"
694+
"${INDEX_SURFACES[@]}" \
695+
"${SKILL_SURFACES[@]}"
677696
678697
STAGED="$(git diff --cached --name-only)"
679698
if [ -z "$STAGED" ]; then
@@ -682,17 +701,18 @@ jobs:
682701
fi
683702
684703
# Re-check every staged path against the allowlist. The pathspec above is
685-
# convenience; THIS is the guarantee. Four filters, same allowlist the
704+
# convenience; THIS is the guarantee. Five filters, same allowlist the
686705
# pathspec used: the fixed release paths by pattern, then the declared
687-
# template, doc and release-index surfaces by WHOLE-LINE EXACT match
706+
# template, doc, release-index and skill surfaces by WHOLE-LINE EXACT match
688707
# (`-xF`) against the very lists that were staged — so no derived filter
689708
# can accept a path its declaration does not name, and none needs
690709
# regex-escaping of the paths to stay exact.
691710
BAD="$(printf '%s\n' "$STAGED" \
692711
| grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^\.objectui-sha$|^packages/spec/src/kernel/protocol-version\.ts$' \
693712
| grep -vxF -f "$TEMPLATE_LIST" \
694713
| grep -vxF -f "$SURFACE_LIST" \
695-
| grep -vxF -f "$INDEX_LIST" || true)"
714+
| grep -vxF -f "$INDEX_LIST" \
715+
| grep -vxF -f "$SKILL_LIST" || true)"
696716
if [ -n "$BAD" ]; then
697717
echo "::error::the version commit would carry paths outside the release file surface. Refusing to push. Offending paths follow; if the version pass legitimately grew a new output, widen the allowlist in this workflow deliberately."
698718
printf '%s\n' "$BAD" | sed 's/^/::error:: unexpected: /'

‎.github/workflows/release.yml‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,14 +613,31 @@ jobs:
613613
exit 1
614614
fi
615615
616-
# Same four filters cut-rc.yml applies to the same surface: the fixed
617-
# release paths by pattern, then the three declared lists by WHOLE-LINE
616+
# The SKILL.md stamps the skill-compatibility rewriter writes (the root
617+
# `version` script's last step, `--fix`), on the same terms: resolved from
618+
# `stampedSkillPaths()`, derived from the walk its `--fix` writes from, and
619+
# judged by `check:skill-compatibility` in the content half below.
620+
SKILL_LIST="${RUNNER_TEMP}/post-version-skill-surfaces.txt"
621+
if ! node --input-type=module \
622+
-e 'import { stampedSkillPaths } from "./scripts/check-skill-compatibility-version.mjs"; for (const p of stampedSkillPaths()) console.log(p);' \
623+
> "${SKILL_LIST}"; then
624+
echo "::error::could not resolve stampedSkillPaths() from scripts/check-skill-compatibility-version.mjs, so the skill half of the post-version surface is unknown. Refusing to call this tree validated."
625+
exit 1
626+
fi
627+
if [ ! -s "${SKILL_LIST}" ]; then
628+
echo "::error::stampedSkillPaths() in scripts/check-skill-compatibility-version.mjs resolved EMPTY. It THROWS on a moved or empty skills directory, so an empty file here is a resolution that reported nothing while still exiting 0. Refusing to call this tree validated."
629+
exit 1
630+
fi
631+
632+
# Same five filters cut-rc.yml applies to the same surface: the fixed
633+
# release paths by pattern, then the four declared lists by WHOLE-LINE
618634
# EXACT match, so no derived filter can accept a path its declaration
619635
# does not name and none needs regex-escaping.
620636
UNEXPECTED="$(grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^packages/spec/src/kernel/protocol-version\.ts$' "${MOVED_FILE}" \
621637
| grep -vxF -f "${TEMPLATE_LIST}" \
622638
| grep -vxF -f "${SURFACE_LIST}" \
623-
| grep -vxF -f "${INDEX_LIST}" || true)"
639+
| grep -vxF -f "${INDEX_LIST}" \
640+
| grep -vxF -f "${SKILL_LIST}" || true)"
624641
if [ -n "${UNEXPECTED}" ]; then
625642
printf '%s\n' "${UNEXPECTED}" | sed 's/^/::error:: unexpected: /'
626643
echo "::error::the version pass wrote outside the reviewed post-version surface (paths above). This is the treadmill guard: a new version-time output must arrive together with the gate that judges it. Add the surface to the declaration its rewriter reads, and its gate to the content half below — deliberately, in one reviewed diff."
@@ -672,6 +689,7 @@ jobs:
672689
run_gate pnpm check:docs-image-tag-sync
673690
run_gate pnpm check:template-version-sync
674691
run_gate pnpm check:release-index-currency-sync
692+
run_gate pnpm check:skill-compatibility
675693
run_gate pnpm check:nul-bytes
676694
run_gate pnpm check:release-notes
677695
run_gate pnpm check:release-page-status

‎scripts/check-skill-compatibility-version.mjs‎

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
// node scripts/check-skill-compatibility-version.mjs --fix # derive both lines, then check
8383
// node scripts/check-skill-compatibility-version.mjs --self-test
8484

85+
import { spawnSync } from 'node:child_process';
8586
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
8687
import { tmpdir } from 'node:os';
8788
import { dirname, join, relative } from 'node:path';
@@ -590,6 +591,58 @@ export function readSkillFiles(root = REPO_ROOT) {
590591
return { files, problems };
591592
}
592593

594+
/**
595+
* Every repo-relative path `--fix` may write, in the order the walk visits them.
596+
*
597+
* This is the export the post-version shape guard reads (`release.yml`'s
598+
* `Validate the post-version tree`, `cut-rc.yml`'s release-file allowlist), on the
599+
* terms of `stampedPaths()` in sync-template-versions.mjs and `syncedPaths()` in
600+
* sync-release-index-currency.mjs: the root `version` script ends with this
601+
* script's `--fix`, so the SKILL.md files it restamps are a version-time output,
602+
* and a guard that restated them would be one more contract to drift. It is
603+
* derived from `readSkillFiles()`, the walk `applyFix()` writes from, so the
604+
* answer cannot name a different set than the one that gets written — the one
605+
* justified EXEMPT file included, because the exemption covers its pin, never its
606+
* `metadata.version` stamp.
607+
*
608+
* Zero skills THROWS rather than returning `[]`: an empty allowlist reads exactly
609+
* like "nothing to admit" and means "the directory moved".
610+
*
611+
* @param {{ root?: string }} [options] checkout to walk; defaults to this one
612+
* @returns {string[]} POSIX-separated, repo-relative `skills/NAME/SKILL.md` paths
613+
*/
614+
export function stampedSkillPaths({ root = REPO_ROOT } = {}) {
615+
const { files } = readSkillFiles(root);
616+
if (files.length === 0) {
617+
throw new Error(
618+
`check-skill-compatibility-version: no SKILL.md under ${SKILLS_DIR}/. The version pass stamps ` +
619+
'every published skill, so this is a moved directory rather than an empty catalog — ' +
620+
'refusing to report an empty stamped-path set.',
621+
);
622+
}
623+
return files.map((f) => f.file);
624+
}
625+
626+
/**
627+
* The `--fix` leg: derive every scanned file and write the ones that moved under
628+
* `root`. Returns the derived set (what the check then judges) and what was
629+
* written, so the self-test can hold the written set against `stampedSkillPaths()`.
630+
*
631+
* @returns {{ files: { file: string, text: string }[], written: { file: string, changes: string[] }[] }}
632+
*/
633+
export function applyFix(scanned, pkgs, root = REPO_ROOT) {
634+
const written = [];
635+
const files = scanned.map(({ file, text }) => {
636+
const { text: next, changes } = deriveFrontmatter(text, pkgs);
637+
if (next !== text) {
638+
writeFileSync(join(root, file), next);
639+
written.push({ file, changes });
640+
}
641+
return { file, text: next };
642+
});
643+
return { files, written };
644+
}
645+
593646
function readWorkspacePackages(root = REPO_ROOT) {
594647
const pkgs = new Map();
595648
const walk = (abs, depth) => {
@@ -698,6 +751,9 @@ const SELF_TEST_BATTERIES = Object.freeze({
698751
'--fix rewrites a pin inside a folded `compatibility: >` block too': 1,
699752
'--fix invents no structure: a file with no metadata.version comes back unchanged, and the check names it': 1,
700753
'--fix leaves a pin naming a package the workspace lacks alone, for the check to refuse': 1,
754+
'stampedSkillPaths() equals the set --fix writes on an all-stale fixture, in walk order': 1,
755+
'a SKILL.md write outside stampedSkillPaths() stays unexpected under the guard\'s grep -vxF -f filter': 1,
756+
'stampedSkillPaths() THROWS on a tree with no skills, never an empty list': 1,
701757
});
702758

703759
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -706,7 +762,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
706762
// the literal above, so the roster falls below this number; the table
707763
// cross-check in the floor block is the other half, and names WHICH label
708764
// collided.
709-
const SELF_TEST_BATTERY_FLOOR = 27;
765+
const SELF_TEST_BATTERY_FLOOR = 30;
710766

711767
function selfTest() {
712768
console.log('check-skill-compatibility-version self-test\n');
@@ -727,6 +783,30 @@ function selfTest() {
727783
text: fm('compatibility: >\n Needs `@objectstack/spec` and `@objectstack/cli` at the TARGET major\n (protocol 10 at the time of writing).'),
728784
};
729785

786+
// A tree where every published SKILL.md is stale (a pin, a stamp, and the
787+
// exempt file's stamp alone), beside a skill directory with no SKILL.md, a
788+
// non-directory entry, and an internal SKILL.md the walk never reaches.
789+
const STALE_TEXT = fm('compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas)', '16.0.0');
790+
const OUTSIDE_SKILL = '.claude/skills/internal/SKILL.md';
791+
const withStaleFixture = (body) => {
792+
const root = mkdtempSync(join(tmpdir(), 'skill-compat-stamped-'));
793+
try {
794+
const put = (rel, text) => {
795+
mkdirSync(dirname(join(root, rel)), { recursive: true });
796+
writeFileSync(join(root, rel), text);
797+
};
798+
put('skills/a/SKILL.md', STALE_TEXT);
799+
put('skills/b/SKILL.md', fm('compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas)', '17.0.0-rc.4'));
800+
put(exemptUpgrade.file, exemptUpgrade.text.replace('"17.0.0-rc.5"', '"16.0.0"'));
801+
put('skills/README.md', '# index\n');
802+
mkdirSync(join(root, 'skills', 'no-skill-md'), { recursive: true });
803+
put(OUTSIDE_SKILL, STALE_TEXT);
804+
body(root);
805+
} finally {
806+
rmSync(root, { recursive: true, force: true });
807+
}
808+
};
809+
730810
const cases = [
731811
{
732812
label: 'the landed wording (exact 17.x pin) → GREEN',
@@ -997,6 +1077,62 @@ function selfTest() {
9971077
if (text !== before || changes.length !== 0) throw new Error('an unknown package was rewritten');
9981078
},
9991079
},
1080+
// ── The post-version surface the shape guard reads ──────────────────────
1081+
{
1082+
label: 'stampedSkillPaths() equals the set --fix writes on an all-stale fixture, in walk order',
1083+
run: () => withStaleFixture((root) => {
1084+
const listed = stampedSkillPaths({ root });
1085+
const { written } = applyFix(readSkillFiles(root).files, PKGS, root);
1086+
const wrote = written.map((w) => w.file);
1087+
if (JSON.stringify(wrote) !== JSON.stringify(listed)) {
1088+
throw new Error(`--fix wrote ${JSON.stringify(wrote)}, the export lists ${JSON.stringify(listed)}`);
1089+
}
1090+
// The writes really landed under `root` and nowhere else: each listed file
1091+
// now passes the check, and the internal skill outside the walk is untouched.
1092+
if (runAllChecks(readSkillFiles(root).files, PKGS, EXEMPT).problems.length !== 0) {
1093+
throw new Error('the fixture is still stale after --fix, so the writes did not land under root');
1094+
}
1095+
if (readFileSync(join(root, OUTSIDE_SKILL), 'utf8') !== STALE_TEXT) {
1096+
throw new Error(`--fix wrote ${OUTSIDE_SKILL}, a SKILL.md outside the walk`);
1097+
}
1098+
}),
1099+
},
1100+
{
1101+
label: 'a SKILL.md write outside stampedSkillPaths() stays unexpected under the guard\'s grep -vxF -f filter',
1102+
run: () => withStaleFixture((root) => {
1103+
// The guard's own filter, spawned as the workflows spell it, over the
1104+
// list printed the way their resolve line prints it.
1105+
const listFile = join(root, 'skill-list.txt');
1106+
const movedFile = join(root, 'moved.txt');
1107+
const listed = stampedSkillPaths({ root });
1108+
writeFileSync(listFile, listed.map((p) => `${p}\n`).join(''));
1109+
const outside = [OUTSIDE_SKILL, 'skills/a/nested/SKILL.md', 'skills/no-skill-md/SKILL.md'];
1110+
writeFileSync(movedFile, [...listed, ...outside].map((p) => `${p}\n`).join(''));
1111+
const r = spawnSync('grep', ['-vxF', '-f', listFile, movedFile], { encoding: 'utf8' });
1112+
if (r.error) throw new Error(`could not spawn grep: ${r.error.message}`);
1113+
const unexpected = r.stdout.split('\n').filter(Boolean);
1114+
if (r.status !== 0 || JSON.stringify(unexpected) !== JSON.stringify(outside)) {
1115+
throw new Error(`grep exit ${r.status}, unexpected ${JSON.stringify(unexpected)}, wanted ${JSON.stringify(outside)}`);
1116+
}
1117+
// Control: the listed set alone is all admitted (exit 1, nothing selected).
1118+
writeFileSync(movedFile, listed.map((p) => `${p}\n`).join(''));
1119+
const c = spawnSync('grep', ['-vxF', '-f', listFile, movedFile], { encoding: 'utf8' });
1120+
if (c.status !== 1 || c.stdout !== '') throw new Error(`control: grep exit ${c.status}, output ${JSON.stringify(c.stdout)}`);
1121+
}),
1122+
},
1123+
{
1124+
label: 'stampedSkillPaths() THROWS on a tree with no skills, never an empty list',
1125+
run: () => {
1126+
const empty = mkdtempSync(join(tmpdir(), 'skill-compat-nolist-'));
1127+
try {
1128+
let threw = false;
1129+
try { stampedSkillPaths({ root: empty }); } catch (err) { threw = /no SKILL\.md under skills\//.test(err.message); }
1130+
if (!threw) throw new Error('an empty tree resolved without the refusal');
1131+
} finally {
1132+
rmSync(empty, { recursive: true, force: true });
1133+
}
1134+
},
1135+
},
10001136
];
10011137
for (const c of derivationCases) {
10021138
registerCase(c.label);
@@ -1177,19 +1313,12 @@ function main() {
11771313
// write only the files that moved, then CHECK the result like any other run —
11781314
// a derivation that leaves a file red (no stamp to rewrite, a pin naming no
11791315
// package) is reported, never papered over.
1180-
const files = process.argv.includes('--fix')
1181-
? scanned.map(({ file, text }) => {
1182-
const { text: next, changes } = deriveFrontmatter(text, pkgs);
1183-
if (next !== text) {
1184-
writeFileSync(join(REPO_ROOT, file), next);
1185-
console.log(`✎ ${file}: ${changes.join('; ')}`);
1186-
}
1187-
return { file, text: next };
1188-
})
1189-
: scanned;
1316+
let files = scanned;
11901317
if (process.argv.includes('--fix')) {
1191-
const moved = files.filter((f, i) => f.text !== scanned[i].text).length;
1192-
console.log(`check-skill-compatibility-version --fix: ${moved} of ${files.length} SKILL.md file(s) rewritten.\n`);
1318+
const fixed = applyFix(scanned, pkgs);
1319+
for (const { file, changes } of fixed.written) console.log(`✎ ${file}: ${changes.join('; ')}`);
1320+
files = fixed.files;
1321+
console.log(`check-skill-compatibility-version --fix: ${fixed.written.length} of ${files.length} SKILL.md file(s) rewritten.\n`);
11931322
}
11941323

11951324
const { problems, results, pinCount } = runAllChecks(files, pkgs, EXEMPT);

0 commit comments

Comments
 (0)