From 101955c01772612cd15bcc84ce4d3c2f80229a0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:34:43 +0000 Subject: [PATCH] test(spec): pin the blueprint pointer row to its subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published skill-index row for `ai/solution-blueprint.zod.ts` is generated from the module's own doc block, with an export list as the fallback when no block qualifies. Neither generator can see the difference: `check:skill-refs` and `check:docs` each compare an artifact against the generator that wrote it, and the generator reproduces the header selector faithfully — so a generator-only check passes on exactly the state where the row says nothing about the file's subject. This pins the fact the artifact must state instead. Two legs that fail differently. The SOURCE leg reds the moment the separator between the module header and `SNAKE_CASE` goes away — no regeneration needed. The CORPUS leg stays green through that, because it reads checked-in bytes, and reds once an index is regenerated from a file whose header no longer qualifies. Both directions measured by ablation. The pin reads `skills/*/references/_index.md`, outside its own package, so it is declared in `vitest.repo-tests.json` and runs under the `repo` project, whose hash moves with what it actually reads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .../solution-blueprint-header-row.test.ts | 116 ++++++++++++++++++ packages/spec/vitest.repo-tests.json | 1 + 2 files changed, 117 insertions(+) create mode 100644 packages/spec/scripts/solution-blueprint-header-row.test.ts diff --git a/packages/spec/scripts/solution-blueprint-header-row.test.ts b/packages/spec/scripts/solution-blueprint-header-row.test.ts new file mode 100644 index 0000000000..f079becba6 --- /dev/null +++ b/packages/spec/scripts/solution-blueprint-header-row.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin for WHAT the published pointer row for `ai/solution-blueprint.zod.ts` + * names — plan-first authoring, not a list of the symbols it happens to export. + * + * `build-skill-references.ts` describes each source by the module's own doc + * block (`lib/file-description.ts` selects it: top-level, in the header zone, + * documenting no symbol), and falls through to `Exports: …` when no block + * qualifies. This file HAD its header — ADR-0033 §4, the `apply_blueprint` + * expansion — but a single blank line was all that separated it from + * `const SNAKE_CASE`, so TSDoc's own attachment rule made it that regex + * constant's documentation and the selector disqualified it. The published + * index therefore stated a true fact ABOUT the file and nothing about its + * SUBJECT, on the very row whose job is to route an agent to the source for + * exact field shapes. + * + * Same root cause as #14441 pointing the other way: there the wrong block was + * published, here the right one was suppressed and a machine-generated list + * took its place. Both are the header-zone selector deciding against a header + * a human wrote — and in this direction the selector was RIGHT under its own + * rule, so the repair is in the source, not in the selector. + * + * No generator can see this class. `check:skill-refs` and `check:docs` compare + * the artifact against the generator, and the generator reproduced the + * selector faithfully — a generator-only check PASSES on the defect. So pin + * the fact the artifact must state, not the pipeline that states it. + * + * Two legs that fail DIFFERENTLY, which is why both exist. The SOURCE leg reds + * the moment the separator between the header and `SNAKE_CASE` is removed — + * no regeneration needed. The CORPUS leg stays green through that (it reads + * checked-in bytes, which only move when someone regenerates) and reds on the + * state this card found: an index regenerated from a file whose header no + * longer qualifies. MEASURED both ways in the fix's reverse verification. + */ + +import fs from 'fs'; +import path from 'path'; +import url from 'url'; + +import { describe, expect, it } from 'vitest'; + +import { findModuleDocBlock } from './lib/file-description'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '../../..'); +const SKILLS_DIR = path.resolve(REPO_ROOT, 'skills'); +const BLUEPRINT_SOURCE = path.resolve(HERE, '../src/ai/solution-blueprint.zod.ts'); + +/** + * The module's own opening sentence. Spelled out rather than derived from the + * source: deriving it would re-assert the generator's rule and say nothing + * about WHICH subject the row names, which is the whole defect. + */ +const BLUEPRINT_SENTENCE = 'Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring)'; + +/** The pointer path the generator writes for this source in every index. */ +const POINTER = 'node_modules/@objectstack/spec/src/ai/solution-blueprint.zod.ts'; + +/** First prose line of the block the generator would publish for a source. */ +const firstDescriptionLine = (source: string): string | null => { + const block = findModuleDocBlock(source); + if (block === null) return null; + const lines = block + .split('\n') + .map((line) => line.replace(/^\s*\*\s?/, '').trim()) + .filter((line) => line && !line.startsWith('@') && !line.startsWith('```')); + return lines[0] ?? null; +}; + +describe('ai/solution-blueprint.zod.ts — the module block describes the module', () => { + it('opens on the plan-first authoring sentence, not on `SNAKE_CASE`', () => { + const source = fs.readFileSync(BLUEPRINT_SOURCE, 'utf-8'); + expect(firstDescriptionLine(source)).toBe(BLUEPRINT_SENTENCE); + }); + + it('still documents `SNAKE_CASE` — the fix ADDS a symbol doc, it does not delete the header', () => { + // The cheapest way to satisfy the leg above is to delete the separator's + // reason for existing. `SNAKE_CASE` is what the header must not be glued + // to, and what a reader of this file still needs explained. + const source = fs.readFileSync(BLUEPRINT_SOURCE, 'utf-8'); + const documented = /\/\*\*[^\n]*\*\/\n(?:export )?const SNAKE_CASE\b/.test(source); + expect(documented).toBe(true); + }); +}); + +describe('published catalog — every pointer row for the blueprint names its subject', () => { + /** Every checked-in skill-index row pointing at `ai/solution-blueprint.zod.ts`. */ + const publishedRows = (): { file: string; description: string }[] => { + const rows: { file: string; description: string }[] = []; + for (const skill of fs.readdirSync(SKILLS_DIR)) { + const index = path.resolve(SKILLS_DIR, skill, 'references/_index.md'); + if (!fs.existsSync(index)) continue; + for (const line of fs.readFileSync(index, 'utf-8').split('\n')) { + const match = /^- `([^`]+)` — (.+)$/.exec(line); + if (match && match[1] === POINTER) { + rows.push({ file: path.relative(REPO_ROOT, index), description: match[2].trim() }); + } + } + } + return rows; + }; + + it('finds the rows at all', () => { + // Nothing parsed means nothing compared, and "no bad row" would read as + // green — the failure mode this whole file exists to refuse. + expect(publishedRows().length).toBeGreaterThan(0); + }); + + it('reads the blueprint sentence on every one of them, never the `Exports:` fallback', () => { + const offenders = publishedRows() + .filter((row) => row.description !== BLUEPRINT_SENTENCE) + .map((row) => `${row.file}: ${row.description}`); + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 9e6156d2dd..65d15639b8 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -17,6 +17,7 @@ "scripts/references-banner.test.ts", "scripts/root-index.test.ts", "scripts/schema-tree-freshness.test.ts", + "scripts/solution-blueprint-header-row.test.ts", "scripts/strictness-ledger-doc.test.ts", "scripts/strictness-ledger.test.ts", "src/api/error-catalog-docs.test.ts",