Skip to content

Commit dfa669a

Browse files
os-zhuangclaude
andcommitted
refactor(spec): build-docs uses the shared generated-output sink
#3138 extracted the emit()/manageDir()/flush() sink to lib/generated-output.ts and moved build-skill-references + build-react-blocks-contract onto it, but left build-docs.ts carrying the inline original: #3126 was rewriting the same file at the time and the migration would have conflicted. #3126 has landed, so collect the debt. Two copies of a check/write sink is the one duplication this design cannot afford — its whole claim is that --check and write are the same code, and that claim is per copy. A fix to one (say the empty-dir prune #3138 added) silently leaves the other behind, and the failure mode is a gate that passes on output a real run would not produce. Adds wasEmitted() to the shared sink. build-docs is the first caller whose later output depends on its earlier output — each category index links only the pages that got generated — and that question belongs to the sink: it already owns the emitted map, and having the caller keep a second copy would fork the state the sink exists to consolidate. The other two callers are unaffected (new export, no signature change). The guard moves from an inline `managedDirs.size === 0` to flush()'s guard hook, keeping the same meaning: json-schema/ is gitignored, so a fresh checkout that skips gen:schema emits almost nothing and "nothing differs" would read as success. Pure refactor, verified rather than assumed: - regenerating produces byte-identical output — all 258 files, tree stays clean - all five drift classes still fail: stale content, missing page, stale leftover, no-schema guard, plus in-sync staying green - both existing sink callers regress clean, including #3138's reverse test that a hand-written .md in skills/*/references/ keeps the gate green and survives a write Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 09b18f5 commit dfa669a

2 files changed

Lines changed: 39 additions & 97 deletions

File tree

packages/spec/scripts/build-docs.ts

Lines changed: 26 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import fs from 'fs';
2020
import path from 'path';
2121

22+
import { createSink } from './lib/generated-output';
23+
2224
const SCHEMA_DIR = path.resolve(__dirname, '../json-schema');
2325
const SRC_DIR = path.resolve(__dirname, '../src');
2426
// Output directly to references folder (flattened)
@@ -29,102 +31,13 @@ const REPO_ROOT = path.resolve(__dirname, '../../..');
2931
const CHECK = process.argv.includes('--check');
3032

3133
// ── Output sink ──────────────────────────────────────────────────────────────
32-
// Every generated file goes through emit(), every wholesale-regenerated folder
33-
// through manageDir(). Nothing touches the output tree until flush(), so the
34-
// two modes run byte-for-byte identical generation logic and differ only in the
35-
// final disposition — write to disk, or compare against it. That shared path is
36-
// what makes --check trustworthy: it cannot pass on output a real run wouldn't
37-
// produce, because it *is* the real run minus the writes.
38-
39-
/** Absolute path → intended content. */
40-
const emitted = new Map<string, string>();
41-
/** Absolute dirs regenerated wholesale — anything on disk here that we didn't
42-
* emit is stale, and a real run would delete it. */
43-
const managedDirs = new Set<string>();
44-
45-
function emit(filePath: string, content: string): void {
46-
emitted.set(path.resolve(filePath), content);
47-
}
48-
49-
function manageDir(dir: string): void {
50-
managedDirs.add(path.resolve(dir));
51-
}
34+
// Shared with the spec's other generators — see lib/generated-output.ts for why
35+
// the write and --check paths must be the same code.
5236

