Skill Tracer
Skills › gstack › gstack › modules-render.ts

modules-render.ts

176 lines · scripts/modules-render.ts at 3f79d81

1/**
2 * gstack-modules render (GSTA-140, PR 1a): one loadout rendered into a
3 * directory GSTA-2 can preregister, published only when complete.
4 *
5 * Order: render into an owned staging directory beside --out-dir (Claude
6 * section links already name the final path), refuse dangling references to
7 * an excluded module, hash every file into loadout.lock.json (written last),
8 * then swap the staging directory into place. Any failure leaves the previous
9 * --out-dir untouched and removes the staging directory.
10 */
11import { createHash, randomBytes } from 'node:crypto';
12import * as fs from 'node:fs';
13import * as path from 'node:path';
14import { runGeneration } from './gen-skill-docs';
15import { ModuleError, type ModuleSelection } from './modules';
16import type { Host } from './resolvers/types';
17import { getHostConfig } from '../hosts/index';
18import type { Model } from './models';
20export const LOCK_FILE = 'loadout.lock.json';
21const ROOT = path.resolve(import.meta.dir, '..');
23export interface RenderRequest {
24 host: Host;
25 /** null = the full loadout. */
26 selection: ModuleSelection | null;
27 outDir: string;
28 installRoot?: string | null;
29 model?: Model | null;
30 planMode?: boolean;
33export interface LoadoutLock {
34 schema_version: 1;
35 loadout: string;
36 spec: string;
37 spec_sha256: string | null;
38 host: Host;
39 model: string | null;
40 install_root: string | null;
41 plan_mode: boolean;
42 gstack_version: string;
43 excluded: string[];
44 total_bytes: number;
45 files: Array<{ path: string; bytes: number; sha256: string }>;
48function walk(dir: string, base = dir): string[] {
49 return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
50 const full = path.join(dir, entry.name);
51 if (entry.isDirectory()) return walk(full, base);
52 return entry.isFile() ? [path.relative(base, full).split(path.sep).join('/')] : [];
53 }).sort();
56/**
57 * References an excluded module leaves behind in the rendered text. A section
58 * counts as referenced by: its absolute path (`/<skill>/sections/<file>`, any
59 * file, so resolver-inlined cross-skill pointers count), an installed-relative
60 * pointer naming its skill, or, inside its own skill's directory, a bare
61 * `sections/<file>` or the inline-host "section inlined" phrase. An excluded
62 * skill counts as referenced by another skill naming its SKILL.md or one of
63 * its section files.
64 */
65export function danglingReferences(stage: string, selection: ModuleSelection): string[] {
66 const checks: Array<{ id: string; scope: string | null; test: (line: string) => boolean }> = [];
67 for (const id of selection.excluded) {
68 if (id.startsWith('section:')) {
69 const [skill, sectionId] = id.slice('section:'.length).split('/');
70 const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, skill, 'sections', 'manifest.json'), 'utf-8'));
71 const file = (manifest.sections as Array<{ id: string; file: string }>).find(entry => entry.id === sectionId)!.file;
72 const stem = file.replace(/\.md$/, '');
73 checks.push(
74 { id, scope: null, test: line => line.includes(`/${skill}/sections/${file}`) },
75 { id, scope: null, test: line => line.includes(`sections/${file}\` relative to the installed`) && line.includes(`${skill}\``) },
76 { id, scope: skill, test: line => line.includes(`sections/${file}`) || line.includes(`the \`${stem}\` section inlined`) },
77 );
78 } else if (id.startsWith('skill:')) {
79 const skill = id.slice('skill:'.length);
80 checks.push({ id, scope: null, test: line => line.includes(`/${skill}/SKILL.md`) || line.includes(`gstack-${skill}/SKILL.md`) });
81 const manifestPath = path.join(ROOT, skill, 'sections', 'manifest.json');
82 if (fs.existsSync(manifestPath)) {
83 for (const { file } of JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).sections as Array<{ file: string }>) {
84 checks.push(
85 { id, scope: null, test: line => line.includes(`/${skill}/sections/${file}`) },
86 { id, scope: null, test: line => line.includes(`sections/${file}\` relative to the installed`) && line.includes(`${skill}\``) },
87 );
88 }
89 }
90 }
91 }
92 if (!checks.length) return [];
93 const found = new Set<string>();
94 for (const rel of walk(stage)) {
95 if (!rel.endsWith('.md')) continue;
96 const dirs = rel.split('/');
97 const lines = fs.readFileSync(path.join(stage, rel), 'utf-8').split('\n');
98 lines.forEach((line, index) => {
99 for (const check of checks) {
100 if (check.scope && !dirs.includes(check.scope) && !dirs.includes(`gstack-${check.scope}`)) continue;
101 if (check.test(line)) found.add(`${check.id}: referenced by ${rel}:${index + 1}`);
102 }
103 });
104 }
105 return [...found];
108function owned(dir: string): boolean {
109 if (!fs.existsSync(dir)) return true;
110 return fs.readdirSync(dir).length === 0 || fs.existsSync(path.join(dir, LOCK_FILE));
113export async function renderLoadout(request: RenderRequest): Promise<LoadoutLock> {
114 const final = path.resolve(request.outDir);
115 if (!owned(final)) {
116 throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} exists and holds no ${LOCK_FILE}, so it is not a gstack-modules render`, 'pass an empty or new --out-dir, or one an earlier gstack-modules render wrote');
117 }
118 const tag = `${process.pid}-${randomBytes(4).toString('hex')}`;
119 const stage = `${final}.stage-${tag}`;
120 const retired = `${final}.old-${tag}`;
121 fs.mkdirSync(path.dirname(final), { recursive: true });
122 try {
123 const result = await runGeneration({
124 host: request.host,
125 outputRoot: stage,
126 contentLinkRoot: final,
127 installRoot: request.installRoot ?? null,
128 model: request.model ?? null,
129 planMode: request.planMode ?? true,
130 modules: request.selection,
131 });
132 const errors = result.diagnostics.filter(d => d.kind === 'error').map(d => d.message);
133 if (result.exitCode !== 0 || errors.length) {
134 throw new ModuleError('RENDER_FAILED', `gen-skill-docs failed: ${errors.join('; ') || 'exit 1'}`, 'fix the named template or module, then rerun; side effects: none, safe to retry');
135 }
136 if (request.selection) {
137 const dangling = danglingReferences(stage, request.selection);
138 if (dangling.length) {
139 throw new ModuleError('MODULE_EXCLUSION_UNSAFE', dangling.slice(0, 5).join('; ') + (dangling.length > 5 ? ` (+${dangling.length - 5} more)` : ''), 'exclude the referencing module too, or guard the reference with {{IF_SECTION:id}} in its template; side effects: none, safe to retry');
140 }
141 }
142 const files = walk(stage).map(rel => {
143 const bytes = fs.readFileSync(path.join(stage, rel));
144 return { path: rel, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
145 });
146 const lock: LoadoutLock = {
147 schema_version: 1,
148 loadout: request.selection?.name ?? 'full',
149 spec: request.selection?.spec ?? 'full',
150 spec_sha256: request.selection?.specSha256 ?? null,
151 host: request.host,
152 model: request.model ?? getHostConfig(request.host).defaultModel ?? null,
153 install_root: request.installRoot ?? null,
154 plan_mode: request.planMode ?? true,
155 gstack_version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf-8').trim(),
156 excluded: [...(request.selection?.excluded ?? [])].sort(),
157 total_bytes: files.reduce((sum, f) => sum + f.bytes, 0),
158 files,
159 };
160 fs.writeFileSync(path.join(stage, LOCK_FILE), JSON.stringify(lock, null, 2) + '\n');
161 if (!owned(final)) {
162 throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} changed during the render and now holds no ${LOCK_FILE}`, 'pass an empty or new --out-dir, or one an earlier gstack-modules render wrote');
163 }
164 if (fs.existsSync(final)) fs.renameSync(final, retired);
165 try {
166 fs.renameSync(stage, final);
167 } catch (error) {
168 if (fs.existsSync(retired)) fs.renameSync(retired, final);
169 throw error;
170 }
171 fs.rmSync(retired, { recursive: true, force: true });
172 return lock;
173 } finally {
174 fs.rmSync(stage, { recursive: true, force: true });
175 }