Files
gstack/scripts/gen-qm-edition.ts
T
Garry Tanandcapy-ai[bot] eaf1701da2 QM edition: three gstack skills QM loads on demand (GST-1492) (#4049)
* QM edition: three gstack skills QM loads on demand (GST-1492)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: name the QM edition's manifest and shipped script

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* paid lane: classify the QM edition as free-only (no paid case reads qm/)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

diff-base.sh names the untracked files past the first 30 (UNTRACKED_MORE) instead of dropping them silently; the gstack-review first step tells QM to read every untracked file.

Review-Finding: p1/outside-voice#1 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: point the QM row in docs/hosts.md at the QM edition

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: record the non-behavioral HARNESS_VERSION decision for the qm/** free-only row

CI run 42135033713 failed test/harness-version.test.ts: scripts/test-pr-profile.ts is a pinned shared-harness file and 87ed675 changed it without a decision. The row only classifies qm/ as free-only; no paid case reads qm/.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-06 00:38:43 +00:00

274 lines
13 KiB
TypeScript

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* Generate the QM edition (hosts/qm.ts) into qm/sandbox/skills/<id>/ and qm/edition.json.
*
* Each skill is a QM-authored header plus sections cut by heading from its
* gstack sources, so source edits flow in on the next run. Generation fails
* closed: a kept heading, skipped heading or dropped block that no longer
* exists, an unmapped {{RESOLVER}}, a file over the size limit, forbidden
* text, or a command QM's sandbox can't run stops it.
*
* bun run scripts/gen-qm-edition.ts write the edition
* bun run scripts/gen-qm-edition.ts --check exit 1 when the committed edition is stale
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { QM_EDITION, QM_IMAGE_COMMANDS, QM_POSIX_COMMANDS, type QmPart, type QmSkill } from '../hosts/qm';
const ROOT = path.resolve(import.meta.dir, '..');
const HEADING = /^(#{1,6}) +(.+?) *$/;
const FENCE = /^ *(```|~~~)/;
const GENERATED = '<!-- Generated by scripts/gen-qm-edition.ts';
const SHELL_FENCE = /^ *```(sh|bash|shell|zsh)\b/;
interface Line { text: string; level: number; title: string }
/** Lines with their heading level; a # line inside a code fence is never a heading. */
function scanLines(text: string): Line[] {
let open = false;
return text.split('\n').map(line => {
if (FENCE.test(line)) open = !open;
const m = !open && !FENCE.test(line) ? HEADING.exec(line) : null;
return { text: line, level: m ? m[1]!.length : 0, title: m ? m[2]! : '' };
});
}
function sectionRange(lines: Line[], title: string, where: string): [number, number] {
const hits = lines.flatMap((l, i) => (l.title === title ? [i] : []));
if (hits.length !== 1) throw new Error(`${where}: heading "${title}" found ${hits.length} times (want 1)`);
const start = hits[0]!;
let end = start + 1;
while (end < lines.length && !(lines[end]!.level && lines[end]!.level <= lines[start]!.level)) end++;
return [start, end];
}
/** Blocks: a fenced block, a list item with its continuation, a heading, or a paragraph; blank lines kept as ''. */
function splitBlocks(text: string): string[] {
const blocks: string[] = [];
let current: string[] = [];
let inFence = false;
const flush = () => { if (current.length) blocks.push(current.join('\n')); current = []; };
for (const line of text.split('\n')) {
if (inFence) {
current.push(line);
if (FENCE.test(line)) { inFence = false; flush(); }
} else if (FENCE.test(line)) {
flush(); current.push(line); inFence = true;
} else if (line.trim() === '') {
flush(); blocks.push('');
} else {
if (/^ {0,3}([-*]|\d+\.) /.test(line) || HEADING.test(line)) flush();
current.push(line);
}
}
flush();
const merged: string[] = [];
for (const block of blocks) {
const prev = merged.findLastIndex(b => b !== '');
const continues = /^\s+\S/.test(block) && prev >= 0 && /^ {0,3}([-*]|\d+\.) |^\s+\S/.test(merged[prev]!);
if (continues) merged.splice(prev, merged.length - prev, [merged[prev], ...merged.slice(prev + 1), block].join('\n'));
else merged.push(block);
}
return merged;
}
function cutPart(part: QmPart, where: string): string {
const at = `${where} ${part.file}`;
const lines = scanLines(fs.readFileSync(path.join(ROOT, part.file), 'utf-8'));
const [start, end] = sectionRange(lines, part.heading, at);
const keep = lines.slice(start, end);
for (const skip of part.skipHeadings ?? []) {
const [s, e] = sectionRange(keep, skip, `${at} skipHeadings`);
keep.splice(s, e - s);
}
const shift = part.level - keep[0]!.level;
let text = keep.map(l => (l.level ? `${'#'.repeat(l.level + shift)} ${l.title}` : l.text)).join('\n');
for (const needle of part.dropBlocks ?? []) {
const blocks = splitBlocks(text);
const kept = blocks.filter(b => !b.includes(needle));
if (kept.length === blocks.length) throw new Error(`${at}: dropBlocks "${needle}" matched nothing`);
text = kept.join('\n');
}
for (const [from, to] of part.replaceText ?? []) {
if (!text.includes(from)) throw new Error(`${at}: replaceText "${from}" matched nothing`);
text = text.split(from).join(to);
}
return text;
}
function applyResolvers(text: string, where: string): string {
return text.replace(/\{\{([A-Z_]+)(:[^}]*)?\}\}/g, (match, name: string) => {
if (!QM_EDITION.dropResolvers.includes(name)) {
throw new Error(`${where}: unmapped placeholder ${match}; add it to QM_EDITION.dropResolvers or drop its block`);
}
return '';
});
}
function tidy(text: string): string {
return text.split('\n').map(l => l.replace(/[ \t]+$/, '')).join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
}
/** One skill's files, keyed by path inside its folder. */
export function renderSkill(skill: QmSkill): Map<string, string> {
const where = `qm edition ${skill.id}`;
let body = applyResolvers(skill.parts.map(p => cutPart(p, where)).join('\n\n'), where);
for (const [from, to] of QM_EDITION.rewrites) body = body.split(from).join(to);
const sources = [...new Set(skill.parts.map(p => p.file))].join(', ');
const files = new Map<string, string>();
files.set('SKILL.md', tidy([
'---',
`name: ${skill.id}`,
`description: "${skill.description}"`,
'---',
`${GENERATED} from hosts/qm.ts and ${sources}. Do not edit. -->`,
'',
skill.header,
'',
body,
].join('\n')));
for (const [name, src] of Object.entries(skill.scripts)) {
files.set(`scripts/${name}`, fs.readFileSync(path.join(ROOT, QM_EDITION.scriptSourceDir, src), 'utf-8'));
}
return files;
}
const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', '{', 'time']);
const CLOSERS = new Set(['fi', 'done', 'esac', '}']);
const NOT_QM = new Set(['bun', 'bunx', 'node', 'npm', 'npx', 'pnpm', 'yarn', 'python', 'python3', 'pip', 'jq', 'curl', 'gh', 'rg', 'claude', 'codex', 'make']);
const ALLOWED = new Set([...QM_POSIX_COMMANDS, ...QM_IMAGE_COMMANDS]);
/** The words of every simple command in a shell snippet: pipeline stages, list members and $( ) substitutions. */
export function commandWords(snippet: string): { functions: string[]; commands: string[][] } {
const extra: string[] = [];
const functions: string[] = [];
let text = snippet.replace(/(^|\s)#.*$/gm, '$1').replace(/'[^']*'/g, "''").replace(/\d*>&\d*-?/g, ' ');
text = text.replace(/"((?:[^"\\]|\\.)*)"/g, (_m, inner: string) => {
for (const sub of inner.matchAll(/\$\(([^()]*)\)/g)) extra.push(sub[1]!);
return '""';
});
text = text.replace(/^\s*([A-Za-z_]\w*)\s*\(\)/gm, (_m, name: string) => { functions.push(name); return ''; });
const commands: string[][] = [];
for (const segment of [text, ...extra].join('\n').split(/\|\||&&|\$\(|[|;&()`\n]/)) {
const words = segment.trim().split(/\s+/).filter(Boolean);
while (words.length && (KEYWORDS.has(words[0]!) || /^[A-Za-z_]\w*=/.test(words[0]!))) words.shift();
if (!words.length || CLOSERS.has(words[0]!) || words[0] === 'for' || words[0] === 'case') continue;
commands.push(words);
}
return { functions, commands };
}
/** Why the files of skill `id` name something QM's sandbox can't run or the edition doesn't ship; empty when clean. */
export function qmCommandViolations(id: string, files: Map<string, string>): string[] {
const shipped = new Set([...files.keys()].filter(f => f.startsWith('scripts/')).map(f => f.slice('scripts/'.length)));
const ids = new Set(QM_EDITION.skills.map(s => s.id));
const problems: string[] = [];
const check = (snippet: string, where: string) => {
const { functions, commands } = commandWords(snippet);
for (const [cmd, arg] of commands) {
if (cmd === 'sh') {
const script = (arg ?? '').replace(/^"|"$/g, '');
const prefix = `skills/${id}/scripts/`;
if (!script.startsWith(prefix) || !shipped.has(script.slice(prefix.length))) {
problems.push(`${where}: sh runs "${script}", not a script shipped in ${prefix}`);
}
} else if (!ALLOWED.has(cmd!) && !functions.includes(cmd!)) {
problems.push(`${where}: "${cmd}" is neither POSIX, an image binary (${QM_IMAGE_COMMANDS.join(', ')}) nor a shipped script`);
}
}
};
for (const [file, content] of files) {
const where = `${id}/${file}`;
const prose = content.split('\n').filter(l => !l.startsWith(GENERATED)).join('\n');
for (const token of QM_EDITION.forbiddenText) {
if (prose.includes(token)) problems.push(`${where}: names "${token}"`);
}
for (const m of prose.matchAll(/\bgstack-[a-z0-9-]+/g)) {
if (!ids.has(m[0])) problems.push(`${where}: names "${m[0]}", a gstack command QM doesn't have`);
}
if (file.startsWith('scripts/')) { check(content, where); continue; }
let fence: string[] | null = null;
for (const line of content.split('\n')) {
if (fence) {
if (FENCE.test(line)) { check(fence.join('\n'), `${where} shell block`); fence = null; } else fence.push(line);
continue;
}
if (SHELL_FENCE.test(line)) { fence = []; continue; }
for (const span of line.matchAll(/`([^`]+)`/g)) {
const first = span[1]!.trim().split(/\s+/)[0]!;
if (ALLOWED.has(first) || NOT_QM.has(first)) check(span[1]!, `${where} inline`);
}
}
}
return problems;
}
/** Every file of the edition, keyed by path relative to the gstack root; throws when any rule fails. */
export function renderQmEdition(): Map<string, string> {
const out = new Map<string, string>();
const problems: string[] = [];
const { fileChars, descriptionChars, indexChars } = QM_EDITION.limits;
let index = 0;
const manifest = QM_EDITION.skills.map(skill => {
const files = renderSkill(skill);
problems.push(...qmCommandViolations(skill.id, files));
const d = skill.description;
if (d.length > descriptionChars) problems.push(`${skill.id}: description is ${d.length} chars (limit ${descriptionChars})`);
if (d.includes('"') || d.includes('\n')) problems.push(`${skill.id}: description must be one line without double quotes`);
const skillMd = files.get('SKILL.md')!;
const firstDerived = skillMd.indexOf(skill.parts[0]!.heading);
const step = skillMd.indexOf(skill.firstStep.command);
if (step < 0 || step > firstDerived) problems.push(`${skill.id}: first step "${skill.firstStep.command}" is not in the header`);
if (!new RegExp(skill.firstStep.marker).test(skill.firstStep.command)) problems.push(`${skill.id}: marker does not match the first step`);
index += skill.id.length + 3 + d.length;
for (const [file, content] of files) {
if (content.length > fileChars) problems.push(`${skill.id}/${file}: ${content.length} chars (limit ${fileChars})`);
out.set(path.posix.join(QM_EDITION.outDir, skill.id, file), content);
}
return { id: skill.id, source: skill.source, files: [...files.keys()], first_step: skill.firstStep.command, first_step_marker: skill.firstStep.marker };
});
if (index > indexChars) problems.push(`skills index entries total ${index} chars (limit ${indexChars})`);
if (problems.length) throw new Error(`QM edition is invalid:\n ${problems.join('\n ')}`);
out.set(QM_EDITION.manifest, JSON.stringify({ edition: 'gstack-qm', layer_root: QM_EDITION.outDir, skills: manifest }, null, 2) + '\n');
return out;
}
function onDisk(): string[] {
const dir = path.join(ROOT, QM_EDITION.outDir);
const files = fs.existsSync(dir)
? (fs.readdirSync(dir, { recursive: true }) as string[]).map(f => path.posix.join(QM_EDITION.outDir, f.split(path.sep).join('/')))
: [];
return [...files, QM_EDITION.manifest].filter(f => fs.existsSync(path.join(ROOT, f)) && fs.statSync(path.join(ROOT, f)).isFile());
}
/** Paths whose committed bytes differ from the generator's, or that only one side has. */
export function staleQmFiles(): string[] {
const want = renderQmEdition();
const stale = onDisk().filter(f => !want.has(f));
for (const [f, content] of want) {
const abs = path.join(ROOT, f);
if (!fs.existsSync(abs) || fs.readFileSync(abs, 'utf-8') !== content) stale.push(f);
}
return stale.sort();
}
if (import.meta.main) {
if (process.argv.includes('--check')) {
const stale = staleQmFiles();
if (stale.length) {
console.error(`QM edition is stale (${stale.join(', ')}). Run: bun run scripts/gen-qm-edition.ts`);
process.exit(1);
}
console.log('QM edition: fresh');
} else {
const want = renderQmEdition();
for (const f of onDisk()) if (!want.has(f)) fs.rmSync(path.join(ROOT, f));
for (const [f, content] of want) {
fs.mkdirSync(path.dirname(path.join(ROOT, f)), { recursive: true });
fs.writeFileSync(path.join(ROOT, f), content);
}
console.log(`QM edition: wrote ${want.size} files`);
}
}