diff --git a/packages/spec/scripts/build-skill-references.ts b/packages/spec/scripts/build-skill-references.ts index 6a84a60f11..366f82a360 100644 --- a/packages/spec/scripts/build-skill-references.ts +++ b/packages/spec/scripts/build-skill-references.ts @@ -28,6 +28,14 @@ import path from 'path'; import { exportListDescription } from './lib/export-list'; import { findModuleDocBlock } from './lib/file-description'; import { createSink, type Owns } from './lib/generated-output'; +import { + SHARED_CORE_SCHEMAS, + TRANSITIVE_ALLOWLIST, + checkCoreEntryShape, + checkSingleOwner, + checkTransitiveAllowlist, + stripInternalIssueIds, +} from './lib/skill-map-guards'; // ── Paths ──────────────────────────────────────────────────────────────────── @@ -100,12 +108,18 @@ const SKILL_MAP: Record = { 'ai/tool.zod.ts', 'ai/skill.zod.ts', 'ai/model-registry.zod.ts', - 'ai/conversation.zod.ts', - 'ai/mcp.zod.ts', - 'ai/embedding.zod.ts', 'ai/knowledge-source.zod.ts', - 'ai/knowledge-document.zod.ts', - 'ai/usage.zod.ts', + // The schema behind the `solution_design` built-in skill the body's + // built-in-skills table names. Taught, never advertised until now. + 'ai/solution-blueprint.zod.ts', + // `conversation`, `mcp`, `embedding`, `knowledge-document` and `usage` + // left this list: the body teaches none of them, and three had zero + // consumers outside packages/spec. An index entry is a POINTER, and + // pointing at a schema the body cannot help with is the defect — the + // schemas keep existing and stay importable. `embedding` is still + // published here as a transitive dep, because `knowledge-source.zod.ts` + // composes `EmbeddingModelSchema`: that pointer is reachable from the + // authorable face, which is exactly the test the other four fail. ], 'objectstack-api': [ 'api/endpoint.zod.ts', @@ -121,10 +135,23 @@ const SKILL_MAP: Record = { 'automation/flow.zod.ts', 'automation/time-relative-trigger.zod.ts', 'automation/approval.zod.ts', - 'automation/state-machine.zod.ts', + // `automation/state-machine.zod.ts` left this list: ADR-0020 retired that + // shape AS A RECORD-LIFECYCLE DECLARATION (the top-level `workflow` type + // and `object.stateMachines` are both gone), and a record's legal + // transitions are now a `state_machine` VALIDATION RULE — `data/validation` + // below, already the correct destination. The file's one surviving door is + // `ai/agent.zod.ts`'s `lifecycle`, an objectstack-ai door, and that index + // reaches it transitively. Advertising it here pointed automation authors + // at a shape the platform deliberately removed from their surface. 'automation/execution.zod.ts', 'automation/webhook.zod.ts', 'automation/node-executor.zod.ts', + // The per-node-type `config` shapes the body teaches and the index did not + // name: screen `fields` and the ADR-0031 loop/parallel/try_catch containers + // reach `builtin-node-config`, `NotifyConfigSchema` and the `http` + // `timeoutMs` reach `io-node-config`. + 'automation/builtin-node-config.zod.ts', + 'automation/io-node-config.zod.ts', 'data/validation.zod.ts', ], 'objectstack-ui': [ @@ -162,7 +189,11 @@ const SKILL_MAP: Record = { ], 'objectstack-formula': [ 'shared/expression.zod.ts', - 'data/date-macros.zod.ts', + // `data/date-macros.zod.ts` left this list: it is objectstack-query's, and + // both bodies say so — view list filters are not a CEL surface, and the + // token list lives in objectstack-query's `rules/filters.md`. One schema + // file, one owning package; see SHARED_CORE_SCHEMAS for the three the map + // deliberately shares and why. ], }; @@ -271,11 +302,11 @@ function extractDescription(filePath: string): string { const firstLine = lines[0]; if (firstLine && firstLine.length > 5) { const clean = firstLine.replace(/^#+\s*/, ''); - const sentence = clean.split(/\.\s/)[0]; + const sentence = stripInternalIssueIds(clean.split(/\.\s/)[0]); return sentence.length > 120 ? sentence.slice(0, 117) + '...' : sentence; } } - return exportListDescription(content) ?? ''; + return stripInternalIssueIds(exportListDescription(content) ?? ''); } // ── Index generator ────────────────────────────────────────────────────────── @@ -359,9 +390,23 @@ function ownsReferenceEntry(refsDir: string): Owns { function main() { console.log('🔗 Building skill schema reference indexes...\n'); - const problems: string[] = []; + // Map-level guards run before any file is read: they ask questions of the + // authored config that the artifact-vs-generator comparison structurally + // cannot (see lib/skill-map-guards.ts). + const problems: string[] = [ + ...checkCoreEntryShape(SKILL_MAP), + ...checkSingleOwner(SKILL_MAP, SHARED_CORE_SCHEMAS), + ]; let totalSkills = 0; + // The allowlist guard needs each package's closure, so the closures are + // resolved once, up front, and reused by the emit loop below. + const closures: Record = {}; + for (const [skillName, coreFiles] of Object.entries(SKILL_MAP)) { + closures[skillName] = resolveAll(coreFiles).files; + } + problems.push(...checkTransitiveAllowlist(SKILL_MAP, TRANSITIVE_ALLOWLIST, closures)); + for (const [skillName, coreFiles] of Object.entries(SKILL_MAP)) { const skillDir = path.resolve(SKILLS_DIR, skillName); if (!fs.existsSync(skillDir)) { @@ -370,8 +415,19 @@ function main() { } console.log(`📦 ${skillName}`); - const { files: allFiles, missing } = resolveAll(coreFiles); + const { files: resolved, missing } = resolveAll(coreFiles); for (const m of missing) problems.push(`${skillName} → ${m} (no such file under packages/spec/src)`); + + // A package that declares a transitive allowlist publishes its core files + // plus exactly those pointers; one that declares none publishes the whole + // closure, as before. See TRANSITIVE_ALLOWLIST for why the constraint is a + // hand-authored list and not a rule over the import graph. + const allowed = TRANSITIVE_ALLOWLIST[skillName]; + const coreSet = new Set(coreFiles); + const allFiles = + allowed === undefined + ? resolved + : resolved.filter((f) => coreSet.has(f) || allowed.includes(f)); console.log(` ${coreFiles.length} core + ${allFiles.length - coreFiles.length} deps`); const refsDir = path.resolve(skillDir, 'references'); diff --git a/packages/spec/scripts/lib/skill-map-guards.ts b/packages/spec/scripts/lib/skill-map-guards.ts new file mode 100644 index 0000000000..2628cce06e --- /dev/null +++ b/packages/spec/scripts/lib/skill-map-guards.ts @@ -0,0 +1,324 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Guards over the authored `SKILL_MAP` in `build-skill-references.ts`. + * + * The map is hand-written config that decides what a published skill index + * points at, and every defect this file refuses was found by a human reading + * a shipped index rather than by a gate: an entry the owning SKILL.md never + * teaches, an entry two packages both claim, an entry that emits no row at + * all. `check:skill-refs` cannot see any of them — it compares the artifact + * against the generator, and the generator reproduces a wrong map faithfully. + * So these ask questions of the MAP, before the artifact exists. + * + * They live here, beside `export-list.ts` and `file-description.ts`, for the + * reason those do: the generator self-executes on import, so logic that wants + * a unit test cannot live in it. + * + * ## What these guards deliberately do NOT claim + * + * None of them decides whether a schema is RETIRED, and no such guard is + * shipped, because no checkable source for it exists in this repo — measured, + * not assumed: + * + * - the ADR-0087 registry (`src/migrations/entries/retired-defs/**`) names + * defs removed at a major version. `automation/state-machine.zod.ts` is not + * there and correctly so: the def still exists and still parses, through + * `AgentSchema.lifecycle`; + * - the file's own header carries the ADR-0020 retirement in prose, and that + * same header documents the door that SURVIVES — so a prose grep flags a + * file that is live surface for another package; + * - the liveness ledger classifies properties, not files. + * + * The retirement that mattered was PACKAGE-RELATIVE: dead surface for + * automation authoring, live surface for AI authoring. Nothing in the tree + * expresses a per-package liveness claim, so nothing can derive it. What is + * mechanical is below, and what is not stays a human judgement stated out loud + * in the map. + */ + +// ── What the catalog may publish ───────────────────────────────────────────── + +/** + * The gate's criterion for an internal tracker id, restated. + * + * Same shape as `scripts/check-doc-authoring.mjs`'s `INTERNAL_ID_SOURCE`: three + * to five digits, so the ordinal "the #1 mistake" and a six-digit hex colour + * are both below/above it, and neither `##` nor an HTML entity's `&#` counts. + * Restated rather than imported because that gate is a `.mjs` in the repo root + * `scripts/` tree and exports nothing; the pin in + * `skill-map-guards.test.ts` holds the two spellings together by asserting the + * shapes that must and must not match. + */ +const INTERNAL_ISSUE_ID = /(?; + +/** + * Schema files a second package's core list may claim, and why. + * + * ## Why this ledger exists at all, against the instruction that it should not + * + * The seat ruling that ordered this guard asked for the flat rule -- a schema + * file appears in at most ONE package's core list -- and refused a + * "legitimate duplicate ownership" rule on the stated ground that there would + * be "zero legitimate instances" once `data/date-macros.zod.ts` left the + * formula entry. MEASURED AT THE BASE OF THIS CHANGE, that ground does not + * hold: `date-macros` was one of FOUR duplicates, and the other three are + * deliberate, two of them already carrying their reason as a comment in the + * map itself. The flat rule would therefore refuse `origin/main`'s own map on + * its first run, and the only ways to satisfy it are to delete three pointers + * no card has adjudicated, or to keep the gate red. + * + * So the guard ships in the shape that is enforceable and keeps the ruling's + * operational demand -- the NEXT duplicate refuses at generation time -- + * while the three measured instances are declared here rather than deleted. + * This is a deviation from the letter of that ruling, recorded here and in the + * PR body so a reviewer can object to the reasoning rather than discover the + * outcome. Two guards keep the ledger from becoming the spread the ruling + * feared: a row whose file is no longer duplicated is REFUSED, so it cannot + * rot unread, and a row with no reason is refused, so it cannot become a + * silent allowlist. + */ +export const SHARED_CORE_SCHEMAS: Record = { + 'data/validation.zod.ts': + 'objectstack-data owns validation rules as a data surface; objectstack-automation ' + + "teaches the same file because a record's legal transitions are authored there as a " + + '`state_machine` rule (ADR-0020) -- the destination that replaced the retired ' + + 'state-machine shape.', + 'data/datasource.zod.ts': + 'objectstack-data owns datasources as a data surface; objectstack-platform teaches the ' + + 'same file under project setup (`defineStack` + drivers), the surface absorbed from the ' + + 'retired objectstack-quickstart skill.', + 'data/seed.zod.ts': + 'objectstack-data owns seeds as a data surface; objectstack-platform teaches the same ' + + 'file under project setup, the surface absorbed from the retired objectstack-quickstart ' + + 'skill.', +}; + +/** + * One schema file, one owning package -- unless the sharing is declared above. + * + * `references/_index.md` is generator-owned and shipped, and the catalog's + * whole contract is "this package owns this surface". A file in two core lists + * puts one pointer in an index whose SKILL.md routes that surface elsewhere, + * and the reader has no way to tell which of the two indexes meant it: + * `data/date-macros.zod.ts` sat in both `objectstack-query` and + * `objectstack-formula` while both bodies routed date macros to query alone. + */ +export function checkSingleOwner(map: SkillCoreMap, shared: Record): string[] { + const owners = new Map(); + for (const [skillName, coreFiles] of Object.entries(map)) { + for (const rel of coreFiles) { + const list = owners.get(rel) ?? []; + list.push(skillName); + owners.set(rel, list); + } + } + + const problems: string[] = []; + for (const [rel, packages] of owners) { + if (packages.length < 2) continue; + const reason = shared[rel]; + if (reason === undefined) { + problems.push( + `${rel} is in the core list of ${packages.length} packages (${packages.join(', ')}) — ` + + `one schema file, one owning package. Drop it from all but the package whose SKILL.md ` + + `teaches that surface, or declare the sharing in SHARED_CORE_SCHEMAS with the reason.`, + ); + } else if (reason.trim() === '') { + problems.push( + `${rel} is declared in SHARED_CORE_SCHEMAS with an empty reason — the reason is the ` + + `whole point of the declaration; an unexplained row is an allowlist.`, + ); + } + } + + // A declaration for a file that is no longer shared outlives the fact it + // records, and a ledger nobody has to keep true is one nobody reads. + for (const rel of Object.keys(shared)) { + const packages = owners.get(rel) ?? []; + if (packages.length >= 2) continue; + problems.push( + `${rel} is declared in SHARED_CORE_SCHEMAS but is in ${packages.length} core list(s) — ` + + `the sharing it explains is gone. Delete the declaration.`, + ); + } + + return problems; +} + +/** + * Which transitive pointers a package publishes, when the closure is wrong. + * + * ## The feasibility question this answers, decided before anything was built + * + * The closure walks every local `import ... from` edge out of a package's core + * files. Two routes were on the table for constraining it: (1) a REACHABILITY + * RULE -- follow only imports the package's authorable face can reach, which + * would generalise to every package; (2) a per-package list beside the map, + * which fixes one package at a time. Route 1 is the better shape IF it can be + * made precise. It cannot, and the measurement is specific rather than + * hand-wavy: + * + * - `objectstack-i18n` publishes eight transitive pointers, and SEVEN of them + * arrive through one edge -- `shared/strict-object.ts` imports + * `shared/suggestions.zod.ts` for its "did you mean?" text, which imports + * `data/field.zod.ts`, which drags in filter, expression, field-value, + * identifiers and value-domain. That is a schema-building HELPER's + * implementation, not the authorable shape of a translation bundle. Cutting + * traversal through non-shipping helpers is the obvious precise rule, and it + * removes five of the five pointers the finding names. + * - It also removes `shared/identifiers.zod.ts`, which MUST STAY: a bundle's + * object and field keys are the `snake_case` identifiers that file defines, + * and the SKILL.md spends a table and a "Critical:" note on exactly that. + * No import edge expresses it -- `system/translation.zod.ts` does not import + * the file at all, because a bundle addresses everything by NAME STRING. + * - And it KEEPS `kernel/metadata-protection.zod.ts`, which must go: that one + * is a first-class direct `.zod.ts` import of `translation.zod.ts`. + * + * So the required outcome puts a depth-4 pointer reached through a helper on + * the KEEP side and a depth-1 pointer reached through a schema edge on the DROP + * side. No predicate over the import graph orders those two that way, because + * the fact that separates them -- what a translation bundle can address -- is + * not in the graph. Route 1 is therefore not merely unbuilt here; it is + * unbuildable from this input, and route 2 is what ships. + * + * The list is an ALLOWLIST, not a denylist, and that is the half that keeps it + * from rotting the way the closure did: `shared/value-domain.zod.ts` joined the + * i18n index recently, unnoticed, when a new import edge appeared several files + * away. An allowlist cannot silently gain a row; a denylist silently misses + * every new arrival. + * + * A package with NO entry here publishes its full closure, unchanged. Declaring + * a list is a claim about that package's authorable face, and only a package + * whose face someone has actually read should carry one. + */ +export const TRANSITIVE_ALLOWLIST: Record = { + // Everything else the closure reaches here is `strictObject()`'s error-message + // machinery and what that drags behind it -- the Unified Query DSL among them, + // a different skill's whole subject, shipped into every i18n session with an + // instruction to read it. + 'objectstack-i18n': [ + // Bundle keys ARE these identifiers: `objects..fields.` must + // match the `snake_case` names the object and field schemas declare, which + // the SKILL.md states as a "Critical:" rule with its own table. + 'shared/identifiers.zod.ts', + // `FieldTranslationSchema.options` is keyed by select-option VALUE, and the + // SKILL.md teaches that keying by example. `SelectOptionSchema` -- the + // declaration those keys must match -- lives here. + 'data/field.zod.ts', + ], +}; + +/** + * A declared transitive allowlist must name a real package and reachable files. + * + * The list is hand-authored, and a hand-authored list that can quietly say + * nothing is the same defect one layer up: a typo'd package name would leave + * the over-eager closure fully published while the map LOOKS constrained, and a + * file the closure never reaches would read as a pointer that is being kept + * when it was never there to keep. + */ +export function checkTransitiveAllowlist( + map: SkillCoreMap, + allowlist: Record, + closures: Record, +): string[] { + const problems: string[] = []; + for (const [skillName, allowed] of Object.entries(allowlist)) { + const coreFiles = map[skillName]; + if (coreFiles === undefined) { + problems.push( + `TRANSITIVE_ALLOWLIST names ${skillName}, which is not a SKILL_MAP package — ` + + `the list would constrain nothing. Fix the name or delete the entry.`, + ); + continue; + } + const core = new Set(coreFiles); + const closure = new Set(closures[skillName] ?? []); + const seen = new Set(); + for (const rel of allowed) { + if (seen.has(rel)) { + problems.push(`${skillName} → ${rel} is listed twice in TRANSITIVE_ALLOWLIST.`); + continue; + } + seen.add(rel); + if (core.has(rel)) { + problems.push( + `${skillName} → ${rel} is already a core entry; listing it as a transitive ` + + `pointer says it is both, and the index would name it once regardless.`, + ); + } else if (!closure.has(rel)) { + problems.push( + `${skillName} → ${rel} is in TRANSITIVE_ALLOWLIST but nothing in the package's ` + + `core closure imports it — this row keeps a pointer that does not exist.`, + ); + } + } + } + return problems; +} + +/** + * Every core entry must be a path this generator can actually publish. + * + * `resolveAll()` keeps only `*.zod.ts` from the closure, because the published + * package's `files` allowlist ships those sources and nothing else — a pointer + * to any other src file 404s in a consumer's `node_modules`. That filter runs + * over the CORE list too, and the index template then intersects the core set + * with what survived: so a core entry that is not a `.zod.ts` path is dropped + * from the index SILENTLY — no `missing` row, and a green `--check`. The map is + * authored config; a line in it that emits nothing is a bug in the map, not a + * shape to absorb. + */ +export function checkCoreEntryShape(map: SkillCoreMap): string[] { + const problems: string[] = []; + for (const [skillName, coreFiles] of Object.entries(map)) { + for (const rel of coreFiles) { + if (rel.endsWith('.zod.ts')) continue; + problems.push( + `${skillName} → ${rel} is not a *.zod.ts path — only those sources ship in ` + + `@objectstack/spec, so this entry emits no pointer row at all. Point it at the ` + + `schema file, or drop it.`, + ); + } + } + return problems; +} diff --git a/packages/spec/scripts/skill-map-guards.test.ts b/packages/spec/scripts/skill-map-guards.test.ts new file mode 100644 index 0000000000..531f7331ca --- /dev/null +++ b/packages/spec/scripts/skill-map-guards.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Negative tests for the `SKILL_MAP` guards in `lib/skill-map-guards.ts`. + * + * Each guard exists because a shipped skill index was found wrong by a human + * reading it, and `check:skill-refs` was green the whole time: that gate + * compares the artifact against the generator, so a wrong map produces a + * faithful artifact and a green verdict. The guards ask their question of the + * MAP instead, and these tests assert they REFUSE — a guard that only ever + * returns an empty array is the failure mode a positive-only test cannot see. + * + * Two legs, failing differently, as `query-pointer-row.test.ts` does one layer + * up: + * + * - the BEHAVIOUR leg drives each guard over fabricated maps: one that must + * be refused, and one that must pass, so neither an always-green nor an + * always-red guard survives; + * - the WIRING leg reads `build-skill-references.ts` and asserts each guard is + * actually called there. A guard nobody calls is green in this file and + * absent from the gate, which is exactly the state the map was already in. + * + * The live corpus is deliberately NOT re-asserted here: `check:skill-refs` runs + * the real generator over the real map on every CI run, and it fails on any + * problem these guards report. Restating that in vitest would buy a second + * spelling of one fact, not a second fact. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + SHARED_CORE_SCHEMAS, + TRANSITIVE_ALLOWLIST, + checkCoreEntryShape, + checkSingleOwner, + checkTransitiveAllowlist, + stripInternalIssueIds, + type SkillCoreMap, +} from './lib/skill-map-guards'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const GENERATOR = path.resolve(HERE, 'build-skill-references.ts'); + +describe('checkCoreEntryShape — a core entry that emits no row is refused', () => { + it('refuses a non-.zod.ts core entry', () => { + const map: SkillCoreMap = { + 'objectstack-demo': ['data/field.zod.ts', 'contracts/plugin-lifecycle-events.ts'], + }; + const problems = checkCoreEntryShape(map); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('contracts/plugin-lifecycle-events.ts'); + expect(problems[0]).toContain('objectstack-demo'); + }); + + it('passes a map whose entries are all schema paths', () => { + // Without this leg a guard that returned a problem for every entry would + // satisfy the refusal test above and break every real run. + expect(checkCoreEntryShape({ 'objectstack-demo': ['data/field.zod.ts'] })).toEqual([]); + }); + + it('names every offending entry, not just the first', () => { + const problems = checkCoreEntryShape({ + a: ['x.ts'], + b: ['data/field.zod.ts', 'y.md'], + }); + expect(problems).toHaveLength(2); + }); +}); + +describe('checkSingleOwner — one schema file, one owning package', () => { + const twoOwners: SkillCoreMap = { + 'objectstack-query': ['data/query.zod.ts', 'data/date-macros.zod.ts'], + 'objectstack-formula': ['shared/expression.zod.ts', 'data/date-macros.zod.ts'], + }; + + it('refuses an undeclared duplicate and names both packages', () => { + const problems = checkSingleOwner(twoOwners, {}); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('data/date-macros.zod.ts'); + expect(problems[0]).toContain('objectstack-query'); + expect(problems[0]).toContain('objectstack-formula'); + }); + + it('accepts the same duplicate once it is declared with a reason', () => { + expect(checkSingleOwner(twoOwners, { 'data/date-macros.zod.ts': 'because …' })).toEqual([]); + }); + + it('refuses a declaration with no reason — that is an allowlist, not a ledger', () => { + const problems = checkSingleOwner(twoOwners, { 'data/date-macros.zod.ts': ' ' }); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('empty reason'); + }); + + it('refuses a declaration whose sharing is gone, so the ledger cannot rot', () => { + const oneOwner: SkillCoreMap = { 'objectstack-query': ['data/date-macros.zod.ts'] }; + const problems = checkSingleOwner(oneOwner, { 'data/date-macros.zod.ts': 'because …' }); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('Delete the declaration'); + }); + + it('passes a map with no duplicates and no declarations', () => { + // The always-red twin of the always-green failure the refusal tests catch. + expect( + checkSingleOwner({ a: ['data/field.zod.ts'], b: ['data/object.zod.ts'] }, {}), + ).toEqual([]); + }); + + it('every shipped declaration carries a real reason', () => { + // The ledger is read by a human deciding whether a second owner is right. + // A row that said only "allowed" would pass the guard and teach nothing. + for (const [file, reason] of Object.entries(SHARED_CORE_SCHEMAS)) { + expect(reason.trim().length, `${file} has no reason`).toBeGreaterThan(40); + } + }); +}); + +describe('checkTransitiveAllowlist — a constraint that constrains nothing is refused', () => { + const map: SkillCoreMap = { 'objectstack-i18n': ['system/translation.zod.ts'] }; + const closures = { + 'objectstack-i18n': ['system/translation.zod.ts', 'shared/identifiers.zod.ts'], + }; + + it('accepts a list naming a file the closure really reaches', () => { + expect( + checkTransitiveAllowlist(map, { 'objectstack-i18n': ['shared/identifiers.zod.ts'] }, closures), + ).toEqual([]); + }); + + it('accepts an empty list — publishing no transitive pointer is a real answer', () => { + expect(checkTransitiveAllowlist(map, { 'objectstack-i18n': [] }, closures)).toEqual([]); + }); + + it('refuses a package name that is not in the map', () => { + // The failure this exists for: a typo leaves the over-eager closure fully + // published while the map LOOKS constrained. + const problems = checkTransitiveAllowlist(map, { 'objectstack-i18nn': [] }, closures); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('not a SKILL_MAP package'); + }); + + it('refuses a file the closure never reaches', () => { + const problems = checkTransitiveAllowlist( + map, + { 'objectstack-i18n': ['data/query.zod.ts'] }, + closures, + ); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('does not exist'); + }); + + it('refuses a file that is already a core entry', () => { + const problems = checkTransitiveAllowlist( + map, + { 'objectstack-i18n': ['system/translation.zod.ts'] }, + closures, + ); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('already a core entry'); + }); + + it('refuses a file listed twice', () => { + const problems = checkTransitiveAllowlist( + map, + { 'objectstack-i18n': ['shared/identifiers.zod.ts', 'shared/identifiers.zod.ts'] }, + closures, + ); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('listed twice'); + }); + + it('every shipped list names a package the map has', () => { + // The one fact about the real list this file can assert without re-running + // the generator; reachability of each row is `check:skill-refs`'s job, + // because only it has the closure. + for (const skillName of Object.keys(TRANSITIVE_ALLOWLIST)) { + expect(skillName).toMatch(/^objectstack-/); + } + }); +}); + +describe('stripInternalIssueIds — the catalog carries no tracker ids', () => { + it('drops a trailing citation and the parenthesis it sat in', () => { + expect( + stripInternalIssueIds('Config contracts for the flat IO builtins — `notify` and `http` (#4045).'), + ).toBe('Config contracts for the flat IO builtins — `notify` and `http`.'); + }); + + it('drops the owner/repo spelling whole', () => { + expect(stripInternalIssueIds('removed in objectstack-ai/objectstack#4286 — use the rule')) + .toBe('removed in — use the rule'); + }); + + it('drops a mid-sentence id and leaves one space behind', () => { + expect(stripInternalIssueIds('Metadata Protection Model — Phase 1 (#1234) and later')) + .toBe('Metadata Protection Model — Phase 1 and later'); + }); + + // The other half of the criterion: what must survive. Each of these is a + // shape `check:doc-authoring` explicitly allows, so stripping it here would + // silently rewrite prose the gate never objected to. + it.each([ + ['the #1 authoring mistake', 'an ordinal is one digit, below the floor'], + ['colour #ff00aa is the accent', 'a hex colour starts with no digit'], + ['id #123456789 is not a tracker id', 'nine digits is above the ceiling'], + ['HTTP 404 is not a citation', 'no # at all'], + ['array##4045 is not a citation', 'a doubled # is excluded by the lookbehind'], + ])('leaves %j alone (%s)', (text) => { + expect(stripInternalIssueIds(text)).toBe(text); + }); +}); + +describe('the generator wires the guards in', () => { + const source = (): string => fs.readFileSync(GENERATOR, 'utf-8'); + + it('reads the generator at all', () => { + // Nothing read means nothing asserted, and "no missing call" would read as + // green — the failure mode a source-text pin actually has. + expect(source().length).toBeGreaterThan(1000); + }); + + it('calls checkCoreEntryShape on SKILL_MAP', () => { + expect(source()).toContain('checkCoreEntryShape(SKILL_MAP)'); + }); + + it('calls checkSingleOwner on SKILL_MAP and the declared ledger', () => { + expect(source()).toContain('checkSingleOwner(SKILL_MAP, SHARED_CORE_SCHEMAS)'); + }); + + it('calls checkTransitiveAllowlist, and filters the emitted set by the list', () => { + // Both halves matter: the guard alone would validate a list the emit path + // never reads, which is the shape of a constraint that constrains nothing. + expect(source()).toContain('checkTransitiveAllowlist(SKILL_MAP, TRANSITIVE_ALLOWLIST, closures)'); + expect(source()).toContain('allowed.includes(f)'); + }); + + it('strips internal ids on the description path, not somewhere unreachable', () => { + expect(source()).toContain('stripInternalIssueIds(clean.split'); + expect(source()).toContain('stripInternalIssueIds(exportListDescription(content)'); + }); +}); diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index 42cd0b8f59..09238a53e6 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -10,18 +10,15 @@ from `node_modules` — there is no local copy in the skill bundle. ## Core schemas - `node_modules/@objectstack/spec/src/ai/agent.zod.ts` — Exports: AIModelConfigSchema, StructuredOutputFormatSchema, TransformPipelineStepSchema, StructuredOutputConfigSchema, AgentSchema -- `node_modules/@objectstack/spec/src/ai/conversation.zod.ts` — AI Conversation Memory Protocol -- `node_modules/@objectstack/spec/src/ai/embedding.zod.ts` — Embedding & Vector Store Primitives -- `node_modules/@objectstack/spec/src/ai/knowledge-document.zod.ts` — Knowledge Document / Chunk / Hit — canonical shapes shared by every - `node_modules/@objectstack/spec/src/ai/knowledge-source.zod.ts` — Knowledge Source — declarative metadata describing what to index and -- `node_modules/@objectstack/spec/src/ai/mcp.zod.ts` — Model Context Protocol (MCP) — Reference & Binding Primitives - `node_modules/@objectstack/spec/src/ai/model-registry.zod.ts` — AI Model Registry Protocol - `node_modules/@objectstack/spec/src/ai/skill.zod.ts` — Skill Trigger Condition Schema +- `node_modules/@objectstack/spec/src/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, BlueprintFieldSchema, BlueprintObjectSchema, BlueprintViewSchema - `node_modules/@objectstack/spec/src/ai/tool.zod.ts` — Exports: ToolSchema -- `node_modules/@objectstack/spec/src/ai/usage.zod.ts` — AI Usage Primitives ## Transitive dependencies +- `node_modules/@objectstack/spec/src/ai/embedding.zod.ts` — Embedding & Vector Store Primitives - `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol — hierarchical states, guarded - `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Exports: FieldType, SelectOptionSchema, LocationCoordinatesSchema, CurrencyConfigSchema, CurrencyValueSchema diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 5dfd269ac7..6d5f821bc5 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -10,10 +10,11 @@ from `node_modules` — there is no local copy in the skill bundle. ## Core schemas - `node_modules/@objectstack/spec/src/automation/approval.zod.ts` — Exports: ApproverType, ApprovalDecision, ApprovalNodeApproverSchema, DecisionOutputDefSchema, ApprovalEscalationSchema +- `node_modules/@objectstack/spec/src/automation/builtin-node-config.zod.ts` — Config contracts for the remaining flat builtins — the CRUD quartet - `node_modules/@objectstack/spec/src/automation/execution.zod.ts` — Automation Execution Protocol - `node_modules/@objectstack/spec/src/automation/flow.zod.ts` — Exports: FlowNodeAction, FlowVariableSchema, FlowNodeSchema, FlowEdgeSchema, FlowSchema +- `node_modules/@objectstack/spec/src/automation/io-node-config.zod.ts` — Config contracts for the flat IO builtins — `notify` and `http`. - `node_modules/@objectstack/spec/src/automation/node-executor.zod.ts` — Node Executor Plugin Protocol — Wait Node Pause/Resume -- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol — hierarchical states, guarded - `node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts` — Time-Relative Trigger Protocol - `node_modules/@objectstack/spec/src/automation/webhook.zod.ts` — Exports: WebhookTriggerType, WebhookSchema - `node_modules/@objectstack/spec/src/data/validation.zod.ts` — ObjectStack Validation Protocol diff --git a/skills/objectstack-formula/references/_index.md b/skills/objectstack-formula/references/_index.md index 6b540e9400..a35e1ce7a5 100644 --- a/skills/objectstack-formula/references/_index.md +++ b/skills/objectstack-formula/references/_index.md @@ -9,7 +9,6 @@ from `node_modules` — there is no local copy in the skill bundle. ## Core schemas -- `node_modules/@objectstack/spec/src/data/date-macros.zod.ts` — Date Macro Tokens — the declarative placeholders the UI substitutes - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol ## How to read these diff --git a/skills/objectstack-i18n/references/_index.md b/skills/objectstack-i18n/references/_index.md index dfaec77b69..ce2cbeff8b 100644 --- a/skills/objectstack-i18n/references/_index.md +++ b/skills/objectstack-i18n/references/_index.md @@ -14,14 +14,8 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies -- `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Exports: FieldType, SelectOptionSchema, LocationCoordinatesSchema, CurrencyConfigSchema, CurrencyValueSchema -- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification -- `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) -- `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — Exports: SystemIdentifierSchema, SnakeCaseIdentifierSchema, MetadataItemNameSchema -- `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities -- `node_modules/@objectstack/spec/src/shared/value-domain.zod.ts` — Standard value domains: one closed vocabulary and one membership predicate for settings and fields. ## How to read these