Merge origin/main and stamp v1.91.290.0 (train 32 slot 2, #3526)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-28 04:18:49 +00:00
commit eb0de34458
82 files changed
+4384 -1868

No files matched your search

+187 -19
View File
@@ -3,11 +3,12 @@
* release-cut: tag the newest green commit on main as v<VERSION>, publish a
* GitHub Release whose notes are that span's CHANGELOG headlines, and
* fast-forward refs/heads/stable to it (docs/releases.md). Run by
* .github/workflows/weekly-release.yml every Monday morning and by hand for a
* hotfix (--ref <sha on main>). Dry runs print the plan and change nothing.
* .github/workflows/weekly-release.yml ("Stable release") every Monday and
* Thursday at 14:00 UTC and by hand (--ref <sha on main> for a hotfix). Dry
* runs print the plan and change nothing.
*
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>]
* [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason>] [--dry-run]
* [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
*
* The checkout needs origin's full history (fetch-depth 0). Green, for a
* commit on main's first-parent history, means: its required check run
@@ -34,6 +35,17 @@
* commit. A cut that stopped half-way (tag and stable pushed, release not
* created) is finished by the next run; a completed cut reports NOTHING_NEW
* and exits 0.
*
* The floor: stable moves at most once per FLOOR_HOURS (72). The last move is
* the annotated v<VERSION> tag pointing at stable, or its GitHub Release's
* publish time when that is later; a cut inside the floor is REFUSED_FLOOR
* with NEXT_ALLOWED, and a stable without that tag is FLOOR_UNKNOWN. A
* scheduled run (GITHUB_EVENT_NAME=schedule) gets SCHEDULED_GRACE_HOURS (3) of
* slack for cron start jitter; nothing else does. Finishing a half-done cut is
* not a new move. The one exception is a security cut: --security <reason>
* with --ref, where the reason names a GHSA, CVE or #<n> that verifySecurity
* ties to the commits being released; the reason and its evidence go into the
* tag message and the notes, and a retry recovers them from the tag.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
@@ -51,6 +63,9 @@ const RED = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'st
export const WINDOW_DAYS = 7;
export const WINDOW_COMMITS = 200;
export const NOTES_CAP = 60_000;
export const FLOOR_HOURS = 72;
export const SCHEDULED_GRACE_HOURS = 3;
const HOUR = 3_600_000;
export const runGh: Gh = (args) => {
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
@@ -168,7 +183,14 @@ export function changelogEntries(text: string): ChangelogEntry[] {
return out;
}
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean }
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean; security?: Security | null }
/** A verified security cut: the sanitized reason and what tied it to the released commits. */
export interface Security { reason: string; evidence: string }
export function releaseKind(hotfix: boolean, security: Security | null | undefined): 'security' | 'hotfix' | 'regular' {
return security ? 'security' : hotfix ? 'hotfix' : 'regular';
}
/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */
export function releaseNotes(n: NotesInput): string {
@@ -178,8 +200,9 @@ export function releaseNotes(n: NotesInput): string {
const changelogUrl = 'https://github.com/' + n.repo + '/blob/v' + n.version + '/CHANGELOG.md';
const compare = n.prevTag ? '[Compare](https://github.com/' + n.repo + '/compare/' + n.prevTag + '...v' + n.version + ') or read ' : 'Read ';
const head = [
'## gstack v' + n.version + (n.hotfix ? ' (hotfix release)' : ' (weekly release)'),
'## gstack v' + n.version + ' (' + releaseKind(n.hotfix, n.security) + ' release)',
'',
...(n.security ? ['**Security release:** `' + n.security.reason + '` (verified: ' + n.security.evidence + '). Every version since the previous release ships with it.', ''] : []),
n.first || !n.prevVersion
? 'First tagged release: the ' + span.length + ' version' + plural + (n.prevVersion ? ' since v' + n.prevVersion + ' (the week before it)' : '') + ', one line each; the [full changelog](' + changelogUrl + ') has every earlier one.'
: span.length + ' version' + plural + ' since v' + n.prevVersion + ', one line each. ' + compare + 'the [full changelog](' + changelogUrl + ').',
@@ -198,22 +221,133 @@ export function releaseNotes(n: NotesInput): string {
return body;
}
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
/**
* When stable last moved: the annotated v<VERSION> tag a cut made on it (VERSION
* read at stable), or that tag's release publish time when later. Any other tag
* on that commit is ignored, so a hand-made extra tag cannot date the move or
* pin later runs to stable. published is false when the release does not exist
* yet (a half-done cut, whose tag may predate the stable push); a release that
* cannot be read leaves the move undated.
*/
export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): { at: number; tag: string; published: boolean } | { at: null; why: string } {
const version = git(['show', stable + ':VERSION']);
const tag = 'v' + (version.ok ? version.out.trim() : '<VERSION>');
const listed = version.ok ? git(['for-each-ref', '--points-at=' + stable, '--format=%(objecttype)%09%(creatordate:unix)', 'refs/tags/' + tag]) : null;
const [type, created] = listed?.ok && listed.out ? listed.out.split('\t') : [];
if (type !== 'tag') return { at: null, why: 'no annotated ' + tag + ' tag points at stable ' + stable.slice(0, 12) + ', so its last move cannot be dated' };
const at = Number(created) * 1000;
const view = gh(['release', 'view', tag, '--repo', repo, '--json', 'publishedAt']);
if (view.status !== 0) {
if (/not found|HTTP 404/i.test(view.stderr)) return { tag, at, published: false };
return { at: null, why: 'cannot read the release for ' + tag + ' (' + (view.stderr.trim().slice(0, 120) || 'gh exit ' + view.status) + '), so its last move cannot be dated' };
}
const published = Date.parse(parseJson<{ publishedAt?: string }>(view)?.publishedAt ?? '');
return { tag, at: Number.isFinite(published) ? Math.max(at, published) : at, published: true };
}
/** The floor verdict for moving stable from prevStable to sha. A refusal carries the message and the NEXT_ALLOWED text. */
export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; security: boolean; scheduled: boolean; now: number }): Floor {
if (!o.prevStable) return { verdict: 'first', line: 'FLOOR: none (no stable yet; this cut creates it)' };
if (o.prevStable === o.sha) return { verdict: 'finishing', line: 'FLOOR: not applied (finishing the cut that already moved stable)' };
const moved = stableMovedAt(o.git, o.gh, o.repo, o.prevStable);
if (moved.at === null) {
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + moved.why + ')' };
return { verdict: 'unknown', line: 'FLOOR: unknown', message: moved.why + '; only a --security cut can move it until one does', next: 'none until an annotated v<VERSION> tag dates the release stable points at' };
}
if (!moved.published && !o.security) {
return { verdict: 'unknown', line: 'FLOOR: unknown', message: 'stable moved to ' + moved.tag + ' but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)', next: 'once the release for ' + moved.tag + ' is published' };
}
// A tag dated in the future holds the floor from now, not from that date.
moved.at = Math.min(moved.at, o.now);
const need = FLOOR_HOURS - (o.scheduled ? SCHEDULED_GRACE_HOURS : 0);
const since = 'stable moved to ' + moved.tag + ' at ' + iso(moved.at) + ', ' + ((o.now - moved.at) / HOUR).toFixed(1) + ' h ago';
const next = iso(moved.at + FLOOR_HOURS * HOUR) + ' for a hand-run cut; ' + iso(moved.at + (FLOOR_HOURS - SCHEDULED_GRACE_HOURS) * HOUR) + ' for a scheduled run';
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + since + ')' };
const kind = o.scheduled ? 'a scheduled run needs ' + need + ' h (' + FLOOR_HOURS + ' h less ' + SCHEDULED_GRACE_HOURS + ' h cron grace)' : 'a hand-run cut needs ' + need + ' h';
if (o.now - moved.at >= need * HOUR) return { verdict: 'clear', line: 'FLOOR: clear (' + since + '; ' + kind + ')' };
return { verdict: 'refused', line: 'FLOOR: refused', message: since + '; ' + kind + '; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>', next };
}
/** One line, no control characters or backticks, at most 300 characters: the reason is public in the notes and tag. */
export function sanitizeReason(reason: string): string {
return reason.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/`/g, "'").replace(/\s+/g, ' ').trim().slice(0, 300);
}
/**
* Ties a security reason to the commits in (from, to]. The reason must name a
* GHSA, CVE or #<n>; it is verified when (a) a commit message in the span names
* that GHSA/CVE, (b) issue or PR #<n> carries the security label and a commit
* in the span references #<n>, or (c) the GitHub Advisory Database entry for
* that GHSA/CVE lists a package whose line changes in the span's package.json
* or bun.lock. What the fix fixes, and how bad it was, stays attested.
*/
export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string }): { ok: true; evidence: string } | { ok: false; why: string } {
const advisories = [...new Set([...o.reason.matchAll(/\bGHSA(?:-[2-9cfghjmpqrvwx]{4}){3}\b/gi)].map(m => 'GHSA' + m[0].slice(4).toLowerCase())
.concat([...o.reason.matchAll(/\bCVE-\d{4}-\d{4,}\b/gi)].map(m => m[0].toUpperCase())))];
const issues = [...new Set([...o.reason.matchAll(/(?<![\w&])#(\d+)\b/g)].map(m => m[1]!))];
if (advisories.length === 0 && issues.length === 0) return { ok: false, why: 'the reason names no GHSA-…, CVE-… or #<n> to verify' };
const range = o.from ? [o.from + '..' + o.to] : ['--max-count=' + WINDOW_COMMITS, o.to];
const log = o.git(['log', '--format=%H%x1f%B%x1e', ...range]);
const commits = (log.ok ? log.out.split('\x1e') : []).map(c => c.trim().split('\x1f')).filter(c => c[0]).map(c => ({ sha: c[0]!.slice(0, 12), body: c[1] ?? '' }));
// Package names whose own entry changed: a package.json dependency line or a bun.lock package key, never a name listed inside another package's entry.
const changed = new Set((o.from ? o.git(['diff', '--unified=0', o.from, o.to, '--', 'package.json', 'bun.lock']).out.split('\n') : [])
.flatMap(l => { const m = /^[+-](?![+-])\s*"([^"]+)":\s*(?:"[^"]*"|\[\s*"([^"]+)@)/.exec(l); return m && (!m[2] || m[2] === m[1]) ? [m[1]!] : []; }));
const why: string[] = [];
for (const id of advisories) {
const named = commits.find(c => c.body.toUpperCase().includes(id.toUpperCase()));
if (named) return { ok: true, evidence: 'commit ' + named.sha + ' names ' + id };
const read = parseJson<unknown>(o.gh(['api', id.startsWith('GHSA') ? 'advisories/' + id : 'advisories?cve_id=' + id]));
const entries = (Array.isArray(read) ? read : read ? [read] : []) as Array<{ vulnerabilities?: Array<{ package?: { name?: string; ecosystem?: string } }> }>;
const packages = [...new Set(entries.flatMap(e => e.vulnerabilities ?? []).filter(v => v.package?.ecosystem?.toLowerCase() === 'npm').map(v => v.package?.name).filter((n): n is string => Boolean(n)))];
const bumped = packages.find(p => changed.has(p));
if (bumped) return { ok: true, evidence: 'advisory ' + id + ' lists ' + bumped + ', which changes in package.json/bun.lock' };
why.push(id + ': no commit names it' + (read ? ' and none of its ' + packages.length + ' npm advisory packages changes in package.json/bun.lock' : ' and the advisory database has no entry for it'));
}
for (const n of issues) {
const ref = commits.find(c => new RegExp('(?<![\\w&])#' + n + '\\b').test(c.body));
const issue = parseJson<{ labels?: Array<{ name?: string }> }>(o.gh(['api', 'repos/' + o.repo + '/issues/' + n]));
const labelled = Boolean(issue?.labels?.some(l => l.name?.toLowerCase() === 'security'));
if (ref && labelled) return { ok: true, evidence: '#' + n + ' carries the security label and commit ' + ref.sha + ' references it' };
why.push('#' + n + ': ' + [!issue ? 'cannot read it' : !labelled ? 'no security label' : '', !ref ? 'no commit in the span references it' : ''].filter(Boolean).join(', '));
}
return { ok: false, why: 'nothing in ' + (o.from ? o.from.slice(0, 12) + '..' : 'up to ') + o.to.slice(0, 12) + ' ties the reason to the release: ' + why.join('; ') };
}
/** The security reason and evidence an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
export function securityFromTag(git: Git, tag: string): Security | null {
const body = git(['for-each-ref', '--format=%(contents)', 'refs/tags/' + tag]);
const reason = body.ok ? /^Security: (.+)$/m.exec(body.out) : null;
if (!reason) return null;
return { reason: reason[1]!.trim(), evidence: /^Verified: (.+)$/m.exec(body.out)?.[1]!.trim() ?? 'recorded in tag ' + tag };
}
export interface Plan {
sha: string; version: string; tag: string; date: string; hotfix: boolean; via: string;
prevStable: string | null; prevVersion: string | null; prevTag: string | null;
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
security: Security | null; floor: Floor; ridingAlong: number;
}
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED'; message: string; skipped?: string[] };
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY';
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan };
export interface PlanOptions {
repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number;
requiredCheck?: string; backstopCheck?: string; notesRef?: string;
/** A scheduled run (GITHUB_EVENT_NAME=schedule): the floor allows SCHEDULED_GRACE_HOURS of cron jitter. */
scheduled?: boolean;
/** --security <reason>: bypass the floor for a verified security fix; needs ref. */
security?: string;
}
export function planRelease(o: PlanOptions): PlanResult {
const { git, gh, repo } = o;
const windowDays = o.windowDays ?? WINDOW_DAYS;
const skipped: string[] = [];
if (o.security !== undefined && !o.ref) return { ok: false, code: 'REFUSED_SECURITY', message: '--security needs --ref <the fix commit>: a security cut releases up to the fix, never whatever is newest' };
const mainHead = git(['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}']);
if (!mainHead.ok) return { ok: false, code: 'REFUSED', message: 'origin/main is not fetched: ' + mainHead.out };
const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
@@ -238,6 +372,9 @@ export function planRelease(o: PlanOptions): PlanResult {
const list = git(['rev-list', '--first-parent', '--max-count=' + (o.windowCommits ?? WINDOW_COMMITS), '--since=' + since, 'refs/remotes/origin/main']);
candidates = list.ok && list.out ? list.out.split('\n') : [];
if (candidates.length === 0) return { ok: false, code: 'NOTHING_NEW', message: 'no commit on origin/main in the last ' + windowDays + ' days' };
// A cut that moved stable but never published its release is finished before stable moves again.
const moved = prevStable ? stableMovedAt(git, gh, repo, prevStable) : null;
if (prevStable && moved && moved.at !== null && !moved.published) candidates = [prevStable];
}
// stable itself stays a candidate so a cut that stopped half-way (tag and
@@ -287,10 +424,25 @@ export function planRelease(o: PlanOptions): PlanResult {
const older = git(['rev-list', '--first-parent', '-1', '--before=' + before, sha]);
if (older.ok && older.out) prevVersion = git(['show', older.out + ':VERSION']).out.trim() || null;
}
// Finishing a security cut keeps the reason and evidence its tag recorded; a tag
// that recorded one but never moved stable needs --security verified again.
const recorded = tagExists === 'same' ? securityFromTag(git, tag) : null;
let security: Security | null = recorded && prevStable === sha ? recorded : null;
if (recorded && !security && o.security === undefined) return { ok: false, code: 'REFUSED_SECURITY', message: 'tag ' + tag + ' records a security cut that has not moved stable; run it again with --security and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
if (!security && o.security !== undefined) {
const reason = sanitizeReason(o.security);
const verified = verifySecurity({ reason, git, gh, repo, from: before, to: sha });
if (!verified.ok) return { ok: false, code: 'REFUSED_SECURITY', message: verified.why };
security = { reason, evidence: verified.evidence };
}
const changelog = git(['show', sha + ':CHANGELOG.md']);
const date = git(['show', '-s', '--format=%cs', sha]).out;
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before });
return { ok: true, plan: { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped } };
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, security });
const floor = floorStatus({ git, gh, repo, prevStable, sha, security: Boolean(security), scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
const ridingAlong = Math.max(0, notes.split('\n').filter(l => l.startsWith('- **')).length - 1);
const plan: Plan = { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped, security, floor, ridingAlong };
if (floor.verdict === 'refused' || floor.verdict === 'unknown') return { ok: false, code: floor.verdict === 'refused' ? 'REFUSED_FLOOR' : 'FLOOR_UNKNOWN', message: floor.message!, next: floor.next, plan };
return { ok: true, plan };
}
export interface CutResult { ok: boolean; lines: string[] }
@@ -302,7 +454,8 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + p.tag + '^{commit}']);
if (existing.ok && existing.out !== p.sha) return { ok: false, lines: ['TAG: ' + p.tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + p.sha.slice(0, 12)] };
if (!existing.ok) {
const t = git(['tag', '-a', p.tag, p.sha, '-m', 'gstack ' + p.tag + (p.hotfix ? ' (hotfix release)' : ' (weekly release)')]);
const message = 'gstack ' + p.tag + ' (' + releaseKind(p.hotfix, p.security) + ' release)' + (p.security ? '\n\nSecurity: ' + p.security.reason + '\nVerified: ' + p.security.evidence : '');
const t = git(['tag', '-a', p.tag, p.sha, '-m', message]);
if (!t.ok) return { ok: false, lines: [...lines, 'TAG: failed: ' + t.out] };
const push = git(['push', 'origin', 'refs/tags/' + p.tag]);
if (!push.ok) return { ok: false, lines: [...lines, 'TAG: push failed: ' + push.out] };
@@ -311,8 +464,10 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
lines.push('TAG: ' + p.tag + ' already names ' + p.sha.slice(0, 12));
}
if (!p.stableAt) {
const push = git(['push', 'origin', p.sha + ':refs/heads/stable']);
if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed: ' + push.out] };
if (p.prevStable && !git(['merge-base', '--is-ancestor', p.prevStable, p.sha]).ok) return { ok: false, lines: [...lines, 'STABLE: refused: ' + p.sha.slice(0, 12) + ' does not descend from stable ' + p.prevStable.slice(0, 12)] };
// The lease pins origin's stable to the commit the floor and the ancestry check read; a move in between fails the push.
const push = git(['push', '--force-with-lease=refs/heads/stable:' + (p.prevStable ?? ''), 'origin', p.sha + ':refs/heads/stable']);
if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed (stable moved since this plan read it, or the push was rejected): ' + push.out] };
lines.push('STABLE: ' + (p.prevStable ? p.prevStable.slice(0, 12) : 'none') + ' -> ' + p.sha.slice(0, 12));
} else {
lines.push('STABLE: already at ' + p.sha.slice(0, 12));
@@ -337,12 +492,14 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''),
'STABLE: ' + (p.stableAt ? 'already at ' + p.sha.slice(0, 12) + ' (finishing an earlier cut)' : (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12)),
'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
p.floor.line,
];
if (p.security) lines.push('SECURITY: `' + p.security.reason + '` (verified: ' + p.security.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
for (const s of p.skipped) lines.push('SKIPPED: ' + s);
if (dryRun) {
lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:',
' git tag -a ' + p.tag + ' ' + p.sha + ' && git push origin refs/tags/' + p.tag,
' git push origin ' + p.sha + ':refs/heads/stable',
' git push --force-with-lease=refs/heads/stable:' + (p.prevStable ?? '') + ' origin ' + p.sha + ':refs/heads/stable',
' gh release create ' + p.tag + ' --verify-tag --title "gstack ' + p.tag + '" --notes-file <notes>');
}
return lines;
@@ -357,18 +514,29 @@ if (import.meta.main) {
const repo = arg('--repo');
const dryRun = process.argv.includes('--dry-run');
const summary = arg('--summary');
if (!repo) { console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]'); process.exit(2); }
const security = arg('--security');
if (!repo || (process.argv.includes('--security') && (!security || security.startsWith('--')))) {
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
process.exit(2);
}
const scheduled = process.env.GITHUB_EVENT_NAME === 'schedule';
const git = gitIn(process.cwd());
const emit = (lines: string[]) => {
for (const l of lines) console.log(l);
if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n');
};
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref') });
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security });
if (!planned.ok) {
emit([planned.code + ': ' + planned.message, ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s)]);
// A dry run that finds nothing green has done its job (the skips say why);
// the real cut goes red so someone looks.
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') ? 0 : 1);
emit([planned.code + ': ' + planned.message, ...(planned.next ? ['NEXT_ALLOWED: ' + planned.next] : []), ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s), ...(planned.plan ? describePlan(planned.plan, false) : [])]);
// A dry run that finds nothing green has done its job (the skips say why),
// and so has a floor hold on a dry run or on the schedule: the policy
// working, not a fault. Every other refusal goes red so someone looks.
const hold = planned.code === 'REFUSED_FLOOR' && (dryRun || scheduled);
if (process.env.GITHUB_ACTIONS === 'true' && planned.code !== 'NOTHING_NEW' && planned.code !== 'NO_GREEN_COMMIT') {
const text = planned.code + ': ' + planned.message + (planned.next ? ' NEXT_ALLOWED: ' + planned.next : '');
console.log('::' + (hold ? 'notice' : 'error') + ' title=release-cut ' + planned.code + '::' + text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'));
}
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') || hold ? 0 : 1);
}
emit(describePlan(planned.plan, dryRun));
if (summary) fs.appendFileSync(summary, '\n<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\n');
+26 -85
View File
@@ -143,11 +143,9 @@ console.log("GSTACK_STEP_OK");
- "The first 3 things my eye goes to are: **[1]**, **[2]**, **[3]**." (hierarchy check — are these the 3 things the designer intended? If not, the visual hierarchy is lying.)
- "If I had to describe this in one word: **[word]**." (gut verdict)
**Narration mode:** Write this section in first person, as if you are a user scanning the page for the first time. "I'm looking at this page... my eye goes to the logo, then a wall of text I skip entirely, then... wait, is that a button?" Name the specific element, its position, its visual weight. If you can't name it specifically, you're not actually scanning, you're generating platitudes.
**Narration mode:** write it in first person as a user scanning the page for the first time, naming each element, its position and its visual weight; an element you cannot name is a platitude, not a scan.
**Page Area Test:** Point at each clearly defined area of the page. Can you instantly name its purpose? ("Things I can buy," "Today's deals," "How to search.") Areas you can't name in 2 seconds are poorly defined. List them.
This is the section users read first. Be opinionated. A designer doesn't hedge — they react.
**Page Area Test:** name the purpose of each clearly defined area of the page; list the areas you cannot name in 2 seconds as poorly defined. Be opinionated: this is the section users read first.
---
@@ -265,16 +263,7 @@ Check the \`URL=\` line every script prints. If it contains \`/login\`, \`/signi
### Trunk Test (run on every page)
Imagine being dropped on this page with no context. Can you immediately answer:
1. What site is this? (Site ID visible and identifiable)
2. What page am I on? (Page name prominent, matches what I clicked)
3. What are the major sections? (Primary nav visible and clear)
4. What are my options at this level? (Local nav or content choices obvious)
5. Where am I in the scheme of things? ("You are here" indicator, breadcrumbs)
6. How can I search? (Search box findable without hunting)
Score: PASS (all 6 clear) / PARTIAL (4-5 clear) / FAIL (3 or fewer clear).
A FAIL on the trunk test is a HIGH-impact finding regardless of how polished the visual design is.
Dropped on this page with no context, can you answer at once: (1) what site is this, (2) what page am I on, (3) what are the major sections, (4) what are my options here, (5) where am I ("you are here", breadcrumbs), (6) how do I search? PASS = all 6, PARTIAL = 4-5, FAIL = 3 or fewer. A FAIL is a HIGH-impact finding however polished the page looks.
### Design Audit Checklist (10 categories, ~80 items)
@@ -319,14 +308,13 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- No red/green only combinations (8% of men have red-green deficiency)
- Neutral palette is warm or cool consistently — not mixed
**4. Spacing & Layout** (12 items)
**4. Spacing & Layout** (11 items)
- Grid consistent at all breakpoints
- Spacing uses a scale (4px or 8px base), not arbitrary values
- Alignment is consistent — nothing floats outside the grid
- Rhythm: related items closer together, distinct sections further apart
- Border-radius hierarchy (not uniform bubbly radius on everything)
- Inner radius = outer radius - gap (nested elements)
- No horizontal scroll on mobile
- Max content width set (no full-bleed body text)
- \`env(safe-area-inset-*)\` for notch devices
- URL reflects state (filters, tabs, pagination in query params)
@@ -347,12 +335,10 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- Mindless choice audit: every decision point (button, link, dropdown, modal choice) is a mindless click (obvious what happens). If a click requires thought about whether it's the right choice, flag as HIGH.
- Browser surfaces themed from the palette: \`::selection\`, caret, scrollbars, focus ring, underline offset, tabular numerals. Left at defaults, the page reads as assembled, not designed
**6. Responsive Design** (8 items)
**6. Responsive Design** (6 items)
- Mobile layout makes *design* sense (not just stacked desktop columns)
- Touch targets sufficient on mobile (>= 44px)
- No horizontal scroll on any viewport
- Images handle responsive (srcset, sizes, or CSS containment)
- Text readable without zooming on mobile (>= 16px body)
- Navigation collapses appropriately (hamburger, bottom nav, etc.)
- Forms usable on mobile (correct input types, no autoFocus on mobile)
- No \`user-scalable=no\` or \`maximum-scale=1\` in viewport meta
@@ -366,18 +352,15 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- Only \`transform\` and \`opacity\` animated (not layout properties like width, height, top, left)
- One authored motion moment per page: not the same entrance on every section, not a hover effect on everything. Ease-out from an already-visible default; content never hides behind animation timing
**8. Content & Microcopy** (11 items)
- Empty states designed with warmth (message + action + illustration/icon)
- Error messages specific: what happened + why + what to do next
**8. Content & Microcopy** (8 items)
- Button labels specific ("Save API Key" not "Continue" or "Submit")
- No placeholder/lorem ipsum text visible in production
- Truncation handled (\`text-overflow: ellipsis\`, \`line-clamp\`, or \`break-words\`)
- Active voice ("Install the CLI" not "The CLI will be installed")
- Loading states end with \`…\` ("Saving…" not "Saving...")
- Destructive actions have confirmation modal or undo window
- Happy talk detection: scan for introductory paragraphs that start with "Welcome to..." or tell users how great the site is. If you can hear "blah blah blah", it's happy talk. Flag for removal.
- Instructions detection: any visible instructions longer than one sentence. If users need to read instructions, the design has failed. Flag the instructions AND the interaction they're compensating for.
- Happy talk word count: count total visible words on the page. Classify each text block as "useful content" vs "happy talk" (welcome paragraphs, self-congratulatory text, instructions nobody reads). Report: "This page has X words. Y (Z%) are happy talk."
- Happy talk: welcome paragraphs and self-praise ("Welcome to..."). Flag them, and report "This page has X words. Y (Z%) are happy talk."
- Instructions longer than one sentence mean the design failed: flag the instructions AND the interaction they compensate for.
**9. AI Slop Detection** (${AI_SLOP_BLACKLIST.length} blacklist patterns, ${detectorAll.length} detector rules, ${judgmentAll.length} judgment tells; polish-level ones on the last line)
@@ -435,42 +418,21 @@ Evaluate:
- **Feedback clarity:** Did the action clearly succeed or fail? Is the feedback immediate?
- **Form polish:** Focus states visible? Validation timing correct? Errors near the source?
**Narration mode:** Narrate the flow in first person. "I click 'Sign Up'... spinner appears... 3 seconds pass... still spinning... I'm getting nervous. Finally the dashboard loads, but where am I? The nav doesn't highlight anything." Name the specific element, its position, its visual weight. If you can't name it specifically, you're not actually experiencing the flow, you're generating platitudes.
**Narration mode:** narrate the flow in first person ("I click 'Sign Up'... still spinning after 3 seconds..."), naming each element, its position and its visual weight.
### Goodwill Reservoir (track across the flow)
As you walk the user flow, maintain a mental goodwill meter (starts at 70/100).
These scores are heuristic, not measured. The value is in identifying specific
drains and fills, not in the final number.
Keep a heuristic goodwill meter starting at 70/100; the drains and fills matter, not the number. Drains: hidden information the user wants (pricing, contact, shipping) −15; interstitials, splash screens or forced tours −15; format punishment (rejecting dashes in a phone number) −10; unnecessary information requests −10; sloppy appearance −10; each ambiguous choice −5. Fills: top tasks obvious +10; graceful error recovery with a specific fix +10; upfront about costs and limits +5; each saved step (direct links, smart defaults, autofill) +5; apologizes when things go wrong +5.
Subtract points for:
- Hidden information the user would want (pricing, contact, shipping): subtract 15
- Format punishment (rejecting valid input like dashes in phone numbers): subtract 10
- Unnecessary information requests: subtract 10
- Interstitials, splash screens, forced tours blocking the task: subtract 15
- Sloppy or unprofessional appearance: subtract 10
- Ambiguous choices that require thinking: subtract 5 each
Add points for:
- Top user tasks are obvious and prominent: add 10
- Upfront about costs and limitations: add 5
- Saves steps (direct links, smart defaults, autofill): add 5 each
- Graceful error recovery with specific fix instructions: add 10
- Apologizes when things go wrong: add 5
Report the final goodwill score with a visual dashboard:
Report it as a dashboard, one line per step:
\`\`\`
Goodwill: 70 ████████████████████░░░░░░░░░░
Step 1: Login page 70 → 75 (+5 obvious primary action)
Step 2: Dashboard 75 → 60 (-15 interstitial tour popup)
Step 3: Settings 60 → 50 (-10 format punishment on phone)
Step 4: Billing 50 → 35 (-15 hidden pricing info)
FINAL: 35/100 ⚠️ CRITICAL UX DEBT
Step 1: Dashboard 70 → 55 (-15 interstitial tour popup)
FINAL: 55/100
\`\`\`
Below 30 = critical UX debt. 30-60 = needs work. Above 60 = healthy.
Include the biggest drains and fills as specific findings.
Below 30 = critical UX debt, 30-60 = needs work, above 60 = healthy. File the biggest drains and fills as findings.
---
@@ -487,18 +449,9 @@ Compare screenshots and observations across pages for:
## Phase 6: Compile Report
### Output Locations
### Baseline
**Local:** \`.gstack/design-reports/design-audit-{domain}-{YYYY-MM-DD}.md\`
**Project-scoped:**
\`\`\`bash
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null) && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG"
\`\`\`
Write to: \`<PROJECT_DIR>/{user}-{branch}-design-audit-{datetime}.md\` (\`PROJECT_DIR\` printed above)
**Baseline:** Write \`design-baseline.json\` for regression mode (temp file then \`mv\`, and a per-run copy \`design-baseline.<runId>.json\` beside it):
The report goes where Phase 10 says. Write \`$REPORT_DIR/design-baseline.json\` for regression mode (temp file then \`mv\`, and a per-run copy \`design-baseline.<runId>.json\` beside it):
\`\`\`json
{
"schemaVersion": 2,
@@ -528,12 +481,7 @@ Write to: \`<PROJECT_DIR>/{user}-{branch}-design-audit-{datetime}.md\` (\`PROJEC
- **Design Score: {A-F}** — weighted average of all 10 categories
- **AI Slop Score: {A-F}** — standalone grade with pithy verdict
**Per-category grades:**
- **A:** Intentional, polished, delightful. Shows design thinking.
- **B:** Solid fundamentals, minor inconsistencies. Looks professional.
- **C:** Functional but generic. No major problems, no design point of view.
- **D:** Noticeable problems. Feels unfinished or careless.
- **F:** Actively hurting user experience. Needs significant rework.
**Per-category grades:** A intentional and polished; B solid, minor inconsistencies; C functional but generic, no point of view; D noticeable problems, feels careless; F actively hurts the user.
**Grade computation:** Each category starts at A. Each High-impact finding drops one letter grade. Each Medium-impact finding drops half a letter grade. Polish findings are noted but do not affect grade. Minimum is F.
@@ -565,29 +513,22 @@ When previous \`design-baseline.json\` exists or \`--regression\` flag is used:
## Design Critique Format
Use structured feedback, not opinions:
- "I notice..." — observation (e.g., "I notice the primary CTA competes with the secondary action")
- "I wonder..." — question (e.g., "I wonder if users will understand what 'Process' means here")
- "What if..." — suggestion (e.g., "What if we moved search to a more prominent position?")
- "I think... because..." — reasoned opinion (e.g., "I think the spacing between sections is too uniform because it doesn't create hierarchy")
Tie everything to user goals and product objectives. Always suggest specific improvements alongside problems.
Phrase findings as "I notice..." (observation), "I wonder..." (question), "What if..." (suggestion) and "I think... because..." (reasoned opinion), tied to user goals, each with a specific improvement.
---
## Important Rules
1. **Think like a designer, not a QA engineer.** You care whether things feel right, look intentional, and respect the user. You do NOT just care whether things "work."
2. **Screenshots are evidence.** Every finding needs at least one screenshot. Use annotated screenshots (\`annotatedScreenshot(pg)\`) to highlight elements.
3. **Be specific and actionable.** "Change X to Y because Z" — not "the spacing feels off."
1. **Think like a designer, not a QA engineer:** whether it feels right and looks intentional, not only whether it works.
2. **Screenshots are evidence.** Every finding needs at least one; annotate (\`annotatedScreenshot(pg)\`) to highlight elements.
3. **Be specific and actionable:** "change X to Y because Z", never "the spacing feels off."
4. **Never read source code.** Evaluate the rendered site, not the implementation. (Exception: offer to write DESIGN.md from extracted observations.)
5. **AI Slop detection is your superpower.** Most developers can't evaluate whether their site looks AI-generated. You can. Be direct about it.
5. **Name AI slop directly.** Most developers cannot see it.
6. **Quick wins matter.** Always include a "Quick Wins" section — the 3-5 highest-impact fixes that take <30 minutes each.
7. **Fall back to \`annotatedScreenshot(pg)\` for tricky UIs.** When the snapshot tree does not surface a control you can plainly see (clickable divs, canvas buttons), take the annotated screenshot, Read it, and drive by CSS selector or \`pg.getByText(...)\` instead of by ref.
8. **Responsive is design, not just "not broken."** A stacked desktop layout on mobile is not responsive design — it's lazy. Evaluate whether the mobile layout makes *design* sense.
9. **Document incrementally.** Write each finding to the report as you find it. Don't batch.
10. **Depth over breadth.** 5-10 well-documented findings with screenshots and specific suggestions > 20 vague observations.
11. **Show screenshots to the user.** After every script that saves a screenshot, annotated screenshot, or responsive set, \`cp\` the files out of the printed \`ASIDE_DIR\` into \`$REPORT_DIR/screenshots/\` and use the Read tool on each copied file so the user can see them inline. For the responsive set (3 files), Read all three. This is critical — without it, screenshots are invisible to the user.`;
7. **Tricky UIs:** when the snapshot tree misses a control you can see (clickable divs, canvas buttons), Read the annotated screenshot and drive by CSS selector or \`pg.getByText(...)\`.
8. **Document incrementally,** each finding as you find it.
9. **Depth over breadth:** 5-10 well-documented findings beat 20 vague ones.
10. **Show screenshots to the user.** After every script that saves a screenshot, annotated screenshot, or responsive set, \`cp\` the files out of the printed \`ASIDE_DIR\` into \`$REPORT_DIR/screenshots/\` and use the Read tool on each copied file so the user can see them inline. For the responsive set (3 files), Read all three. This is critical — without it, screenshots are invisible to the user.`;
}
export function generateDesignSketch(ctx: TemplateContext): string {
+578
View File
@@ -0,0 +1,578 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* Static skill-to-bin drift scanner (GSTA-133). Skills teach gstack's own bins;
* when a template names a bin that is gone, or passes a flag the bin's parser
* does not accept, the model runs a wrong command that still passes CI.
*
* Inputs: SKILL.md.tmpl (root and one level down), <skill>/sections/*.md.tmpl,
* <skill>/references/*.md, and the Claude-host renders (SKILL.md, sections/*.md, which carry the
* AUTO-GENERATED banner) so resolver-generated commands count. A render finding
* that its own skill's templates already report is dropped. --skill NAME limits
* the scan to one skill folder (the /write-skill diet step). Only fenced code
* blocks and inline backtick spans are read, so prose placeholders never match.
*
* Bin spellings recognised (every form the templates and renders use today):
* bin/gstack-x (repo-relative)
* ~/.claude/skills/gstack/bin/gstack-x (Claude install; also .claude/skills/...)
* $HOME/.claude/skills/gstack/bin/gstack-x, "$HOME"/.claude/skills/gstack/bin/gstack-x
* $GSTACK_BIN/gstack-x, ${GSTACK_BIN}/gstack-x (env-var hosts)
* {{BIN_DIR}}/gstack-x, {{INSTALLED_BIN_DIR}}/gstack-x (template placeholders)
* $GSTACK_ROOT/bin/gstack-x, $RUNTIME_ROOT/bin/gstack-x, {{RUNTIME_ROOT}}/bin/gstack-x
* ${B%/browse/*}/bin/gstack-x (any ${...}/bin/ expansion)
*
* A call's arguments end at an unquoted pipe, ;, &, &&, ||, redirect, $( ),
* backtick, closing parenthesis, comment or line end (backslash continuations
* join). <VALUE> and <two words> placeholders are words, not redirects. Flags after -- are
* positional.
*
* Flag discovery never executes a bin. Bash: case arm labels (--a), --a|--b),
* --a=*)) whose body does not only refuse, and test comparisons
* ([ "$1" = "--a" ]). TS/JS/Python: --flag tokens in code and string
* literals (usage text included), in the bin and in its relative imports one
* level deep. A bash bin that hands off to a .ts/.js/.mjs/.py file, or passes
* "$@" to a .sh file or sibling gstack-* bin, gains that file's flags. A bin
* .gitignore lists as a build output is read from bin/<name>.ts; with no
* source its flags are unchecked (UNCHECKED list).
*
* Reported, never failed: PERMISSIVE (a bash arg loop whose catch-all *) arm
* neither errors nor has a -*) error arm beside it, or a bash bin that never
* reads its arguments) and NO_HELP (no --help handling and no usage text
* outside comments). A sourced .sh library that never reads arguments gets
* neither label. Failures: DRIFT_UNKNOWN_BIN, DRIFT_UNKNOWN_FLAG.
* test/skill-bin-drift.test.ts holds today's misses in a shrink-only allowlist
* (test/fixtures/skill-bin-drift-allowlist.json).
*/
import * as fs from 'fs';
import * as path from 'path';
export const FIX_LINE = "fix the template or the bin's parser; the allowlist only shrinks";
export const ALLOWLIST_FILE = 'test/fixtures/skill-bin-drift-allowlist.json';
export type DriftCode = 'DRIFT_UNKNOWN_BIN' | 'DRIFT_UNKNOWN_FLAG';
export interface BinCall {
file: string;
line: number;
bin: string;
flags: string[];
}
export interface Finding {
code: DriftCode;
file: string;
line: number;
bin: string;
flag?: string;
key: string;
fix: string;
}
export interface BinProfile {
bin: string;
exists: boolean;
flagsKnown: boolean;
flags: Set<string>;
permissive: boolean;
hasHelp: boolean;
sources: string[];
}
export interface DriftReport {
inputs: string[];
calls: BinCall[];
findings: Finding[];
binsInvoked: string[];
permissive: string[];
noHelp: string[];
unchecked: string[];
}
type Lang = 'sh' | 'ts' | 'py';
const BIN_NAME = 'gstack-[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?';
const BIN_PREFIX = String.raw`(?:~|\$HOME|\$\{HOME\}|"\$HOME")?/?\.claude/skills/gstack/|\$\{[^}\s]*\}/|\$\{?[A-Z_][A-Z0-9_]*\}?/|\{\{[A-Z_]+\}\}/`;
const BIN_RE = new RegExp(
String.raw`(?<![\w./$}-])(?:(?:${BIN_PREFIX})?bin/|\$\{?GSTACK_BIN\}?/|\{\{(?:INSTALLED_)?BIN_DIR\}\}/)(${BIN_NAME})(?![\w.-]*[\w])`,
'g',
);
const FLAG_WORD = /^--[A-Za-z0-9][A-Za-z0-9_-]*/;
/** Shell-style words after a bin, up to the first command boundary. */
export function binArgs(rest: string, openQuote: string | null = null): string[] {
const words: string[] = [];
let i = 0;
if (openQuote) {
const close = rest.indexOf(openQuote);
if (close < 0) return [];
i = close + 1;
}
let cur = '';
let has = false;
let quote: string | null = null;
const push = () => { if (has) words.push(cur); cur = ''; has = false; };
for (; i < rest.length; i++) {
const c = rest[i]!;
if (quote) {
if (c === quote) quote = null;
else if (c === '\\' && quote === '"') cur += rest[++i] ?? '';
else cur += c;
continue;
}
if (c === "'" || c === '"') { quote = c; has = true; continue; }
if (c === '\\') { cur += rest[++i] ?? ''; has = true; continue; }
if (/\s/.test(c)) { push(); continue; }
if (c === '>') {
if (/^\d+$/.test(cur)) { cur = ''; has = false; }
push();
if ('>&|'.includes(rest[i + 1] ?? '')) i++;
while (/[ \t]/.test(rest[i + 1] ?? '')) i++;
while (i + 1 < rest.length && !/\s/.test(rest[i + 1]!) && !'|;&`)'.includes(rest[i + 1]!)) i++;
continue;
}
if ('|;&`)'.includes(c) || (c === '$' && rest[i + 1] === '(')) break;
if (c === '#' && !has) break;
if (c === '<') {
const ph = /^<[A-Za-z_][^<>\n]*>/.exec(rest.slice(i));
if (!ph) break;
cur += ph[0]; has = true; i += ph[0].length - 1;
continue;
}
cur += c; has = true;
}
push();
return words;
}
/** The quote character open at `end` in a shell line, if any. */
function openQuoteAt(text: string, end: number): string | null {
let quote: string | null = null;
for (let i = 0; i < end; i++) {
const c = text[i]!;
if (quote) { if (c === quote) quote = null; else if (c === '\\' && quote === '"') i++; continue; }
if (c === '\\') { i++; continue; }
if (c === "'" || c === '"') quote = c;
}
return quote;
}
function flagsOf(words: string[]): string[] {
const out: string[] = [];
for (const w of words) {
if (w === '--') break;
const m = FLAG_WORD.exec(w);
if (m) out.push(m[0]);
}
return out;
}
function callsInSnippet(snippet: string, file: string, line: number, out: BinCall[]): void {
for (const m of snippet.matchAll(BIN_RE)) {
const end = m.index! + m[0].length;
const words = binArgs(snippet.slice(end), openQuoteAt(snippet, m.index!));
out.push({ file, line, bin: m[1]!, flags: flagsOf(words) });
}
}
const FENCE = /^\s*(`{3,}|~{3,})/;
/** Bin calls inside fenced code blocks and inline backtick spans of one markdown file. */
export function scanText(text: string, file: string): BinCall[] {
const out: BinCall[] = [];
const lines = text.split('\n');
let fence: string | null = null;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]!;
const marker = FENCE.exec(raw);
if (marker) {
if (!fence) fence = marker[1]!;
else if (marker[1]![0] === fence[0] && marker[1]!.length >= fence.length) fence = null;
continue;
}
if (fence) {
let logical = raw;
const start = i;
while (/\\$/.test(logical) && i + 1 < lines.length && !FENCE.test(lines[i + 1]!)) {
logical = logical.slice(0, -1) + ' ' + lines[++i]!;
}
callsInSnippet(logical, file, start + 1, out);
continue;
}
for (const span of raw.matchAll(/(`+)([^`]+?)\1(?!`)/g)) callsInSnippet(span[2]!, file, i + 1, out);
}
return out;
}
function readText(file: string): string {
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
}
function isFile(file: string): boolean {
return fs.existsSync(file) && fs.statSync(file).isFile();
}
function langOf(file: string, text: string): Lang {
if (/\.py$/.test(file)) return 'py';
if (/\.(?:[cm]?[jt]s|tsx)$/.test(file)) return 'ts';
const first = text.split('\n', 1)[0] ?? '';
if (/^#!.*\bpython/.test(first)) return 'py';
if (/^#!.*\b(?:bun|node|deno)\b/.test(first)) return 'ts';
return 'sh';
}
const COMMENT: Record<Lang, RegExp> = { sh: /^\s*#/, py: /^\s*#/, ts: /^\s*(?:\/\/|\*|\/\*)/ };
function codeLines(text: string, lang: Lang): string[] {
return text.split('\n').filter(l => !COMMENT[lang].test(l));
}
/** --flag tokens in TS/JS/Python code and string literals, usage text included. */
export function literalFlags(text: string, lang: Lang = 'ts'): Set<string> {
const flags = new Set<string>();
for (const line of codeLines(text, lang)) {
for (const m of line.matchAll(/(?<![\w-])(--[A-Za-z][A-Za-z0-9_-]*)/g)) flags.add(m[1]!);
}
return flags;
}
const ARM = /^[ \t]*\(?[ \t]*((?:["']?-[-\w=*]*["']?[ \t]*\|[ \t]*)*["']?-[-\w=*]*["']?)[ \t]*\)([\s\S]*?)(?:;;|\besac\b)/gm;
const refuses = (body: string) => !/\bshift\b|\b[A-Za-z_][A-Za-z0-9_]*=/.test(body)
&& /\b(?:refuse|die|fail)\b|\bexit\s+[1-9]|\breturn\s+[1-9]/.test(body);
/** Flags a bash bin's case arms (minus arms that only refuse) and test comparisons accept. */
export function bashFlags(text: string): Set<string> {
const flags = new Set<string>();
const code = codeLines(text, 'sh').join('\n');
for (const arm of code.matchAll(ARM)) {
if (refuses(arm[2]!)) continue;
for (const label of arm[1]!.split('|')) {
const m = FLAG_WORD.exec(label.trim().replace(/^["']|["']$/g, ''));
if (m) flags.add(m[0]);
}
}
for (const m of code.matchAll(/(?:==?|!=)\s*["']?(--[A-Za-z0-9][A-Za-z0-9_-]*)/g)) flags.add(m[1]!);
return flags;
}
/** Top-level bash code plus the bodies of functions it (transitively) hands "$@" to; other functions' $1 is not the bin's. */
function argScope(code: string): string {
const top: string[] = [];
const funcs = new Map<string, string[]>();
let cur: string | null = null;
for (const line of code.split('\n')) {
const start = /^\s*(?:function\s+)?([\w-]+)\s*\(\)\s*\{?\s*$/.exec(line);
if (cur === null && start) { cur = start[1]!; funcs.set(cur, []); continue; }
if (cur !== null) {
if (/^\}/.test(line)) cur = null;
else funcs.get(cur)!.push(line);
continue;
}
top.push(line);
}
const scope = [...top];
const included = new Set<string>();
for (let grew = true; grew;) {
grew = false;
const text = scope.join('\n');
for (const [name, body] of funcs) {
if (included.has(name) || !new RegExp(`(?:^|[\\s;&|(])${name}\\s+"\\$@"`, 'm').test(text)) continue;
included.add(name);
scope.push(...body);
grew = true;
}
}
return scope.join('\n');
}
const readsArgs = (code: string) => /\$\{?[1-9@*#]|getopts|\bshift\b/.test(code);
/** A sourced .sh function library: it never reads arguments at top level, so permissive and help checks do not apply. */
export function isShellLibrary(bin: string, text: string): boolean {
return bin.endsWith('.sh') && !readsArgs(argScope(codeLines(text, 'sh').join('\n')));
}
/** In a case over the arguments, a catch-all *) arm with no error and no -*) error arm; or arguments never read. */
export function bashPermissive(text: string): boolean {
const code = argScope(codeLines(text, 'sh').join('\n'));
if (!readsArgs(code)) return true;
const argVars = new Set(['1']);
for (const m of code.matchAll(/\bfor\s+(\w+)\s+in\s+"\$@"/g)) argVars.add(m[1]!);
for (const m of code.matchAll(/\b(\w+)="?\$\{?1\b/g)) argVars.add(m[1]!);
const blocks = [...code.matchAll(/\bcase\s+"?\$\{?(\w+)[^\n]*?\bin\b([\s\S]*?)\besac\b/g)].filter(m => argVars.has(m[1]!));
const arms = blocks.flatMap(block => [...block[2]!.matchAll(/^[ \t]*\(?[ \t]*(["']?-?-?\*["']?)[ \t]*\)([\s\S]*?)(?:;;|$)/gm)]);
const errors = (body: string) => /\bexit\b|\breturn\b|\bdie\b|usage|>&2|\berr\b|fail|refuse/i.test(body);
const dashError = arms.some(a => /-/.test(a[1]!) && errors(a[2]!));
const silentCatchAll = arms.some(a => !/-/.test(a[1]!) && !errors(a[2]!));
return silentCatchAll && !dashError;
}
function hasUsage(text: string, lang: Lang): boolean {
return /usage/i.test(codeLines(text, lang).filter(l => !/^#!/.test(l)).join('\n'));
}
function resolveModule(fromFile: string, spec: string): string | null {
const base = path.resolve(path.dirname(fromFile), spec);
for (const cand of [base, `${base}.ts`, `${base}.js`, `${base}.mjs`, path.join(base, 'index.ts')]) {
if (isFile(cand)) return cand;
}
return null;
}
/** Relative imports (static, dynamic and require) of one source file. */
export function relativeImports(file: string, text: string): string[] {
const out = new Set<string>();
for (const m of text.matchAll(/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"](\.{1,2}\/[^'"]+)['"]/gm)) {
const hit = resolveModule(file, m[1]!);
if (hit) out.add(hit);
}
return [...out];
}
/** Files a bash bin hands its arguments to: .ts/.js/.mjs/.py paths, and .sh files or sibling bins given "$@". */
export function bashHandoffs(binFile: string, text: string, root: string): string[] {
const out = new Set<string>();
const binDir = path.dirname(binFile);
for (const line of codeLines(text, 'sh')) {
const forwards = /"\$@"/.test(line);
for (const m of line.matchAll(/[^\s'"=;|&<>]+\.(?:[cm]?[jt]s|py|sh)(?![\w.])/g)) {
if (m[0].endsWith('.sh') && !forwards) continue;
const tail = m[0].replace(/^.*(?:\}|\)|\$[A-Za-z_][A-Za-z0-9_]*)/, '').replace(/^\//, '');
if (!tail) continue;
const hit = [binDir, root].map(dir => path.resolve(dir, tail)).find(c => c !== binFile && isFile(c));
if (hit) out.add(hit);
}
if (!forwards) continue;
for (const m of line.matchAll(new RegExp(`(?:^|[/\\s"])(${BIN_NAME})(?![\\w.-])`, 'g'))) {
const cand = path.join(binDir, m[1]!);
if (cand !== binFile && isFile(cand)) out.add(cand);
}
}
return [...out];
}
/** Bin names .gitignore lists as build outputs (bin/<pattern> lines). */
function builtPatterns(root: string): RegExp[] {
return readText(path.join(root, '.gitignore')).split('\n')
.map(l => /^\/?bin\/(gstack-[^\s/]+)$/.exec(l.trim())?.[1])
.filter((p): p is string => Boolean(p))
.map(p => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`));
}
/** Static profile of one bin: accepted flags, permissive parsing, help text. */
export function profileBin(root: string, bin: string, built: RegExp[] = builtPatterns(root)): BinProfile {
const binFile = path.join(root, 'bin', bin);
const profile: BinProfile = { bin, exists: false, flagsKnown: true, flags: new Set(), permissive: false, hasHelp: false, sources: [] };
const seen = new Set<string>();
const absorb = (file: string, text: string, lang: Lang, flags: Set<string>) => {
seen.add(file);
profile.sources.push(path.relative(root, file));
for (const f of flags) profile.flags.add(f);
if (hasUsage(text, lang)) profile.hasHelp = true;
};
const addScript = (file: string, followImports: boolean) => {
if (seen.has(file)) return;
const text = readText(file);
const lang = langOf(file, text);
absorb(file, text, lang, literalFlags(text, lang));
if (followImports && lang === 'ts') for (const dep of relativeImports(file, text)) addScript(dep, false);
};
if (built.some(re => re.test(bin)) && !/\.(?:[cm]?[jt]s|py)$/.test(bin)) {
profile.exists = true;
if (isFile(`${binFile}.ts`)) addScript(`${binFile}.ts`, true);
else profile.flagsKnown = false;
} else if (isFile(binFile)) {
profile.exists = true;
const text = readText(binFile);
if (langOf(binFile, text) !== 'sh') {
addScript(binFile, true);
} else {
absorb(binFile, text, 'sh', bashFlags(text));
if (isShellLibrary(bin, text)) profile.hasHelp = true;
else profile.permissive = bashPermissive(text);
for (const handoff of bashHandoffs(binFile, text, root)) {
if (seen.has(handoff)) continue;
const handoffText = readText(handoff);
if (langOf(handoff, handoffText) !== 'sh') addScript(handoff, true);
else absorb(handoff, handoffText, 'sh', bashFlags(handoffText));
}
}
}
if (profile.flags.has('--help')) profile.hasHelp = true;
return profile;
}
function subdirs(root: string): string[] {
return fs.readdirSync(root, { withFileTypes: true })
.filter(d => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules' && d.name !== 'dist')
.map(d => d.name)
.sort();
}
/** Template sources, sections/references and Claude-host renders, repo-relative; one skill's when `skill` is set. */
export function listInputs(root: string, skill?: string): string[] {
const out: string[] = [];
const add = (rel: string) => { if (isFile(path.join(root, rel))) out.push(rel); };
if (!skill) { add('SKILL.md.tmpl'); add('SKILL.md'); }
for (const dir of skill ? [skill] : subdirs(root)) {
add(`${dir}/SKILL.md.tmpl`);
add(`${dir}/SKILL.md`);
for (const sub of ['sections', 'references']) {
const full = path.join(root, dir, sub);
if (!fs.existsSync(full) || !fs.statSync(full).isDirectory()) continue;
for (const name of fs.readdirSync(full).sort()) {
if (name.endsWith('.md.tmpl') || name.endsWith('.md')) add(`${dir}/${sub}/${name}`);
}
}
}
return out;
}
const isTemplate = (file: string) => file.endsWith('.tmpl');
const skillDirOf = (file: string) => (file.includes('/') ? file.split('/')[0]! : '.');
export function findingKey(f: { file: string; bin: string; flag?: string }): string {
return f.flag ? `${f.file}:${f.bin}:${f.flag}` : `${f.file}:${f.bin}`;
}
function findingsFor(calls: BinCall[], profiles: Map<string, BinProfile>): Finding[] {
const out: Finding[] = [];
for (const call of calls) {
const profile = profiles.get(call.bin)!;
const make = (code: DriftCode, flag?: string): Finding => {
const base = { code, file: call.file, line: call.line, bin: call.bin, flag };
return { ...base, key: findingKey(base), fix: FIX_LINE };
};
if (!profile.exists) { out.push(make('DRIFT_UNKNOWN_BIN')); continue; }
if (!profile.flagsKnown) continue;
for (const flag of call.flags) if (!profile.flags.has(flag)) out.push(make('DRIFT_UNKNOWN_FLAG', flag));
}
return out;
}
/** Drop render findings whose skill's own templates already report the same bin and flag. */
function dropRenderEchoes(findings: Finding[]): Finding[] {
const id = (f: Finding) => `${skillDirOf(f.file)}|${f.code}|${f.bin}|${f.flag ?? ''}`;
const fromTemplates = new Set(findings.filter(f => isTemplate(f.file)).map(id));
return findings.filter(f => isTemplate(f.file) || !fromTemplates.has(id(f)));
}
/** Scan a repository root (or a fixture laid out like one). */
export function scanRepo(root: string, inputs: string[] = listInputs(root)): DriftReport {
const calls = inputs.flatMap(file => scanText(readText(path.join(root, file)), file));
const built = builtPatterns(root);
const profiles = new Map<string, BinProfile>();
for (const call of calls) if (!profiles.has(call.bin)) profiles.set(call.bin, profileBin(root, call.bin, built));
const seen = new Set<string>();
const findings = dropRenderEchoes(findingsFor(calls, profiles)).filter(f => {
const id = `${f.key}|${f.line}`;
if (seen.has(id)) return false;
seen.add(id);
return true;
});
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.key.localeCompare(b.key));
const existing = [...profiles.values()].filter(p => p.exists).sort((a, b) => a.bin.localeCompare(b.bin));
return {
inputs,
calls,
findings,
binsInvoked: [...profiles.keys()].sort(),
permissive: existing.filter(p => p.permissive).map(p => p.bin),
noHelp: existing.filter(p => p.flagsKnown && !p.hasHelp).map(p => p.bin),
unchecked: existing.filter(p => !p.flagsKnown).map(p => p.bin),
};
}
export interface Allowlist { _comment?: string; entries: string[] }
export function loadAllowlist(file: string): Allowlist {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Allowlist;
if (!Array.isArray(parsed.entries)) throw new Error(`${file}: "entries" must be an array of finding keys`);
return parsed;
}
/** Findings the allowlist does not cover, and allowlist keys (for the scanned inputs, when given) that no longer reproduce. */
export function compareAllowlist(findings: Finding[], allowlist: Allowlist, inputs?: string[]): { unlisted: Finding[]; stale: string[] } {
const allowed = new Set(allowlist.entries);
const live = new Set(findings.map(f => f.key));
const scanned = (key: string) => !inputs || inputs.some(file => key.startsWith(`${file}:`));
return {
unlisted: findings.filter(f => !allowed.has(f.key)),
stale: allowlist.entries.filter(k => scanned(k) && !live.has(k)),
};
}
export function formatFinding(f: Finding): string {
const what = f.code === 'DRIFT_UNKNOWN_BIN' ? `bin/${f.bin} does not exist` : `${f.bin} does not accept ${f.flag}`;
return `${f.code} ${f.file}:${f.line} bin=${f.bin}${f.flag ? ` flag=${f.flag}` : ''}: ${what}\n fix: ${f.fix}`;
}
const HELP = `Usage: scripts/skill-bin-drift.ts [--skill NAME] [--json] [--all] [--root DIR]
Statically checks every gstack bin that skill templates, carved sections and
Claude-host renders invoke: the bin must exist in bin/ and every --flag passed
to it must appear in its parser (bash case arms or comparisons, TS/JS/Python
flag literals one relative import deep, bash hand-offs to .ts/.js/.py/.sh).
Never executes a bin.
--skill NAME scan only NAME/: its template, sections, references and
Claude render (stale allowlist entries are judged for those
files only)
--json print the report as JSON
--all list every finding, allowlisted ones included
--root DIR scan DIR instead of this checkout
--help show this help
Exit codes: 0 no finding outside ${ALLOWLIST_FILE} and no stale entry;
1 drift outside the allowlist or a stale allowlist entry; 2 bad usage.
PERMISSIVE, NO_HELP and UNCHECKED bins are reported, never failed.`;
interface CliOptions { json: boolean; all: boolean; root: string; skill?: string }
function parseCli(argv: string[]): CliOptions | string {
const opts: CliOptions = { json: false, all: false, root: path.resolve(import.meta.dir, '..') };
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === '--json') opts.json = true;
else if (a === '--all') opts.all = true;
else if (a === '--root' || a === '--skill') {
const v = argv[++i];
if (!v || v.startsWith('--')) return `${a} needs a value`;
if (a === '--root') opts.root = path.resolve(v);
else opts.skill = v.replace(/\/+$/, '');
} else return `unknown argument ${a}`;
}
if (opts.skill && listInputs(opts.root, opts.skill).length === 0) {
return `no skill named ${opts.skill} under ${opts.root} (expected ${opts.skill}/SKILL.md.tmpl or ${opts.skill}/SKILL.md)`;
}
return opts;
}
function main(argv: string[]): number {
if (argv.includes('--help') || argv.includes('-h')) { console.log(HELP); return 0; }
const opts = parseCli(argv);
if (typeof opts === 'string') { console.error(`skill-bin-drift: ${opts}\n\n${HELP}`); return 2; }
const report = scanRepo(opts.root, listInputs(opts.root, opts.skill));
const allowFile = path.join(opts.root, ALLOWLIST_FILE);
const allowlist = isFile(allowFile) ? loadAllowlist(allowFile) : { entries: [] };
const { unlisted, stale } = compareAllowlist(report.findings, allowlist, opts.skill ? report.inputs : undefined);
const unknownBins = report.findings.filter(f => f.code === 'DRIFT_UNKNOWN_BIN').length;
if (opts.json) {
console.log(JSON.stringify({
inputs: report.inputs.length, calls: report.calls.length, binsInvoked: report.binsInvoked.length,
unknownBins, unknownFlags: report.findings.length - unknownBins,
findings: report.findings, unlisted: unlisted.map(f => f.key), stale,
permissive: report.permissive, noHelp: report.noHelp, unchecked: report.unchecked,
}, null, 2));
} else {
for (const f of opts.all ? report.findings : unlisted) console.log(formatFinding(f));
for (const key of stale) console.log(`STALE_ALLOWLIST ${key}: no longer reproduces; delete it from ${ALLOWLIST_FILE}`);
console.log(`PERMISSIVE (${report.permissive.length}): ${report.permissive.join(' ') || 'none'}`);
console.log(`NO_HELP (${report.noHelp.length}): ${report.noHelp.join(' ') || 'none'}`);
console.log(`UNCHECKED (${report.unchecked.length}): ${report.unchecked.join(' ') || 'none'}`);
console.log(`skill-bin-drift: ${report.inputs.length} inputs, ${report.calls.length} calls, ${report.binsInvoked.length} bins, `
+ `${unknownBins} unknown-bin and ${report.findings.length - unknownBins} unknown-flag findings `
+ `(${unlisted.length} outside the allowlist, ${stale.length} stale entries)`);
}
return unlisted.length > 0 || stale.length > 0 ? 1 : 0;
}
if (import.meta.main) process.exit(main(process.argv.slice(2)));