From 2ee12dddb9d06299584f812f51bebb95523a14d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 06:30:14 +0000 Subject: [PATCH 1/7] feat(scripts): check:skill-links walks every relative link and anchor in the published skills A new gate in the check:skill-* family, wired as one more step in the lint lane that already runs the family. Fragments are judged under GitHub's own slug rule (em-dashes and punctuation deleted, spaces to hyphens with no collapsing, -N for repeats), so the catalog's many double-hyphen anchors read as the links they are. Ships with a battery-floored --self-test that reproduces the three moved-verbatim links the 2026-11-02 review found by hand. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jU4PLtyyimMz72z285 --- .github/workflows/lint.yml | 8 + package.json | 1 + scripts/check-skill-links.mjs | 468 ++++++++++++++++++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 scripts/check-skill-links.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 700b0baebc3..ec1e26ebbc2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5385,6 +5385,14 @@ jobs: continue-on-error: true run: pnpm check:skill-top-level-keys + # Rationale: `scripts/check-skill-links.mjs` header (#31115). Same job, no + # paths filter: a section moved between skill files breaks the links INTO + # it from files the move never touched. + - name: Check every relative link and anchor in the published skills resolves + id: check-every-relative-link-and-anchor-in + continue-on-error: true + run: pnpm check:skill-links + # Rationale: `scripts/check-agent-model-declared.mjs` header (#6803, #6686). - name: Check every agent definition declares a model id: check-every-agent-definition-declares diff --git a/package.json b/package.json index 16fff232e34..3254a5f247f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "check:skill-frame-freshness": "node scripts/check-skill-frame-freshness.mjs --self-test && node scripts/check-skill-frame-freshness.mjs", "check:skill-compatibility": "node scripts/check-skill-compatibility-version.mjs --self-test && node scripts/check-skill-compatibility-version.mjs", "check:skill-top-level-keys": "node scripts/check-skill-top-level-keys.mjs --self-test && node scripts/check-skill-top-level-keys.mjs", + "check:skill-links": "node scripts/check-skill-links.mjs --self-test && node scripts/check-skill-links.mjs", "check:agent-model-declared": "node scripts/check-agent-model-declared.mjs --self-test && node scripts/check-agent-model-declared.mjs", "check:agent-test-spelling": "node scripts/check-agent-test-spelling.mjs --self-test && node scripts/check-agent-test-spelling.mjs", "check:ratchet-remedy-authority": "node scripts/check-ratchet-remedy-authority.mjs --self-test && node scripts/check-ratchet-remedy-authority.mjs", diff --git a/scripts/check-skill-links.mjs b/scripts/check-skill-links.mjs new file mode 100644 index 00000000000..a50582b2656 --- /dev/null +++ b/scripts/check-skill-links.mjs @@ -0,0 +1,468 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-skill-links — every relative link in the published skills catalog + * resolves: the file it names exists, and a `#fragment` on a markdown target + * names a heading (or an HTML `id`/`name`) that file really carries. + * + * node scripts/check-skill-links.mjs # the gate + * node scripts/check-skill-links.mjs --self-test # verify the checker itself, offline + * + * ## The contract it guards + * + * `skills/**` ships to customer agents verbatim (`@objectstack/skills`, and + * `npx skills add` from `main`). A link is how one page hands the reader to the + * rule it depends on, so a dead one is an instruction that ends mid-sentence: + * the agent follows it, lands on the top of the wrong page (a missing fragment + * scrolls nowhere) or on nothing, and carries on without the rule. + * + * ## The expected catch + * + * "Moved verbatim" refactors. A section moves from SKILL.md into a references/ + * or rules/ file (or back), the inbound links keep the old `../SKILL.md#slug`, + * and every other skill gate stays green, because none of them reads links. The + * 2026-11-02 review found three such links by hand, all of this one shape: + * `objectstack-data/references/data-hooks.md` -> `../SKILL.md#lifecycle-hooks`, + * `objectstack-platform/rules/plugin-lifecycle.md` -> + * `../SKILL.md#complete-plugin-example`, and + * `objectstack-platform/rules/service-registry.md` -> + * `../SKILL.md#well-known-plugin-names--services`. The self-test reproduces that + * shape and must red on it. + * + * ## The slug rule is the whole gate + * + * An ad-hoc walk flagged 71 links, 68 of them false: it slugged em-dash headings + * wrong. A link checker with a wrong slug rule is noise, and noise gets + * ignored, so the rule is GitHub's own (github-slugger), stated here and pinned + * case by case in the self-test: + * + * 1. the heading's RENDERED text: code spans keep their content and lose the + * backticks; `[text](url)` keeps `text`; emphasis markers (`*`, `_` at a + * word edge, `~~`) and inline HTML tags drop; a backslash escape keeps the + * escaped character. + * 2. lower-cased. + * 3. every character that is not a letter, a mark, a number, a connector + * (`_`), a space or a hyphen is DELETED — so `—`, `:`, `&`, `/`, `(`, an + * emoji, all vanish without a trace. + * 4. each space becomes ONE hyphen, with no collapsing: `A — B` reads + * `a b` after step 3 and so slugs `a--b`. Double hyphens are legal and + * common in this catalog; a checker that collapses them reds every one. + * 5. a repeated slug in one file gets `-1`, `-2`, … in document order. + * + * ## What is judged, and what is deliberately not + * + * judged every `[text](href)`, `![alt](src)` and `[id]: href` reference + * definition, in every `*.md` under a published skill, whose + * href is relative (no scheme, not `//host`, not `/root`). + * not judged fenced code (an illustrative link such as `./crm_index.md` + * inside an example is not a link, it is the example); inline + * code spans; HTML comments; absolute URLs, `mailto:` and other + * schemes (reachability of the web is not this gate's question, + * and an offline gate cannot answer it); a fragment on a + * non-markdown target (`#L12` on a `.ts` file is GitHub's line + * anchor, not a heading). + * + * Whether a link that leaves `skills/` reaches a file that also SHIPS is a + * different question (the published tarball carries `skills/` only); this gate + * answers existence in the checkout, and says so rather than implying more. + * + * ## Exit codes + * + * 0 every judged link resolves + * 1 at least one dead link — each named by file:line, href and what failed + * 2 prerequisite not met — no skills root, no markdown found, or the lit + * control failed (the extractor stopped seeing the links it must see). + * Never 0: a gate that cannot find its input must say so. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SKILLS_DIR = 'skills'; +const EXIT_CLEAN = 0; +const EXIT_DEAD = 1; +const EXIT_PREREQUISITE = 2; + +/** + * The lit control: on the live tree the walk must see at least this many + * judged links, and this many of them carrying a fragment. Measured on + * 3a0aa915 (2026-11-08): 248 judged links, 120 with a fragment, 71 files. The + * floors sit well below so ordinary pruning never reds; an extractor that + * stops matching (a regex edit, a fence bug that swallows a file) falls far + * under them and must say so instead of reporting a clean run over nothing. + */ +const LIT_CONTROL = Object.freeze({ links: 150, fragments: 60 }); + +/** + * The half of this gate's population `scripts/pm/dispatch-gates.mjs` cannot + * see, in the spelling it compares in: a bare `skills` carries no separator, + * so without this a skills-only card would never be told this gate reads its + * files. Provenance only — nothing below reads it. + */ +const ROOT_DIR_WATCH_HINTS = ['skills/**']; + +// ── Corpus ────────────────────────────────────────────────────────────────── + +/** Every `*.md` under `root/skills`, repo-relative POSIX paths, sorted. */ +export function discoverMarkdown(root = REPO_ROOT) { + const out = []; + const walk = (rel) => { + for (const e of readdirSync(join(root, rel), { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + const child = `${rel}/${e.name}`; + if (e.isDirectory()) walk(child); + else if (e.isFile() && e.name.endsWith('.md')) out.push(child); + } + }; + walk(SKILLS_DIR); + return out.sort(); +} + +/** + * The lines of a markdown file with everything that is not rendered prose + * blanked out, so line numbers survive: YAML frontmatter, fenced blocks (a + * fence closes only on the same character, at least as long), and HTML + * comments. Returned with a per-line flag the anchor pass reads. + * + * @returns {string[]} + */ +export function proseLines(text) { + const lines = text.split('\n'); + const out = []; + let fence = null; + let comment = false; + let front = lines[0] === '---'; + for (let i = 0; i < lines.length; i += 1) { + let line = lines[i]; + if (front) { + out.push(''); + if (i > 0 && line === '---') front = false; + continue; + } + const f = /^\s{0,3}(`{3,}|~{3,})/.exec(line); + if (fence) { + if (f && f[1][0] === fence[0] && f[1].length >= fence.length && line.trim() === f[1]) fence = null; + out.push(''); + continue; + } + if (f) { fence = f[1]; out.push(''); continue; } + let kept = ''; + let rest = line; + while (rest.length) { + if (comment) { + const end = rest.indexOf('-->'); + if (end < 0) { rest = ''; break; } + comment = false; + rest = rest.slice(end + 3); + continue; + } + const start = rest.indexOf('\n' }); return r.dead.length === 0 && r.links === 0; } }, + { label: 'absolute URLs, mailto and root paths are not judged', run: () => { const r = judgeTree({ 'skills/a/SKILL.md': '[a](https://objectstack.ai/x#y) [b](mailto:a@b.c) [c](//cdn.example/x) [d](/docs/x)\n' }); return r.dead.length === 0 && r.links === 0; } }, + { label: 'a fragment on a non-markdown target is not judged', run: () => { const r = judgeTree({ 'skills/a/SKILL.md': '[src](./x.ts#L12)\n', 'skills/a/x.ts': '' }); return r.dead.length === 0 && r.links === 1 && r.fragments === 0; } }, + { label: 'a link climbing above the repository root → [dead-file]', run: () => { const r = judgeTree({ 'skills/a/SKILL.md': '[x](../../../outside.md)\n' }); return r.dead.length === 1 && r.dead[0].includes('climbs above the repository root'); } }, + { label: 'no skills root → exit 2', run: () => withRoot({ 'other/x.md': '# x\n' }, (root) => runOver(root).exit === EXIT_PREREQUISITE) }, + { label: 'a skills root with no markdown → exit 2', run: () => withRoot({ 'skills/a/x.json': '{}' }, (root) => { const r = runOver(root); return r.exit === EXIT_PREREQUISITE && r.lines[0].includes('no *.md'); }) }, + { label: 'lit control — a corpus under the floors → exit 2, never 0', run: () => withRoot(CLEAN, (root) => { const r = runOver(root); return r.exit === EXIT_PREREQUISITE && r.lines[0].includes('lit control failed') && runOver(root, { litControl: { links: 1, fragments: 1 } }).exit === EXIT_CLEAN; }) }, + { label: 'live tree — the real catalog clears the lit control', run: () => { const files = discoverMarkdown(); const r = judge(files, liveTree(REPO_ROOT)); return files.length > 0 && r.links >= LIT_CONTROL.links && r.fragments >= LIT_CONTROL.fragments; } }, + ]; + + const seen = new Map(); + let failed = 0; + for (const c of cases) { + seen.set(c.label, (seen.get(c.label) ?? 0) + 1); + let ok = false; + try { ok = c.run(); } catch (e) { console.error(` ✗ ${c.label} — threw ${e.message}`); failed += 1; continue; } + if (ok) console.log(` ✓ ${c.label}`); else { console.error(` ✗ ${c.label}`); failed += 1; } + } + const declared = Object.keys(SELF_TEST_BATTERIES); + const floor = (msg) => { console.error(`✗ self-test floor: ${msg}`); failed += 1; }; + if (declared.length < SELF_TEST_BATTERY_FLOOR) floor(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR}.`); + for (const [name] of seen) if (!declared.includes(name)) floor(`case "${name}" is attributed to no declared battery.`); + for (const name of declared) if ((seen.get(name) ?? 0) < SELF_TEST_BATTERIES[name]) floor(`battery "${name}" DID NOT RUN (${seen.get(name) ?? 0} of ${SELF_TEST_BATTERIES[name]} pinned).`); + if (failed > 0) { console.error(`\n✗ check-skill-links self-test: ${failed} failure(s) (cases and floor).`); process.exit(1); } + console.log(`\n✓ check-skill-links self-test: ${cases.length} cases pass.`); + selfTestReachedVerdict = true; +} + +function main() { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { console.error('\n✗ check-skill-links self-test: selfTest() returned without reaching its verdict.'); process.exit(1); } + return; + } + const verdict = runOver(REPO_ROOT); + for (const l of verdict.lines) (verdict.exit === EXIT_CLEAN ? console.log : console.error)(l); + process.exit(verdict.exit); +} + +if (isEntrypoint(import.meta.url)) main(); From 29ac27de078a23a924d0e9bc751f4a0df7cdf0d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 06:33:39 +0000 Subject: [PATCH 2/7] feat(scripts): the liveness gate holds the requires-token table, the os generate row and the filter operator table exhaustive Three new Leg 2 bindings, each against its source symbol: the platform skill's requires: token table against PLATFORM_CAPABILITY_TOKENS intersected with CAPABILITY_PROVIDERS, the operations reference's os generate row against GENERATORS minus RETIRED_GENERATORS (a retired kind listed there is its own red), and the query skill's operator reference against FILTER_OPERATORS. The gate now reads string arrays and object-literal keys by name, and a binding can scope to one table row. The os generate row now lists every kind the CLI scaffolds; two restating clauses in the same file's port table pay for the bytes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jU4PLtyyimMz72z285 --- scripts/check-skill-identifier-liveness.mjs | 311 +++++++++++++++++- .../references/operations.md | 6 +- 2 files changed, 297 insertions(+), 20 deletions(-) diff --git a/scripts/check-skill-identifier-liveness.mjs b/scripts/check-skill-identifier-liveness.mjs index 816be9bd089..352489940e2 100644 --- a/scripts/check-skill-identifier-liveness.mjs +++ b/scripts/check-skill-identifier-liveness.mjs @@ -235,6 +235,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'extractor': 26, 'LEG 1 end to end, incl. the positive control': 7, 'LEG 2: symbols, sections, and the two structural safety rules': 23, + 'LEG 2: string arrays, object keys, set algebra and row scope': 15, 'both legs in ONE walk': 1, '#8435: whose remedy is the expanding one': 3, 'the ledgers move in opposite directions, and the source says so': 2, @@ -246,7 +247,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 10; +const SELF_TEST_BATTERY_FLOOR = 11; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -314,9 +315,19 @@ const NEGATIVE_HEADERS = new Set([ * why what makes this section exhaustive, for the reviewer of the next * edit that reddens it. * + * Three optional fields, each read by the same engine path: + * + * intersect `{symbol, source}` — keep only members this symbol also names + * (a token table covers the tokens that HAVE a provider row). + * minus `{symbol, source}` — drop the members this symbol names, and + * RED when the scope documents one of them (`[leg2-retired-row]`): + * a retired member listed as live is this binding's phantom. + * row the exact first cell of the one table row that is exhaustive, + * when its section's other rows are not. + * * ⛔ A row is an ASSERTION THAT A SECTION IS EXHAUSTIVE. Adding one that is not - * manufactures a false red for every legitimate future subset. The nine here - * were each verified complete (or measured incomplete and ledgered) at landing; + * manufactures a false red for every legitimate future subset. Each row here + * was verified complete (or measured incomplete and ledgered) at landing; * `--suggest` lists candidates, it does not bless them. */ const BINDINGS = [ @@ -384,6 +395,33 @@ const BINDINGS = [ source: 'packages/spec/src/ui/app.zod.ts', why: 'The nav-item catalog, and the one binding that landed with a MEASURED gap rather than complete — see the gap ledger. Registered anyway: an unregistered gap is invisible, and the ledger is what makes it shrink.', }, + { + id: 'requires-capability-tokens', + file: 'skills/objectstack-platform/SKILL.md', + heading: '### `requires:` — which service plugins boot', + symbol: 'PLATFORM_CAPABILITY_TOKENS', + source: 'packages/spec/src/kernel/platform-capabilities.ts', + intersect: { symbol: 'CAPABILITY_PROVIDERS', source: 'packages/core/src/capability-providers.ts' }, + why: 'The token table is how an author learns what `requires:` turns on; every token with a provider row must be in it. Tokens with no provider (tier-gated, enterprise) are named in the same section as the ones that do not resolve through the map.', + }, + { + id: 'generate-kinds', + file: 'skills/objectstack-platform/references/operations.md', + heading: '## Build & runtime', + row: '`os generate `', + symbol: 'GENERATORS', + source: 'packages/cli/src/commands/generate.ts', + minus: { symbol: 'RETIRED_GENERATORS', source: 'packages/cli/src/commands/generate.ts' }, + why: 'The CLI row is the one place the catalog lists what `os generate` scaffolds. A kind missing here is never scaffolded; a retired kind listed here is a command that refuses.', + }, + { + id: 'filter-operators', + file: 'skills/objectstack-query/rules/filters.md', + heading: '## Operator Reference', + symbol: 'FILTER_OPERATORS', + source: 'packages/spec/src/data/filter.zod.ts', + why: 'The operator reference table is the filter vocabulary an author writes from; an operator the spec accepts and the table omits is never used.', + }, ]; const argv = process.argv.slice(2); @@ -582,6 +620,174 @@ export function undocumentedMembers(members, pool) { return members.filter((v) => !pool.has(v)); } +// ── LEG 2: the two shapes `extractSymbols` does not read ──────────────────── +// +// Three tables a person asserted exhaustive are keyed by declarations that are +// neither a `z.enum` nor a literal union: a frozen string ARRAY +// (`PLATFORM_CAPABILITY_TOKENS`, `FILTER_OPERATORS`) and an object literal whose +// KEYS are the vocabulary (`CAPABILITY_PROVIDERS`, `GENERATORS`). They are read +// by NAME through `membersOf`, never added to `extractSymbols`: that function +// also feeds `--suggest`, and every object literal in the spec offered as a +// candidate enum would bury the feeder's real leads. +// +// The reader walks characters, not regexes, because these declarations carry +// what a regex reads wrong: a type annotation with braces of its own +// (`Record`) before the `=`, nested objects, and string values +// holding `//` (a URL in a retired generator's help text) that a comment +// stripper would cut mid-string. Anything at depth 1 that is not a plain +// member — a spread, a computed key, a bare identifier element — makes the +// read REFUSE (null), which the engine reports as a stale binding: a member set +// the reader cannot see whole is not a member set. + +/** Index just past the string/template/comment opening at `i`, or `i` when none opens there. */ +function skipNonCode(t, i) { + const c = t[i]; + if (c === '/' && t[i + 1] === '/') { const e = t.indexOf('\n', i); return e < 0 ? t.length : e; } + if (c === '/' && t[i + 1] === '*') { const e = t.indexOf('*/', i + 2); return e < 0 ? t.length : e + 2; } + if (c === "'" || c === '"') { + let j = i + 1; + while (j < t.length && t[j] !== c) j += t[j] === '\\' ? 2 : 1; + return j + 1; + } + if (c === '`') { + let j = i + 1; + while (j < t.length && t[j] !== '`') { + if (t[j] === '\\') { j += 2; continue; } + if (t[j] === '$' && t[j + 1] === '{') { + let depth = 1; + j += 2; + while (j < t.length && depth > 0) { + const k = skipNonCode(t, j); + if (k !== j) { j = k; continue; } + if (t[j] === '{') depth += 1; + else if (t[j] === '}') depth -= 1; + j += 1; + } + continue; + } + j += 1; + } + return j + 1; + } + return i; +} + +/** + * The members of `const NAME = [ 'a', … ]` (optionally `Object.freeze(…)`, + * `as const`, a type annotation) or the KEYS of `const NAME = { a: …, 'b-c': … }`. + * + * @returns {string[] | null} null when NAME is not declared in one of those shapes + */ +export function literalMembersOf(text, name) { + const decl = new RegExp(`(?:^|[^\\w$])const\\s+${name.replace(/\$/g, '\\$')}(?![\\w$])`, 'g'); + for (const m of text.matchAll(decl)) { + let i = m.index + m[0].length; + let depth = 0; + while (i < text.length) { + const k = skipNonCode(text, i); + if (k !== i) { i = k; continue; } + const c = text[i]; + if ('<{[('.includes(c)) depth += 1; + else if ('>}])'.includes(c) && text[i - 1] !== '=') depth -= 1; + else if (c === '=' && depth === 0 && text[i + 1] !== '>' && text[i + 1] !== '=') break; + else if (c === ';' && depth === 0) { i = text.length; break; } + i += 1; + } + if (i >= text.length) continue; + const rhs = /^=\s*(?:Object\.freeze\(\s*)?([[{])/.exec(text.slice(i)); + if (!rhs) continue; + const open = i + rhs[0].length - 1; + const isArray = rhs[1] === '['; + const members = []; + let j = open + 1; + let level = 1; + let expectEntry = true; + while (j < text.length && level > 0) { + const k = skipNonCode(text, j); + if (k !== j) { + const quote = text[j]; + if (level === 1 && expectEntry && (quote === "'" || quote === '"')) { + const value = text.slice(j + 1, k - 1); + const after = /^\s*(:?)/.exec(text.slice(k))[1]; + if (isArray === (after === '')) members.push(value); else return null; + expectEntry = false; + } + j = k; + continue; + } + const c = text[j]; + if (/\s/.test(c)) { j += 1; continue; } + if ('{[('.includes(c)) { if (level === 1 && expectEntry) return null; level += 1; j += 1; continue; } + if ('}])'.includes(c)) { level -= 1; j += 1; continue; } + if (level === 1 && c === ',') { expectEntry = true; j += 1; continue; } + if (level === 1 && expectEntry) { + const id = /^[A-Za-z_$][\w$]*/.exec(text.slice(j)); + if (isArray || !id || !/^\s*:/.test(text.slice(j + id[0].length))) return null; + members.push(id[0]); + expectEntry = false; + j += id[0].length; + continue; + } + j += 1; + } + return members.length ? [...new Set(members)] : null; + } + return null; +} + +/** A symbol's members in any shape this gate reads. @returns {string[] | null} */ +export function membersOf(text, name) { + return extractSymbols(text).get(name) ?? literalMembersOf(text, name); +} + +/** + * The lines a binding judges: its section, narrowed to the table row(s) whose + * first cell is exactly `row` when the binding names one. A row binding exists + * because one table row can be exhaustive while its neighbours are not — the + * CLI table's `os generate ` row lists every kind, the rest of that + * section names other commands, and a section pool would credit a kind + * backticked in an unrelated row. + * + * @returns {{lines: string[]} | {error: 'absent' | 'ambiguous' | 'no-row', count: number}} + */ +export function scopeOf(text, binding) { + const section = sectionOf(text, binding.heading); + if (section.error || binding.row === undefined) return section; + const lines = section.lines.filter((l) => TABLE_ROW.test(l) && (splitRow(l)[0] || '') === binding.row); + return lines.length ? { lines } : { error: 'no-row', count: 0 }; +} + +/** + * The member set a binding asserts, after its set algebra: `symbol`, kept to + * what `intersect` also names, less what `minus` names. The retired half is + * returned too, because a section that documents a member `minus` took away is + * the phantom of this binding: a live-looking row for a thing that is gone. + * + * @returns {{members: string[], retired: string[]} | {stale: string}} + */ +export function bindingMembers(binding, sources) { + const read = (ref) => { + const src = sources.get(ref.source); + return src === undefined ? null : membersOf(src, ref.symbol); + }; + const base = read(binding); + if (!base) return { stale: binding.symbol }; + let members = base; + if (binding.intersect) { + const other = read(binding.intersect); + if (!other) return { stale: binding.intersect.symbol }; + members = members.filter((m) => other.includes(m)); + } + let retired = []; + if (binding.minus) { + const gone = read(binding.minus); + if (!gone) return { stale: binding.minus.symbol }; + members = members.filter((m) => !gone.includes(m)); + retired = gone; + } + return { members, retired }; +} + // ── Ledger ────────────────────────────────────────────────────────────────── const EMPTY_LEDGER = { leg1Exemptions: [], leg2Gaps: {} }; @@ -623,9 +829,11 @@ export function staleExemptionMessage(entries) { + 'An exemption nobody needs is budget a later edit can spend silently.'; } -export function unknownSymbolMessage(binding) { - return `[stale-binding] ${binding.id} binds ${binding.file} to \`${binding.symbol}\`, ` - + `declared at ${binding.source} — and the symbol is NOT THERE. This is the binding's ` +export function unknownSymbolMessage(binding, symbol = binding.symbol) { + const ref = [binding, binding.intersect, binding.minus].find((r) => r && r.symbol === symbol) || binding; + return `[stale-binding] ${binding.id} binds ${binding.file} to \`${symbol}\`, ` + + `declared at ${ref.source} — and the symbol is NOT THERE, or not in a shape this gate reads ` + + 'whole (a z.enum, a literal union, a string array, or an object literal\'s keys). This is the binding\'s ' + 'positive control: without this failure, deleting the schema would turn the row green, ' + 'which is the exact inversion of what it is for. Re-point `source`/`symbol`, or delete the row.'; } @@ -636,6 +844,11 @@ export function anchorMessage(binding, err) { + `${err.count} times in ${binding.file}, so it cannot scope a section. Taking the first ` + 'silently measures a different table than the one the row\'s author read. Make the anchor unique.'; } + if (err.error === 'no-row') { + return `[stale-binding] ${binding.id}: no table row under ${JSON.stringify(binding.heading)} in ` + + `${binding.file} opens with the cell ${JSON.stringify(binding.row)}. A reworded row unbinds the ` + + 'assertion, so it fails here rather than quietly checking nothing. Re-point `row`, or delete the binding.'; + } return `[stale-binding] ${binding.id}: the heading ${JSON.stringify(binding.heading)} is not in ` + `${binding.file}. A renamed heading unbinds the assertion, so it fails here rather than ` + 'quietly checking nothing. Re-point `heading`, or delete the row.'; @@ -643,7 +856,7 @@ export function anchorMessage(binding, err) { export function missingRowMessage(binding, missing, allowed) { const grew = allowed !== undefined; - return `[leg2-missing-row] ${binding.file} — ${binding.heading} is registered exhaustive over ` + return `[leg2-missing-row] ${binding.file} — ${binding.heading}${binding.row ? ` (row ${binding.row})` : ''} is registered exhaustive over ` + `\`${binding.symbol}\` (${binding.source}) and does not document ` + `${missing.map((m) => `\`${m}\``).join(', ')}` + (grew ? ` — the recorded gap ${allowed.length} grew to ${missing.length}.` : '.') @@ -657,6 +870,14 @@ export function missingRowMessage(binding, missing, allowed) { + 'moved, and only policy tells a ratchet-down from a weakening apart.'; } +export function retiredRowMessage(binding, shown) { + return `[leg2-retired-row] ${binding.file} — ${binding.heading}${binding.row ? ` (row ${binding.row})` : ''} ` + + `documents ${shown.map((m) => `\`${m}\``).join(', ')}, which \`${binding.minus.symbol}\` ` + + `(${binding.minus.source}) declares RETIRED. A retired member listed as live is an instruction an AI ` + + 'author follows straight into a refusal. REMEDY: delete it from the row — the retirement prints its ' + + 'own prescription when someone still reaches for it. There is no ledger for this one.'; +} + export function shrunkGapMessage(binding, missing, allowed) { return `[leg2-improved] ${binding.file} — ${binding.heading}: the recorded gap for ` + `\`${binding.symbol}\` was ${allowed.length} (${allowed.join(', ')}) and is now ` @@ -713,15 +934,17 @@ export function run({ corpus, index, bindings, sources, ledger }) { const gaps = {}; const improved = []; for (const b of bindings) { - const src = sources.get(b.source); - if (src === undefined) { errors.push(unknownSymbolMessage(b)); continue; } - const members = extractSymbols(src).get(b.symbol); - if (!members) { errors.push(unknownSymbolMessage(b)); continue; } + const resolved = bindingMembers(b, sources); + if (resolved.stale) { errors.push(unknownSymbolMessage(b, resolved.stale)); continue; } + const { members, retired } = resolved; const text = byFile.get(b.file); if (text === undefined) { errors.push(anchorMessage(b, { error: 'absent' })); continue; } - const section = sectionOf(text, b.heading); + const section = scopeOf(text, b); if (section.error) { errors.push(anchorMessage(b, section)); continue; } - const missing = undocumentedMembers(members, documentedPool(section.lines)); + const pool = documentedPool(section.lines); + const shown = retired.filter((m) => pool.has(m)); + if (shown.length) errors.push(retiredRowMessage(b, shown)); + const missing = undocumentedMembers(members, pool); const allowed = ledger.leg2Gaps[b.id]; if (missing.length) gaps[b.id] = missing; if (allowed === undefined) { @@ -812,10 +1035,10 @@ function readIndexFiles() { function readSources(bindings) { const sources = new Map(); - for (const b of bindings) { - if (sources.has(b.source)) continue; - const abs = join(REPO_ROOT, b.source); - if (existsSync(abs)) sources.set(b.source, readFileSync(abs, 'utf8')); + for (const ref of bindings.flatMap((b) => [b, b.intersect, b.minus]).filter(Boolean)) { + if (sources.has(ref.source)) continue; + const abs = join(REPO_ROOT, ref.source); + if (existsSync(abs)) sources.set(ref.source, readFileSync(abs, 'utf8')); } return sources; } @@ -1127,6 +1350,60 @@ function selfTest() { }); expect('duplicate binding ids are refused', r2dupe.errors.some((e) => e.includes('duplicate binding id'))); + // ── LEG 2: arrays, object keys, set algebra, row scope ─────────────────── + battery('LEG 2: string arrays, object keys, set algebra and row scope'); + const literalText = [ + "export const OPS = ['$eq', // '$notAMember'", + " '$ne', '$empty'] as const;", + "export const TOKENS: readonly string[] = Object.freeze(['ai', 'cache', 'mailbox']);", + 'const PROVIDERS: Readonly> = {', + " cache: { pkg: 'a', nested: { deep: 1 } },", + " // ghost: { pkg: 'commented out' },", + " 'mailbox': { pkg: 'b', help: ['https://objectstack.ai/docs', `t ${'{'} x`] },", + '};', + 'const KINDS: Record void }> = { object: { run: () => {} }, agent: {}, flow: {} };', + "const GONE: Record = { agent: 'retired' };", + "const SPREAD = [...OPS, '$x'];", + 'const COMPUTED = { [KEY]: 1 };', + ].join('\n'); + eq('a const string array is read, comments masked', literalMembersOf(literalText, 'OPS'), ['$eq', '$ne', '$empty']); + eq('Object.freeze and a type annotation do not hide an array', literalMembersOf(literalText, 'TOKENS'), ['ai', 'cache', 'mailbox']); + eq('an object literal yields its top-level KEYS, past a braced type annotation, nested values and a URL string', + literalMembersOf(literalText, 'PROVIDERS'), ['cache', 'mailbox']); + eq('a function-typed annotation (`=>`) does not end the type early', literalMembersOf(literalText, 'KINDS'), ['object', 'agent', 'flow']); + eq('a spread element REFUSES the read rather than guessing', literalMembersOf(literalText, 'SPREAD'), null); + eq('a computed key REFUSES the read', literalMembersOf(literalText, 'COMPUTED'), null); + eq('an undeclared name reads as absent', literalMembersOf(literalText, 'NOPE'), null); + const algebra = new Map([['lit.ts', literalText]]); + eq('intersect keeps only members the second symbol names', + bindingMembers({ symbol: 'TOKENS', source: 'lit.ts', intersect: { symbol: 'PROVIDERS', source: 'lit.ts' } }, algebra).members, + ['cache', 'mailbox']); + const minus = bindingMembers({ symbol: 'KINDS', source: 'lit.ts', minus: { symbol: 'GONE', source: 'lit.ts' } }, algebra); + eq('minus drops the retired members and returns them', [minus.members, minus.retired], [['object', 'flow'], ['agent']]); + eq('a vanished intersect symbol is stale, naming that symbol', + bindingMembers({ symbol: 'TOKENS', source: 'lit.ts', intersect: { symbol: 'Gone', source: 'lit.ts' } }, algebra).stale, 'Gone'); + + const cli = [ + '## Build & runtime', '| Command | What |', '|:--|:--|', + '| `os build` | builds an `app` |', '| `os generate ` | Scaffold `object` `flow` |', + ].join('\n'); + const rowBinding = { + id: 'kinds', file: 'skills/s/ops.md', heading: '## Build & runtime', row: '`os generate `', + symbol: 'KINDS', source: 'lit.ts', minus: { symbol: 'GONE', source: 'lit.ts' }, why: 'synthetic row-scoped binding with a retired set', + }; + const idxCli = new Set(['os']); + const rRow = (text) => run({ corpus: [{ file: 'skills/s/ops.md', text }], index: idxCli, bindings: [rowBinding], sources: algebra, ledger: noLedger }); + eq('a row-scoped binding over a complete row is green', rRow(cli).errors, []); + const rowMiss = rRow(cli.replace(' `flow` |', ' |')); + expect('the ROW is the scope: a member backticked in a neighbouring row does not count', + rRow(cli.replace(' `flow` |', ' |').replace('`app`', '`flow`')).errors.length === 1 && rowMiss.errors[0].includes('`flow`')); + expect('the missing-row finding names the row', rowMiss.errors[0].includes('(row `os generate `)')); + const rowRetired = rRow(cli.replace('`flow` |', '`flow` `agent` |')); + expect('a retired member listed in the scope is a [leg2-retired-row] red', + rowRetired.errors.length === 1 && rowRetired.errors[0].startsWith('[leg2-retired-row]') && rowRetired.errors[0].includes('`agent`')); + expect('a reworded row is a stale binding, never an empty scope read green', + rRow(cli.replace('`os generate `', '`os g `')).errors.some((e) => e.startsWith('[stale-binding]') && e.includes('no table row'))); + // ── both legs in ONE walk ──────────────────────────────────────────────── battery('both legs in ONE walk'); const both = run({ diff --git a/skills/objectstack-platform/references/operations.md b/skills/objectstack-platform/references/operations.md index 074cb0e53b3..ca3f2d1383f 100644 --- a/skills/objectstack-platform/references/operations.md +++ b/skills/objectstack-platform/references/operations.md @@ -20,7 +20,7 @@ | `os compile` | Compile to portable JSON artifact (for runtime hydration) | | `os serve` | Serve a compiled stack in production mode | | `os start` | Quick-start a server: auto-compiles `objectstack.config.ts` when no artifact is present, and falls back to an empty kernel with the Console + marketplace when there is no config at all. It does **not** validate env or apply migrations — run `os validate` / `os migrate apply` yourself | -| `os generate ` | Scaffold an object / view / flow / skill from a template | +| `os generate ` | Scaffold `object` `view` `action` `flow` `dashboard` `app` `skill` `picklist` `permission` `dataset` | ## Verify your work @@ -61,8 +61,8 @@ Port resolution is the same for `os dev` and `os start` (both spawn `os serve`): | Mode | If the resolved port is busy | |:-----|:-----------------------------| -| **Dev** (`os dev`, or `NODE_ENV=development`) | Auto-hops to the next free port (up to +100) so several example apps run side-by-side. The startup banner shows the *actual* bound port. | -| **Production** (`os start`) | **Fails loudly and exits 1.** It never silently drifts. | +| **Dev** (`os dev`, or `NODE_ENV=development`) | Auto-hops to the next free port (up to +100). The startup banner shows the *actual* bound port. | +| **Production** (`os start`) | **Fails loudly and exits 1.** | **Production guidance:** From f6a7e61a2bc5712e945619863dcad804cc1c2a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 06:36:21 +0000 Subject: [PATCH 3/7] feat(scripts): the skill-compatibility gate reads the versions a skill body names Pure functions and self-test rows only: extractBodyVersions finds the install-shape pins and the prose versions in every skill markdown body, judgeBodyVersions holds each to its package's workspace version unless its sentence reads as history, and deriveBodyVersions is the --fix derivation that rewrites the non-history ones. Kept apart from deriveFrontmatter, which the token ratchet asks for the one stamp line. Not yet called from the gate's run or its --fix leg. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jU4PLtyyimMz72z285 --- scripts/check-skill-compatibility-version.mjs | 265 +++++++++++++++++- 1 file changed, 264 insertions(+), 1 deletion(-) diff --git a/scripts/check-skill-compatibility-version.mjs b/scripts/check-skill-compatibility-version.mjs index b3336030885..d8e355042a4 100644 --- a/scripts/check-skill-compatibility-version.mjs +++ b/scripts/check-skill-compatibility-version.mjs @@ -346,6 +346,159 @@ export function deriveFrontmatter(text, pkgs) { return { text: lines.join('\n'), changes }; } +// --------------------------------------------------------------------------- +// Body versions — the exact versions a skill's BODY names +// --------------------------------------------------------------------------- +// +// The frontmatter is not the only place a skill names a release. A body names +// one in two shapes, and both drifted while the frontmatter gate was green: +// `references/bootstrap.md`'s starter package.json pinned `18.1.0` after the +// workspace moved on, and a rule page named `18.3.0` while the package was +// `18.2.0`. The contract here is the card's: a body version is EQUAL to that +// package's workspace version, or it sits in a sentence that reads as history +// (`since`, `removed in`, `until`, …) — a past release named as a past release. +// +// install shape `"@objectstack/": ""` (a `^`/`~` range spelled +// the same way). Always judged, history wording or not: it is +// what an author copies into a package.json. +// prose shape `@objectstack/ ` or `@objectstack/@`. +// +// Frontmatter is skipped (the two derived lines are judged above), and so is +// nothing else: a starter package.json lives in a fenced block on purpose. +// +// `deriveBodyVersions` is the pure derivation of every non-history body +// version, kept apart from `deriveFrontmatter` because the token ratchet asks +// that function which ONE line of a SKILL.md is the release stamp. + +const BODY_INSTALL_PIN_RE = /"@objectstack\/([a-z0-9][a-z0-9-]*)"(\s*:\s*")([~^]?)(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)"/g; +const BODY_PROSE_VERSION_RE = /@objectstack\/([a-z0-9][a-z0-9-]*)([ @]v?)(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)/g; + +/** Words that make a sentence name a past release rather than the current one. */ +const HISTORY_RE = /\b(?:since|until|before|as of|(?:removed|retired|added|introduced|deprecated|renamed|changed|shipped|landed) in)\b/i; + +/** The frontmatter's line count (0 when there is none), so body scans start below it. */ +function frontmatterEnd(lines) { + if (lines[0]?.trim() !== '---') return 0; + const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---'); + return end === -1 ? 0 : end + 1; +} + +/** + * Every body version in one file, each with its line, shape, the version + * named, and whether its sentence reads as history. + * + * @returns {Array<{line: number, shape: 'install' | 'prose', pkg: string, version: string, history: boolean}>} + */ +export function extractBodyVersions(text) { + const lines = text.split('\n'); + const out = []; + const start = frontmatterEnd(lines); + // The sentence a match sits in, across line breaks (prose is wrapped): the + // paragraph (its run of non-blank lines) joined with spaces, cut back to the + // previous sentence end and forward to the next. + const sentenceAt = (i, col) => { + let top = i; + while (top - 1 >= start && lines[top - 1].trim() !== '') top -= 1; + let bottom = i; + while (bottom + 1 < lines.length && lines[bottom + 1].trim() !== '') bottom += 1; + const para = lines.slice(top, bottom + 1).join(' '); + const at = lines.slice(top, i).reduce((n, l) => n + l.length + 1, 0) + col; + const head = para.slice(0, at).split(/[.!?]\s+/).pop(); + const tail = para.slice(at).split(/[.!?](?:\s|$)/)[0]; + return `${head}${tail}`; + }; + for (let i = start; i < lines.length; i += 1) { + for (const m of lines[i].matchAll(BODY_INSTALL_PIN_RE)) { + out.push({ line: i + 1, shape: 'install', pkg: `@objectstack/${m[1]}`, version: m[4], history: false }); + } + for (const m of lines[i].matchAll(BODY_PROSE_VERSION_RE)) { + out.push({ line: i + 1, shape: 'prose', pkg: `@objectstack/${m[1]}`, version: m[3], history: HISTORY_RE.test(sentenceAt(i, m.index)) }); + } + } + return out; +} + +/** + * The body half of the check, over every skill markdown file. + * + * @param files [{ file, text }] every `*.md` under skills/ + * @param pkgs Map + * @returns {{ problems: string[], count: number }} + */ +export function judgeBodyVersions(files, pkgs) { + const problems = []; + let count = 0; + for (const { file, text } of files) { + for (const v of extractBodyVersions(text)) { + count += 1; + const actual = pkgs.get(v.pkg); + if (!actual) { + problems.push(`${file}:${v.line}\n names ${v.pkg} ${v.version}, but no workspace package is named ${v.pkg}\n` + + ' fix: correct the package name, or drop the version.'); + continue; + } + if (v.version === actual.version || (v.shape === 'prose' && v.history)) continue; + problems.push( + `${file}:${v.line}\n` + + ` names: ${v.pkg} ${v.version} (${v.shape === 'install' ? 'an install pin an author copies' : 'prose that does not read as history'})\n` + + ` actual: ${v.pkg} ${actual.version} (${actual.file})\n` + + (v.shape === 'install' + ? ` fix: run \`${FIX_COMMAND}\` — it rewrites the pin to "${actual.version}"; the version is derived, never typed.` + : ` fix: run \`${FIX_COMMAND}\` to name the current version, or say which past release it was ` + + '("since", "removed in", "until", …) if that is what the sentence means.'), + ); + } + } + return { problems, count }; +} + +/** + * The `--fix` derivation for a body, pure: every body version that is not a + * history sentence becomes its package's workspace version (an install pin + * keeps its `^`/`~`). A version naming a package the workspace lacks is left + * for the check to refuse. + * + * @returns {{ text: string, changes: string[] }} + */ +export function deriveBodyVersions(text, pkgs) { + const lines = text.split('\n'); + const changes = []; + const history = new Set(extractBodyVersions(text).filter((v) => v.history).map((v) => `${v.line}:${v.pkg}:${v.version}`)); + for (let i = frontmatterEnd(lines); i < lines.length; i += 1) { + const to = (pkg, version) => { + const actual = pkgs.get(`@objectstack/${pkg}`); + if (!actual || actual.version === version || history.has(`${i + 1}:@objectstack/${pkg}:${version}`)) return null; + changes.push(`line ${i + 1}: @objectstack/${pkg} ${version} → ${actual.version}`); + return actual.version; + }; + lines[i] = lines[i] + .replace(BODY_INSTALL_PIN_RE, (whole, pkg, sep, range, version) => { + const next = to(pkg, version); + return next === null ? whole : `"@objectstack/${pkg}"${sep}${range}${next}"`; + }) + .replace(BODY_PROSE_VERSION_RE, (whole, pkg, sep, version) => { + const next = to(pkg, version); + return next === null ? whole : `@objectstack/${pkg}${sep}${next}`; + }); + } + return { text: lines.join('\n'), changes }; +} + +/** Every `*.md` under skills/, as { file, text }, sorted by path. */ +export function readSkillMarkdown(root = REPO_ROOT) { + const out = []; + const walk = (rel) => { + for (const e of readdirSync(join(root, rel), { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + const child = `${rel}/${e.name}`; + if (e.isDirectory()) walk(child); + else if (e.isFile() && e.name.endsWith('.md')) out.push(child); + } + }; + if (existsSync(join(root, SKILLS_DIR))) walk(SKILLS_DIR); + return out.sort().map((file) => ({ file, text: readFileSync(join(root, file), 'utf8') })); +} + // --------------------------------------------------------------------------- // Checks (pure — the self-test drives these with in-memory inputs) // --------------------------------------------------------------------------- @@ -754,6 +907,16 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'stampedSkillPaths() equals the set --fix writes on an all-stale fixture, in walk order': 1, 'a SKILL.md write outside stampedSkillPaths() stays unexpected under the guard\'s grep -vxF -f filter': 1, 'stampedSkillPaths() THROWS on a tree with no skills, never an empty list': 1, + 'body: an install pin at the workspace version → GREEN; one behind → RED naming file:line, both versions and the fix': 1, + 'body: a ^/~ range pin is judged by its version and keeps its range under --fix': 1, + 'body: prose at the current version → GREEN; a past version outside a history sentence → RED': 1, + 'body: a past version in a history sentence (removed in / since / until), wrapped across lines → GREEN': 1, + 'body: history wording one sentence away does not excuse a version': 1, + 'body: an install pin is judged even when its sentence reads as history': 1, + 'body: a body version naming a package the workspace lacks → RED': 1, + 'body: frontmatter pins and the stamp are not body versions': 1, + 'body: deriveBodyVersions rewrites every non-history version and nothing else; a second pass is a no-op': 1, + 'body: the live catalog carries install pins to judge (lit control), every one naming a workspace package': 1, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -762,7 +925,106 @@ const SELF_TEST_BATTERIES = Object.freeze({ // the literal above, so the roster falls below this number; the table // cross-check in the floor block is the other half, and names WHICH label // collided. -const SELF_TEST_BATTERY_FLOOR = 30; +const SELF_TEST_BATTERY_FLOOR = 40; + +// The body-version rows of the self-test's derivation table, each a battery of +// its own (see SELF_TEST_BATTERIES). A row throws to fail. +function bodyVersionCases(PKGS) { + const V = PKGS.get('@objectstack/spec').version; + const md = (body) => `---\nname: x\ncompatibility: Requires @objectstack/spec 17.x\nmetadata:\n version: "${V}"\n---\n\n${body}\n`; + const pkgJson = (v, range = '') => md(`\`\`\`json\n{ "dependencies": {\n "@objectstack/spec": "${range}${v}",\n "@objectstack/core": "${range}${v}"\n} }\n\`\`\``); + const judgeOne = (text) => judgeBodyVersions([{ file: 'skills/a/references/bootstrap.md', text }], PKGS); + const need = (cond, msg) => { if (!cond) throw new Error(msg); }; + return [ + { + label: 'body: an install pin at the workspace version → GREEN; one behind → RED naming file:line, both versions and the fix', + run: () => { + need(judgeOne(pkgJson(V)).problems.length === 0, 'a current pin was refused'); + const r = judgeOne(pkgJson('17.0.0-rc.4')); + need(r.problems.length === 2, `want 2 problems, got ${r.problems.length}`); + need(r.problems[0].startsWith('skills/a/references/bootstrap.md:10') && r.problems[0].includes('17.0.0-rc.4') + && r.problems[0].includes(V) && r.problems[0].includes(FIX_COMMAND), r.problems[0]); + }, + }, + { + label: 'body: a ^/~ range pin is judged by its version and keeps its range under --fix', + run: () => { + need(judgeOne(pkgJson(V, '^')).problems.length === 0, 'a current ^ pin was refused'); + need(judgeOne(pkgJson('16.0.0', '~')).problems.length === 2, 'a stale ~ pin was not refused'); + const d = deriveBodyVersions(pkgJson('16.0.0', '~'), PKGS); + need(d.text === pkgJson(V, '~') && d.changes.length === 2, d.text); + }, + }, + { + label: 'body: prose at the current version → GREEN; a past version outside a history sentence → RED', + run: () => { + need(judgeOne(md(`Install @objectstack/spec ${V} and go.`)).problems.length === 0, 'current prose refused'); + const r = judgeOne(md('Install @objectstack/spec@16.2.0 and go.')); + need(r.problems.length === 1 && r.problems[0].includes('does not read as history'), JSON.stringify(r.problems)); + }, + }, + { + label: 'body: a past version in a history sentence (removed in / since / until), wrapped across lines → GREEN', + run: () => { + const text = md('`split` and `popover` were\nremoved in @objectstack/spec 16.2.0 (ADR-0087)\nand are refused. Present since @objectstack/spec 15.0.0. Accepted until @objectstack/core 16.0.0.'); + const r = judgeOne(text); + need(r.problems.length === 0 && r.count === 3, JSON.stringify(r)); + need(deriveBodyVersions(text, PKGS).changes.length === 0, '--fix rewrote a history sentence'); + }, + }, + { + label: 'body: history wording one sentence away does not excuse a version', + run: () => { + const r = judgeOne(md('Removed in 16.0.0, sadly. Install @objectstack/spec 16.2.0 now.')); + need(r.problems.length === 1, JSON.stringify(r.problems)); + }, + }, + { + label: 'body: an install pin is judged even when its sentence reads as history', + run: () => { + const r = judgeOne(md('Since the move: "@objectstack/spec": "16.0.0"')); + need(r.problems.length === 1 && r.problems[0].includes('an install pin'), JSON.stringify(r.problems)); + }, + }, + { + label: 'body: a body version naming a package the workspace lacks → RED', + run: () => { + const r = judgeOne(md('"@objectstack/nope": "1.0.0"')); + need(r.problems.length === 1 && r.problems[0].includes('no workspace package is named @objectstack/nope'), JSON.stringify(r.problems)); + }, + }, + { + label: 'body: frontmatter pins and the stamp are not body versions', + run: () => { + const text = `---\nname: x\ncompatibility: Requires @objectstack/spec 9.x and @objectstack/spec 9.0.0\nmetadata:\n version: "9.0.0"\n---\n\nBody.\n`; + need(extractBodyVersions(text).length === 0, 'the frontmatter was read as body'); + need(extractBodyVersions(md('Requires @objectstack/spec 17.x here.')).length === 0, 'an N.x pin was read as an exact version'); + }, + }, + { + label: 'body: deriveBodyVersions rewrites every non-history version and nothing else; a second pass is a no-op', + run: () => { + const stale = md('Install @objectstack/spec 16.2.0. Removed in @objectstack/spec 16.0.0.\n\n```json\n"@objectstack/core": "16.2.0"\n```'); + const want = md(`Install @objectstack/spec ${V}. Removed in @objectstack/spec 16.0.0.\n\n\`\`\`json\n"@objectstack/core": "${V}"\n\`\`\``); + const once = deriveBodyVersions(stale, PKGS); + need(once.text === want && once.changes.length === 2, once.text); + const twice = deriveBodyVersions(once.text, PKGS); + need(twice.text === once.text && twice.changes.length === 0, 'a second pass moved bytes'); + need(deriveFrontmatter(once.text, PKGS).changes.length === 0, 'the body derivation touched the frontmatter'); + }, + }, + { + label: 'body: the live catalog carries install pins to judge (lit control), every one naming a workspace package', + run: () => { + const pkgs = readWorkspacePackages(); + const found = readSkillMarkdown().flatMap((f) => extractBodyVersions(f.text)); + const install = found.filter((v) => v.shape === 'install'); + need(install.length >= 1, `the live catalog yields ${install.length} install pin(s); the extractor stopped matching`); + need(found.every((v) => pkgs.has(v.pkg)), 'a live body version names a package the workspace lacks'); + }, + }, + ]; +} function selfTest() { console.log('check-skill-compatibility-version self-test\n'); @@ -1133,6 +1395,7 @@ function selfTest() { } }, }, + ...bodyVersionCases(PKGS), ]; for (const c of derivationCases) { registerCase(c.label); From abe37e9a433faa9d3a491e52cef7c685253d6070 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 06:36:38 +0000 Subject: [PATCH 4/7] chore(changeset): a skills patch for the os generate row Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jU4PLtyyimMz72z285 --- .changeset/31115-skills-link-and-liveness-gates.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/31115-skills-link-and-liveness-gates.md diff --git a/.changeset/31115-skills-link-and-liveness-gates.md b/.changeset/31115-skills-link-and-liveness-gates.md new file mode 100644 index 00000000000..d298cb0ef13 --- /dev/null +++ b/.changeset/31115-skills-link-and-liveness-gates.md @@ -0,0 +1,10 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): the operations reference's `os generate ` row names every kind the CLI scaffolds + +Clause-②: no +Metadata-protocol: none + +`skills/objectstack-platform/references/operations.md`: the `os generate ` row listed four kinds in prose ("an object / view / flow / skill"); it now lists all ten `GENERATORS` keys (`object` `view` `action` `flow` `dashboard` `app` `skill` `picklist` `permission` `dataset`). The liveness gate now holds that row exhaustive against `GENERATORS` minus `RETIRED_GENERATORS`, so a new generator without a row, or a retired one (`agent`, `schema`) listed again, reds. Two restating clauses in the same file's port table ("so several example apps run side-by-side", "It never silently drifts") were deleted to pay the token ratchet for the longer row. From cddfb86fbfa70cbbbab3cf8d7f915ad01d480532 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 06:56:56 +0000 Subject: [PATCH 5/7] fix(scripts): the liveness self-test's reworded-row fixture names no unknown command Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jU4PLtyyimMz72z285 --- scripts/check-skill-identifier-liveness.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-skill-identifier-liveness.mjs b/scripts/check-skill-identifier-liveness.mjs index 352489940e2..746cd36ad3b 100644 --- a/scripts/check-skill-identifier-liveness.mjs +++ b/scripts/check-skill-identifier-liveness.mjs @@ -1402,7 +1402,7 @@ function selfTest() { expect('a retired member listed in the scope is a [leg2-retired-row] red', rowRetired.errors.length === 1 && rowRetired.errors[0].startsWith('[leg2-retired-row]') && rowRetired.errors[0].includes('`agent`')); expect('a reworded row is a stale binding, never an empty scope read green', - rRow(cli.replace('`os generate `', '`os g `')).errors.some((e) => e.startsWith('[stale-binding]') && e.includes('no table row'))); + rRow(cli.replace('`os generate `', '`os generate KIND`')).errors.some((e) => e.startsWith('[stale-binding]') && e.includes('no table row'))); // ── both legs in ONE walk ──────────────────────────────────────────────── battery('both legs in ONE walk'); From 1eedd80abcadc2e6074aab3a5626401b0ed824ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 07:09:11 +0000 Subject: [PATCH 6/7] docs(scripts): check-skill-links' header names the reproduced links in prose, not as path anchors Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jU4PLtyyimMz72z285 --- scripts/check-skill-links.mjs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/scripts/check-skill-links.mjs b/scripts/check-skill-links.mjs index a50582b2656..45f5b59c1e6 100644 --- a/scripts/check-skill-links.mjs +++ b/scripts/check-skill-links.mjs @@ -20,15 +20,14 @@ * ## The expected catch * * "Moved verbatim" refactors. A section moves from SKILL.md into a references/ - * or rules/ file (or back), the inbound links keep the old `../SKILL.md#slug`, + * or rules/ file (or back), the inbound links keep the old SKILL.md fragment, * and every other skill gate stays green, because none of them reads links. The - * 2026-11-02 review found three such links by hand, all of this one shape: - * `objectstack-data/references/data-hooks.md` -> `../SKILL.md#lifecycle-hooks`, - * `objectstack-platform/rules/plugin-lifecycle.md` -> - * `../SKILL.md#complete-plugin-example`, and - * `objectstack-platform/rules/service-registry.md` -> - * `../SKILL.md#well-known-plugin-names--services`. The self-test reproduces that - * shape and must red on it. + * 2026-11-02 review found three such links by hand, all of this one shape: the + * objectstack-data data-hooks reference linking its SKILL.md at fragment + * lifecycle-hooks, and the objectstack-platform plugin-lifecycle and + * service-registry rules linking theirs at complete-plugin-example and + * well-known-plugin-names--services. The self-test reproduces that shape and + * must red on it. * * ## The slug rule is the whole gate * From 11be77328934272cda588cf8d813a6c6616033a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 07:11:50 +0000 Subject: [PATCH 7/7] revert(scripts): hold the skill-compatibility body-version reader for its own PR Reverts f6a7e61a. The body-version functions are not yet called from the gate's run or its --fix leg, and where their --fix writes land is still being decided, so this PR ships without uncalled code. The patch is kept for the follow-up. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jU4PLtyyimMz72z285 --- scripts/check-skill-compatibility-version.mjs | 265 +----------------- 1 file changed, 1 insertion(+), 264 deletions(-) diff --git a/scripts/check-skill-compatibility-version.mjs b/scripts/check-skill-compatibility-version.mjs index d8e355042a4..b3336030885 100644 --- a/scripts/check-skill-compatibility-version.mjs +++ b/scripts/check-skill-compatibility-version.mjs @@ -346,159 +346,6 @@ export function deriveFrontmatter(text, pkgs) { return { text: lines.join('\n'), changes }; } -// --------------------------------------------------------------------------- -// Body versions — the exact versions a skill's BODY names -// --------------------------------------------------------------------------- -// -// The frontmatter is not the only place a skill names a release. A body names -// one in two shapes, and both drifted while the frontmatter gate was green: -// `references/bootstrap.md`'s starter package.json pinned `18.1.0` after the -// workspace moved on, and a rule page named `18.3.0` while the package was -// `18.2.0`. The contract here is the card's: a body version is EQUAL to that -// package's workspace version, or it sits in a sentence that reads as history -// (`since`, `removed in`, `until`, …) — a past release named as a past release. -// -// install shape `"@objectstack/": ""` (a `^`/`~` range spelled -// the same way). Always judged, history wording or not: it is -// what an author copies into a package.json. -// prose shape `@objectstack/ ` or `@objectstack/@`. -// -// Frontmatter is skipped (the two derived lines are judged above), and so is -// nothing else: a starter package.json lives in a fenced block on purpose. -// -// `deriveBodyVersions` is the pure derivation of every non-history body -// version, kept apart from `deriveFrontmatter` because the token ratchet asks -// that function which ONE line of a SKILL.md is the release stamp. - -const BODY_INSTALL_PIN_RE = /"@objectstack\/([a-z0-9][a-z0-9-]*)"(\s*:\s*")([~^]?)(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)"/g; -const BODY_PROSE_VERSION_RE = /@objectstack\/([a-z0-9][a-z0-9-]*)([ @]v?)(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)/g; - -/** Words that make a sentence name a past release rather than the current one. */ -const HISTORY_RE = /\b(?:since|until|before|as of|(?:removed|retired|added|introduced|deprecated|renamed|changed|shipped|landed) in)\b/i; - -/** The frontmatter's line count (0 when there is none), so body scans start below it. */ -function frontmatterEnd(lines) { - if (lines[0]?.trim() !== '---') return 0; - const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---'); - return end === -1 ? 0 : end + 1; -} - -/** - * Every body version in one file, each with its line, shape, the version - * named, and whether its sentence reads as history. - * - * @returns {Array<{line: number, shape: 'install' | 'prose', pkg: string, version: string, history: boolean}>} - */ -export function extractBodyVersions(text) { - const lines = text.split('\n'); - const out = []; - const start = frontmatterEnd(lines); - // The sentence a match sits in, across line breaks (prose is wrapped): the - // paragraph (its run of non-blank lines) joined with spaces, cut back to the - // previous sentence end and forward to the next. - const sentenceAt = (i, col) => { - let top = i; - while (top - 1 >= start && lines[top - 1].trim() !== '') top -= 1; - let bottom = i; - while (bottom + 1 < lines.length && lines[bottom + 1].trim() !== '') bottom += 1; - const para = lines.slice(top, bottom + 1).join(' '); - const at = lines.slice(top, i).reduce((n, l) => n + l.length + 1, 0) + col; - const head = para.slice(0, at).split(/[.!?]\s+/).pop(); - const tail = para.slice(at).split(/[.!?](?:\s|$)/)[0]; - return `${head}${tail}`; - }; - for (let i = start; i < lines.length; i += 1) { - for (const m of lines[i].matchAll(BODY_INSTALL_PIN_RE)) { - out.push({ line: i + 1, shape: 'install', pkg: `@objectstack/${m[1]}`, version: m[4], history: false }); - } - for (const m of lines[i].matchAll(BODY_PROSE_VERSION_RE)) { - out.push({ line: i + 1, shape: 'prose', pkg: `@objectstack/${m[1]}`, version: m[3], history: HISTORY_RE.test(sentenceAt(i, m.index)) }); - } - } - return out; -} - -/** - * The body half of the check, over every skill markdown file. - * - * @param files [{ file, text }] every `*.md` under skills/ - * @param pkgs Map - * @returns {{ problems: string[], count: number }} - */ -export function judgeBodyVersions(files, pkgs) { - const problems = []; - let count = 0; - for (const { file, text } of files) { - for (const v of extractBodyVersions(text)) { - count += 1; - const actual = pkgs.get(v.pkg); - if (!actual) { - problems.push(`${file}:${v.line}\n names ${v.pkg} ${v.version}, but no workspace package is named ${v.pkg}\n` + - ' fix: correct the package name, or drop the version.'); - continue; - } - if (v.version === actual.version || (v.shape === 'prose' && v.history)) continue; - problems.push( - `${file}:${v.line}\n` + - ` names: ${v.pkg} ${v.version} (${v.shape === 'install' ? 'an install pin an author copies' : 'prose that does not read as history'})\n` + - ` actual: ${v.pkg} ${actual.version} (${actual.file})\n` + - (v.shape === 'install' - ? ` fix: run \`${FIX_COMMAND}\` — it rewrites the pin to "${actual.version}"; the version is derived, never typed.` - : ` fix: run \`${FIX_COMMAND}\` to name the current version, or say which past release it was ` + - '("since", "removed in", "until", …) if that is what the sentence means.'), - ); - } - } - return { problems, count }; -} - -/** - * The `--fix` derivation for a body, pure: every body version that is not a - * history sentence becomes its package's workspace version (an install pin - * keeps its `^`/`~`). A version naming a package the workspace lacks is left - * for the check to refuse. - * - * @returns {{ text: string, changes: string[] }} - */ -export function deriveBodyVersions(text, pkgs) { - const lines = text.split('\n'); - const changes = []; - const history = new Set(extractBodyVersions(text).filter((v) => v.history).map((v) => `${v.line}:${v.pkg}:${v.version}`)); - for (let i = frontmatterEnd(lines); i < lines.length; i += 1) { - const to = (pkg, version) => { - const actual = pkgs.get(`@objectstack/${pkg}`); - if (!actual || actual.version === version || history.has(`${i + 1}:@objectstack/${pkg}:${version}`)) return null; - changes.push(`line ${i + 1}: @objectstack/${pkg} ${version} → ${actual.version}`); - return actual.version; - }; - lines[i] = lines[i] - .replace(BODY_INSTALL_PIN_RE, (whole, pkg, sep, range, version) => { - const next = to(pkg, version); - return next === null ? whole : `"@objectstack/${pkg}"${sep}${range}${next}"`; - }) - .replace(BODY_PROSE_VERSION_RE, (whole, pkg, sep, version) => { - const next = to(pkg, version); - return next === null ? whole : `@objectstack/${pkg}${sep}${next}`; - }); - } - return { text: lines.join('\n'), changes }; -} - -/** Every `*.md` under skills/, as { file, text }, sorted by path. */ -export function readSkillMarkdown(root = REPO_ROOT) { - const out = []; - const walk = (rel) => { - for (const e of readdirSync(join(root, rel), { withFileTypes: true })) { - if (e.name === 'node_modules' || e.name.startsWith('.')) continue; - const child = `${rel}/${e.name}`; - if (e.isDirectory()) walk(child); - else if (e.isFile() && e.name.endsWith('.md')) out.push(child); - } - }; - if (existsSync(join(root, SKILLS_DIR))) walk(SKILLS_DIR); - return out.sort().map((file) => ({ file, text: readFileSync(join(root, file), 'utf8') })); -} - // --------------------------------------------------------------------------- // Checks (pure — the self-test drives these with in-memory inputs) // --------------------------------------------------------------------------- @@ -907,16 +754,6 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'stampedSkillPaths() equals the set --fix writes on an all-stale fixture, in walk order': 1, 'a SKILL.md write outside stampedSkillPaths() stays unexpected under the guard\'s grep -vxF -f filter': 1, 'stampedSkillPaths() THROWS on a tree with no skills, never an empty list': 1, - 'body: an install pin at the workspace version → GREEN; one behind → RED naming file:line, both versions and the fix': 1, - 'body: a ^/~ range pin is judged by its version and keeps its range under --fix': 1, - 'body: prose at the current version → GREEN; a past version outside a history sentence → RED': 1, - 'body: a past version in a history sentence (removed in / since / until), wrapped across lines → GREEN': 1, - 'body: history wording one sentence away does not excuse a version': 1, - 'body: an install pin is judged even when its sentence reads as history': 1, - 'body: a body version naming a package the workspace lacks → RED': 1, - 'body: frontmatter pins and the stamp are not body versions': 1, - 'body: deriveBodyVersions rewrites every non-history version and nothing else; a second pass is a no-op': 1, - 'body: the live catalog carries install pins to judge (lit control), every one naming a workspace package': 1, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -925,106 +762,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // the literal above, so the roster falls below this number; the table // cross-check in the floor block is the other half, and names WHICH label // collided. -const SELF_TEST_BATTERY_FLOOR = 40; - -// The body-version rows of the self-test's derivation table, each a battery of -// its own (see SELF_TEST_BATTERIES). A row throws to fail. -function bodyVersionCases(PKGS) { - const V = PKGS.get('@objectstack/spec').version; - const md = (body) => `---\nname: x\ncompatibility: Requires @objectstack/spec 17.x\nmetadata:\n version: "${V}"\n---\n\n${body}\n`; - const pkgJson = (v, range = '') => md(`\`\`\`json\n{ "dependencies": {\n "@objectstack/spec": "${range}${v}",\n "@objectstack/core": "${range}${v}"\n} }\n\`\`\``); - const judgeOne = (text) => judgeBodyVersions([{ file: 'skills/a/references/bootstrap.md', text }], PKGS); - const need = (cond, msg) => { if (!cond) throw new Error(msg); }; - return [ - { - label: 'body: an install pin at the workspace version → GREEN; one behind → RED naming file:line, both versions and the fix', - run: () => { - need(judgeOne(pkgJson(V)).problems.length === 0, 'a current pin was refused'); - const r = judgeOne(pkgJson('17.0.0-rc.4')); - need(r.problems.length === 2, `want 2 problems, got ${r.problems.length}`); - need(r.problems[0].startsWith('skills/a/references/bootstrap.md:10') && r.problems[0].includes('17.0.0-rc.4') - && r.problems[0].includes(V) && r.problems[0].includes(FIX_COMMAND), r.problems[0]); - }, - }, - { - label: 'body: a ^/~ range pin is judged by its version and keeps its range under --fix', - run: () => { - need(judgeOne(pkgJson(V, '^')).problems.length === 0, 'a current ^ pin was refused'); - need(judgeOne(pkgJson('16.0.0', '~')).problems.length === 2, 'a stale ~ pin was not refused'); - const d = deriveBodyVersions(pkgJson('16.0.0', '~'), PKGS); - need(d.text === pkgJson(V, '~') && d.changes.length === 2, d.text); - }, - }, - { - label: 'body: prose at the current version → GREEN; a past version outside a history sentence → RED', - run: () => { - need(judgeOne(md(`Install @objectstack/spec ${V} and go.`)).problems.length === 0, 'current prose refused'); - const r = judgeOne(md('Install @objectstack/spec@16.2.0 and go.')); - need(r.problems.length === 1 && r.problems[0].includes('does not read as history'), JSON.stringify(r.problems)); - }, - }, - { - label: 'body: a past version in a history sentence (removed in / since / until), wrapped across lines → GREEN', - run: () => { - const text = md('`split` and `popover` were\nremoved in @objectstack/spec 16.2.0 (ADR-0087)\nand are refused. Present since @objectstack/spec 15.0.0. Accepted until @objectstack/core 16.0.0.'); - const r = judgeOne(text); - need(r.problems.length === 0 && r.count === 3, JSON.stringify(r)); - need(deriveBodyVersions(text, PKGS).changes.length === 0, '--fix rewrote a history sentence'); - }, - }, - { - label: 'body: history wording one sentence away does not excuse a version', - run: () => { - const r = judgeOne(md('Removed in 16.0.0, sadly. Install @objectstack/spec 16.2.0 now.')); - need(r.problems.length === 1, JSON.stringify(r.problems)); - }, - }, - { - label: 'body: an install pin is judged even when its sentence reads as history', - run: () => { - const r = judgeOne(md('Since the move: "@objectstack/spec": "16.0.0"')); - need(r.problems.length === 1 && r.problems[0].includes('an install pin'), JSON.stringify(r.problems)); - }, - }, - { - label: 'body: a body version naming a package the workspace lacks → RED', - run: () => { - const r = judgeOne(md('"@objectstack/nope": "1.0.0"')); - need(r.problems.length === 1 && r.problems[0].includes('no workspace package is named @objectstack/nope'), JSON.stringify(r.problems)); - }, - }, - { - label: 'body: frontmatter pins and the stamp are not body versions', - run: () => { - const text = `---\nname: x\ncompatibility: Requires @objectstack/spec 9.x and @objectstack/spec 9.0.0\nmetadata:\n version: "9.0.0"\n---\n\nBody.\n`; - need(extractBodyVersions(text).length === 0, 'the frontmatter was read as body'); - need(extractBodyVersions(md('Requires @objectstack/spec 17.x here.')).length === 0, 'an N.x pin was read as an exact version'); - }, - }, - { - label: 'body: deriveBodyVersions rewrites every non-history version and nothing else; a second pass is a no-op', - run: () => { - const stale = md('Install @objectstack/spec 16.2.0. Removed in @objectstack/spec 16.0.0.\n\n```json\n"@objectstack/core": "16.2.0"\n```'); - const want = md(`Install @objectstack/spec ${V}. Removed in @objectstack/spec 16.0.0.\n\n\`\`\`json\n"@objectstack/core": "${V}"\n\`\`\``); - const once = deriveBodyVersions(stale, PKGS); - need(once.text === want && once.changes.length === 2, once.text); - const twice = deriveBodyVersions(once.text, PKGS); - need(twice.text === once.text && twice.changes.length === 0, 'a second pass moved bytes'); - need(deriveFrontmatter(once.text, PKGS).changes.length === 0, 'the body derivation touched the frontmatter'); - }, - }, - { - label: 'body: the live catalog carries install pins to judge (lit control), every one naming a workspace package', - run: () => { - const pkgs = readWorkspacePackages(); - const found = readSkillMarkdown().flatMap((f) => extractBodyVersions(f.text)); - const install = found.filter((v) => v.shape === 'install'); - need(install.length >= 1, `the live catalog yields ${install.length} install pin(s); the extractor stopped matching`); - need(found.every((v) => pkgs.has(v.pkg)), 'a live body version names a package the workspace lacks'); - }, - }, - ]; -} +const SELF_TEST_BATTERY_FLOOR = 30; function selfTest() { console.log('check-skill-compatibility-version self-test\n'); @@ -1395,7 +1133,6 @@ function selfTest() { } }, }, - ...bodyVersionCases(PKGS), ]; for (const c of derivationCases) { registerCase(c.label);