53-
/** Files this run generated, for the read-after-write lookups below. */
54-
function wasEmitted(filePath: string): boolean {
55-
return emitted.has(path.resolve(filePath));
56-
}
57-
58-
function walk(dir: string): string[] {
59-
if (!fs.existsSync(dir)) return [];
60-
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
61-
const full = path.join(dir, e.name);
62-
return e.isDirectory() ? walk(full) : [full];
63-
});
64-
}
65-
66-
const rel = (p: string) => path.relative(REPO_ROOT, p);
67-
68-
/** Write the emitted tree, or (in --check) report how it differs from disk. */
69-
function flush(): void {
70-
if (!CHECK) {
71-
for (const dir of managedDirs) {
72-
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
73-
}
74-
for (const [file, content] of emitted) {
75-
fs.mkdirSync(path.dirname(file), { recursive: true });
76-
fs.writeFileSync(file, content);
77-
console.log(`✓ Generated ${rel(file)}`);
78-
}
79-
console.log(`\n✅ Generated ${emitted.size} files`);
80-
return;
81-
}
82-
83-
// A run with no schemas to read emits almost nothing, and "nothing differs"
84-
// would read as success — the gate would pass while checking no pages at all.
85-
// json-schema/ is gitignored, so this is one forgotten `gen:schema` away on a
86-
// fresh checkout. Fail loudly instead of greenly.
87-
if (managedDirs.size === 0) {
88-
console.error(
89-
`\n✗ No JSON schemas found under ${rel(SCHEMA_DIR)} — nothing to check against.\n` +
90-
` Run \`pnpm --filter @objectstack/spec gen:schema\` first (\`check:docs\` does this for you).\n`,
91-
);
92-
process.exit(1);
93-
}
94-
95-
const changed: string[] = [];
96-
const added: string[] = [];
97-
for (const [file, content] of emitted) {
98-
if (!fs.existsSync(file)) added.push(rel(file));
99-
else if (fs.readFileSync(file, 'utf-8') !== content) changed.push(rel(file));
100-
}
101-
// A managed folder is regenerated in full, so an on-disk file we didn't emit
102-
// is one a real run would delete — e.g. the page of a type removed from spec.
103-
const stale = [...managedDirs]
104-
.flatMap(walk)
105-
.filter(f => !wasEmitted(f))
106-
.map(rel);
107-
108-
const drift = [
109-
...added.map(f => ` + ${f} (missing — spec adds it)`),
110-
...changed.map(f => ` ~ ${f} (out of date)`),
111-
...stale.map(f => ` - ${f} (stale — spec no longer defines it)`),
112-
];
113-
114-
if (drift.length === 0) {
115-
console.log(`✅ ${emitted.size} reference files in sync with packages/spec`);
116-
return;
117-
}
118-
119-
console.error(
120-
`\n✗ content/docs/references/ is out of date with packages/spec:\n\n` +
121-
drift.join('\n') +
122-
`\n\nThese files are GENERATED — do not hand-edit them. Regenerate and commit:\n\n` +
123-
` pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs\n` +
124-
` git add content/docs/references\n`,
125-
);
126-
process.exit(1);
127-
}
37+
const { emit, manageDir, wasEmitted, flush } = createSink({
38+
check: CHECK,
39+
repoRoot: REPO_ROOT,
40+
});
12841

12942
// Dynamically discover categories from src directory
13043
const getCategoryTitle = (dir: string) => {
@@ -524,6 +437,9 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string,
524437

525438
console.log('Building documentation...');
526439

440+
/** Categories that had schemas to regenerate from — drives the flush() guard. */
441+
let managedCount = 0;
442+
527443
// 1. Clean existing category folders from DOCS_ROOT — but only when there are
528444
// JSON schemas to regenerate from. Otherwise we'd silently delete every .mdx
529445
// file when the upstream `gen:schema` step produced nothing (data loss).
@@ -539,6 +455,7 @@ Object.keys(CATEGORIES).forEach(category => {
539455
return;
540456
}
541457
manageDir(dir);
458+
managedCount++;
542459
});
543460

544461
const generatedFiles: string[] = [];
@@ -661,4 +578,17 @@ const meta = {
661578
emit(path.join(DOCS_ROOT, 'meta.json'), JSON.stringify(meta, null, 2));
662579

663580
// 4. Disposition: write the tree, or report drift against it.
664-
flush();
581+
flush({
582+
surface: 'content/docs/references/',
583+
regenerate:
584+
' pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs\n' +
585+
' git add content/docs/references',
586+
// json-schema/ is gitignored, so a fresh checkout that forgot gen:schema has no
587+
// input at all: every category is skipped, nothing is managed, and "nothing
588+
// differs" would read as success — green while checking no pages. Fail loudly.
589+
guard: () =>
590+
managedCount === 0
591+
? `No JSON schemas found under ${path.relative(REPO_ROOT, SCHEMA_DIR)} — nothing to check against.\n` +
592+
' Run `pnpm --filter @objectstack/spec gen:schema` first (`check:docs` does this for you).'
593+
: null,
594+
});

packages/spec/scripts/lib/generated-output.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ export function createSink(options: { check: boolean; repoRoot: string }) {
6666
managed.set(path.resolve(dir), owns);
6767
}
6868

69+
/**
70+
* Whether this run generated `filePath`. For a generator whose later output
71+
* depends on its earlier output — e.g. an index that links only the pages
72+
* that got generated — ask this rather than the disk. In write mode the two
73+
* agree (the folder was just wiped and rewritten), but under `--check`
74+
* nothing is written and the disk still holds the stale tree, so reading it
75+
* would make the check diverge from the run it is meant to model.
76+
*/
77+
function wasEmitted(filePath: string): boolean {
78+
return emitted.has(path.resolve(filePath));
79+
}
80+
6981
function walk(dir: string): string[] {
7082
if (!fs.existsSync(dir)) return [];
7183
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
@@ -138,5 +150,5 @@ export function createSink(options: { check: boolean; repoRoot: string }) {
138150
process.exit(1);
139151
}
140152

141-
return { emit, manageDir, flush };
153+
return { emit, manageDir, wasEmitted, flush };
142154
}

0 commit comments

Comments
 (0)