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. 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-identifier-liveness.mjs b/scripts/check-skill-identifier-liveness.mjs index 816be9bd089..746cd36ad3b 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 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'); const both = run({ diff --git a/scripts/check-skill-links.mjs b/scripts/check-skill-links.mjs new file mode 100644 index 00000000000..45f5b59c1e6 --- /dev/null +++ b/scripts/check-skill-links.mjs @@ -0,0 +1,467 @@ +#!/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 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: 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 + * + * 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(); diff --git a/skills/objectstack-platform/references/operations.md b/skills/objectstack-platform/references/operations.md index c877361a7c6..d47c6538362 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:**