axstack 0.30.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ import { chmodSync, closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, realpathSync, rmSync, writeFileSync, writeSync } from 'node:fs';
2
+
3
+ const installs = {
4
+ bun: ['install', '--frozen-lockfile'], npm: ['ci'],
5
+ pnpm: ['install', '--frozen-lockfile'], yarn: ['install', '--immutable'],
6
+ };
7
+ const groups = [['env.build', ['build']], ['testing.test', ['test']], ['testing.lint-typecheck', ['lint', 'typecheck']]];
8
+ const drainGraceMs = 200;
9
+ // No shell interpolation, and the wrapper preserves the command's allowlisted env.
10
+ const networkWrapper = `const ip = Bun.spawnSync([process.argv[1], 'link', 'set', 'lo', 'up'], { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' });
11
+ if (ip.exitCode !== 0) process.exit(ip.exitCode || 1);
12
+ const command = Bun.spawn(process.argv.slice(2), { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' });
13
+ process.exit(await command.exited);`;
14
+
15
+ function killGroup(proc) {
16
+ try { process.kill(-proc.pid, 'SIGKILL'); }
17
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
18
+ }
19
+
20
+ async function runCommand(commands, paths, kind, argv, cwd, env, limits, state, spawnArgv = argv) {
21
+ const started = performance.now();
22
+ const step = { kind, argv, spawnArgv, exitCode: null, durationMs: 0, logPath: `${paths.evidence}/${crypto.randomUUID()}-${kind}.log`, timedOut: false, logBytes: 0, omittedBytes: 0, logIncomplete: false };
23
+ step.receiptPath = `${step.logPath.slice(0, -4)}.json`;
24
+ commands.push(step);
25
+ const fd = openSync(step.logPath, 'wx', 0o600);
26
+ let proc;
27
+ let timer;
28
+ let graceTimer;
29
+ let logging = true;
30
+ const readers = [];
31
+ function log(chunk) {
32
+ if (!logging) return;
33
+ const kept = Math.min(chunk.length, limits.logBytes - step.logBytes);
34
+ if (kept) writeSync(fd, chunk.subarray(0, kept));
35
+ step.logBytes += kept;
36
+ step.omittedBytes += chunk.length - kept;
37
+ }
38
+ async function drain(reader) {
39
+ try {
40
+ while (true) {
41
+ const { done, value } = await reader.read();
42
+ if (done) return;
43
+ log(value);
44
+ }
45
+ } catch (error) {
46
+ if (logging) { step.error = error.message; killGroup(proc); }
47
+ }
48
+ }
49
+ try {
50
+ proc = Bun.spawn(spawnArgv, { cwd, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: true });
51
+ state.active = proc;
52
+ readers.push(proc.stdout.getReader(), proc.stderr.getReader());
53
+ const drained = Promise.all(readers.map(drain));
54
+ const exited = proc.exited.then((code) => {
55
+ if (logging) step.exitCode = code;
56
+ clearTimeout(timer);
57
+ killGroup(proc);
58
+ });
59
+ const timeout = new Promise((resolve) => {
60
+ timer = setTimeout(() => { step.timedOut = true; killGroup(proc); resolve(); }, limits.timeoutMs);
61
+ });
62
+ await Promise.race([exited, timeout]);
63
+ const complete = await Promise.race([
64
+ Promise.all([exited, drained]).then(() => true),
65
+ new Promise((resolve) => { graceTimer = setTimeout(() => resolve(false), drainGraceMs); }),
66
+ ]);
67
+ step.logIncomplete = !complete;
68
+ } catch (error) {
69
+ step.error = error.message;
70
+ log(new TextEncoder().encode(error.message));
71
+ } finally {
72
+ clearTimeout(timer);
73
+ clearTimeout(graceTimer);
74
+ logging = false;
75
+ if (proc) killGroup(proc);
76
+ // Cancel without another EOF wait. Escaped writers may retain the other pipe end forever.
77
+ for (const reader of readers) reader.cancel().then(() => reader.releaseLock()).catch(() => {});
78
+ state.active = null;
79
+ closeSync(fd);
80
+ step.durationMs = Math.round(performance.now() - started);
81
+ writeFileSync(step.receiptPath, `${JSON.stringify(step, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
82
+ }
83
+ return step;
84
+ }
85
+
86
+ export async function executeDeclared(repo, revision, paths, criteria, manager, pkg, limits) {
87
+ const pending = groups.filter(([id]) => criteria.find((item) => item.id === id).reason === 'not run');
88
+ const commands = [];
89
+ const result = { commands, network: 'not isolated', descendants: 'not contained' };
90
+ if (!pending.length) return result;
91
+ mkdirSync(paths.evidence, { recursive: true, mode: 0o700 });
92
+ let scratch;
93
+ const state = { active: null, signal: null };
94
+ const interrupt = (signal) => { state.signal = signal; if (state.active) killGroup(state.active); };
95
+ const sigint = () => interrupt('SIGINT');
96
+ const sigterm = () => interrupt('SIGTERM');
97
+ process.on('SIGINT', sigint);
98
+ process.on('SIGTERM', sigterm);
99
+ try {
100
+ const home = realpathSync(process.env.HOME || '/root');
101
+ let temp = realpathSync(process.env.TMPDIR || '/tmp');
102
+ if (temp === home || temp.startsWith(`${home}/`)) temp = realpathSync('/tmp');
103
+ if (temp === home || temp.startsWith(`${home}/`)) throw new Error('no scratch directory outside HOME; set TMPDIR to an owned temp directory');
104
+ scratch = mkdtempSync(`${temp}/axstack-readiness-`);
105
+ chmodSync(scratch, 0o700);
106
+ if (lstatSync(scratch).uid !== process.getuid()) throw new Error('scratch directory is not owned');
107
+ result.scratch = scratch;
108
+ const env = { PATH: process.env.PATH || '/usr/bin:/bin', LANG: process.env.LANG || 'C.UTF-8', CI: '1', HOME: scratch, TMPDIR: scratch };
109
+ const cwd = `${scratch}/repo`;
110
+ const unshare = Bun.which('unshare');
111
+ const ip = Bun.which('ip');
112
+ const pidFlags = ['--pid', '--fork', '--kill-child'];
113
+ const contained = (argv) => [unshare, '-r', ...pidFlags, ...argv];
114
+ const isolated = (argv) => [unshare, '-rn', ...(result.descendants === 'contained' ? pidFlags : []), process.execPath, '-e', networkWrapper, ip, ...argv];
115
+ const run = async (kind, argv, directory = scratch, spawnArgv) => {
116
+ if (state.signal) throw new Error(`interrupted by ${state.signal}`);
117
+ const step = await runCommand(commands, paths, kind, argv, directory, env, limits, state, spawnArgv ?? (result.descendants === 'contained' ? contained(argv) : argv));
118
+ if (state.signal) throw new Error(`interrupted by ${state.signal}; see ${step.logPath}`);
119
+ return step;
120
+ };
121
+ if (unshare) {
122
+ const probe = await run('descendant-probe', contained(['/bin/true']));
123
+ if (probe.exitCode === 0 && !probe.timedOut && !probe.error) result.descendants = 'contained';
124
+ }
125
+ if (unshare && ip) {
126
+ const argv = isolated(['/bin/true']);
127
+ const probe = await run('network-probe', argv, scratch, argv);
128
+ if (probe.exitCode === 0 && !probe.timedOut && !probe.error) result.network = 'isolated';
129
+ }
130
+ const git = ['git', '--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false'];
131
+ const clone = await run('clone', [...git, 'clone', '--no-hardlinks', '--no-checkout', '--', realpathSync(repo), cwd], scratch);
132
+ if (clone.exitCode !== 0 || clone.timedOut) throw new Error(`clone failed; see ${clone.logPath}`);
133
+ const checkout = await run('checkout', [...git, '-C', cwd, 'checkout', '--detach', revision], scratch);
134
+ if (checkout.exitCode !== 0 || checkout.timedOut) throw new Error(`checkout failed; see ${checkout.logPath}`);
135
+ const install = await run('install', [manager.name, ...installs[manager.name]], cwd);
136
+ if (install.exitCode !== 0 || install.timedOut) {
137
+ for (const [id] of pending) Object.assign(criteria.find((item) => item.id === id), { reason: 'install failed', evidence: [install.logPath] });
138
+ return result;
139
+ }
140
+ for (const [id, names] of pending) {
141
+ const steps = [];
142
+ for (const name of names.filter((name) => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim())) {
143
+ const argv = [manager.name, ...(manager.name === 'pnpm' ? ['--config.enable-pre-post-scripts=false'] : []), 'run', name];
144
+ steps.push(await run('script', argv, cwd, result.network === 'isolated' ? isolated(argv) : undefined));
145
+ }
146
+ const passed = steps.every((step) => step.exitCode === 0 && !step.timedOut && !step.error);
147
+ Object.assign(criteria.find((item) => item.id === id), { status: passed ? 'pass' : 'fail', reason: steps.some((step) => step.timedOut) ? 'timeout' : passed ? '' : 'command failed', evidence: steps.map((step) => step.logPath) });
148
+ }
149
+ } catch (error) {
150
+ result.error = error.message;
151
+ for (const [id] of pending) Object.assign(criteria.find((item) => item.id === id), { status: 'unknown', reason: 'execution unavailable', evidence: [error.message] });
152
+ } finally {
153
+ process.off('SIGINT', sigint);
154
+ process.off('SIGTERM', sigterm);
155
+ if (scratch) {
156
+ try { rmSync(scratch, { recursive: true }); }
157
+ catch (error) { result.error = `scratch removal failed: ${scratch}: ${error.message}`; }
158
+ }
159
+ }
160
+ return result;
161
+ }
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env bun
2
+ import { readFileSync, mkdirSync, writeFileSync, lstatSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
3
+ import { executeDeclared } from './readiness-run.js';
4
+
5
+ const scriptVersion = '1.1.0';
6
+ const criteriaVersion = 1;
7
+
8
+ function gitOutput(repo, args, stdin = 'ignore') {
9
+ const argv = ['git', '--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', repo, ...args];
10
+ const result = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', stdin });
11
+ if (result.exitCode !== 0) throw new Error(result.stderr.toString().trim());
12
+ return result.stdout;
13
+ }
14
+
15
+ function git(repo, ...args) {
16
+ return gitOutput(repo, args).toString();
17
+ }
18
+
19
+ function readTextBlobs(repo, ids) {
20
+ const unique = [...new Set(ids)];
21
+ const text = new Map();
22
+ if (!unique.length) return text;
23
+ const data = gitOutput(repo, ['cat-file', '--batch'], new TextEncoder().encode(`${unique.join('\n')}\n`));
24
+ let offset = 0;
25
+ for (const oid of unique) {
26
+ const end = data.indexOf(10, offset);
27
+ const header = end < 0 ? null : /^([0-9a-f]+) blob (\d+)$/.exec(data.subarray(offset, end).toString());
28
+ const size = Number(header?.[2]);
29
+ const start = end + 1;
30
+ if (header?.[1] !== oid || !Number.isSafeInteger(size) || size < 0 || data[start + size] !== 10) throw new Error('incomplete git blob response');
31
+ const blob = data.subarray(start, start + size);
32
+ offset = start + size + 1;
33
+ if (blob.includes(0)) continue;
34
+ try { text.set(oid, new TextDecoder('utf-8', { fatal: true }).decode(blob)); }
35
+ catch { /* Binary content is not configuration or prose. */ }
36
+ }
37
+ if (offset !== data.length) throw new Error('unexpected git blob response');
38
+ return text;
39
+ }
40
+
41
+ function normalizeOrigin(origin, repo) {
42
+ if (!origin) return '';
43
+ const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(origin);
44
+ let address = origin;
45
+ if (!origin.includes('://')) {
46
+ address = scp ? `ssh://${scp[1]}/${scp[2]}` : `file://${absolute(origin.startsWith('/') ? origin : `${repo}/${origin}`).split('/').map(encodeURIComponent).join('/')}`;
47
+ }
48
+ const url = new URL(address);
49
+ let path = url.pathname.replace(/\/+$/, '').replace(/\.git$/, '');
50
+ if (url.hostname.toLowerCase() === 'github.com') path = path.toLowerCase();
51
+ const host = url.hostname.toLowerCase() === 'github.com' ? 'github.com' : url.host.toLowerCase();
52
+ return `${url.protocol === 'file:' ? 'file' : 'https'}://${host}${path}`;
53
+ }
54
+
55
+ function snapshot(repo, revision) {
56
+ const entries = new Map(git(repo, 'ls-tree', '-rz', '--full-tree', revision).split('\0').filter(Boolean).map((entry) => {
57
+ const tab = entry.indexOf('\t');
58
+ const [mode, type, oid] = entry.slice(0, tab).split(' ');
59
+ return [entry.slice(tab + 1), { mode, type, oid }];
60
+ }));
61
+ const ids = [...entries].filter(([path, entry]) => entry.type === 'blob' && (
62
+ /\.md$/i.test(path) || ['package.json', 'tsconfig.json', 'yarn.lock'].includes(path) || /^\.github\/workflows\/[^/]+\.ya?ml$/.test(path)
63
+ )).map(([, entry]) => entry.oid);
64
+ let text;
65
+ return {
66
+ revision,
67
+ paths: [...entries.keys()],
68
+ has: (path) => entries.get(path)?.type === 'blob',
69
+ mode: (path) => entries.get(path)?.mode,
70
+ read(path) {
71
+ if (entries.get(path)?.type !== 'blob') return '';
72
+ text ??= readTextBlobs(repo, ids);
73
+ return text.get(entries.get(path).oid) ?? '';
74
+ },
75
+ };
76
+ }
77
+
78
+ // tsconfig permits comments and trailing commas; preserve strings while removing them.
79
+ function jsonc(text) {
80
+ const stripped = text.replace(/"(?:\\.|[^"\\])*"|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, (token) => token.startsWith('"') ? token : '');
81
+ return JSON.parse(stripped.replace(/"(?:\\.|[^"\\])*"|,\s*(?=[}\]])/g, (token) => token.startsWith('"') ? token : ''));
82
+ }
83
+
84
+ const definitions = [
85
+ ['docs.agents', 'Docs'], ['docs.claude', 'Docs'],
86
+ ['style.linter', 'Style'], ['style.formatter', 'Style'], ['style.strict', 'Style'], ['style.pre-commit', 'Style'],
87
+ ['env.lockfile', 'Build/Env'], ['env.toolchain', 'Build/Env'], ['env.build', 'Build/Env', 'executed'],
88
+ ['testing.files', 'Testing'], ['testing.test', 'Testing', 'executed'], ['testing.lint-typecheck', 'Testing', 'executed'], ['testing.verify-skill', 'Testing'],
89
+ ['security.pr-tests', 'Governance/Security'], ['security.status-checks', 'Governance/Security'], ['security.updates', 'Governance/Security'], ['security.secrets', 'Governance/Security'],
90
+ ];
91
+
92
+ function packageManager(files) {
93
+ const locks = [
94
+ ['bun', ['bun.lock', 'bun.lockb']], ['npm', ['package-lock.json', 'npm-shrinkwrap.json']],
95
+ ['pnpm', ['pnpm-lock.yaml']], ['yarn', ['yarn.lock']],
96
+ ].filter(([, names]) => names.some(files.has));
97
+ if (!locks.length) return { reason: 'no lockfile' };
98
+ if (locks.length > 1) return { reason: 'ambiguous lockfile' };
99
+ const [name, paths] = locks[0];
100
+ if (name === 'yarn' && !/^__metadata:/m.test(files.read('yarn.lock'))) return { reason: 'unsupported package manager' };
101
+ return { name, paths: paths.filter(files.has) };
102
+ }
103
+
104
+ function declaredCommands(files, pkg, manager, set, invalidPackage) {
105
+ const prose = files.paths.filter((path) => /\.md$/i.test(path)).map((path) => files.read(path)).join('\n');
106
+ for (const [id, names] of [['env.build', ['build']], ['testing.test', ['test']], ['testing.lint-typecheck', ['lint', 'typecheck']]]) {
107
+ if (invalidPackage) {
108
+ set(id, 'unknown', [`git show ${files.revision}:package.json`], 'invalid package.json');
109
+ continue;
110
+ }
111
+ const present = names.filter((name) => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim());
112
+ const evidence = present.map((name) => `git show ${files.revision}:package.json: scripts.${name}`);
113
+ if (!present.length) {
114
+ const mentioned = names.some((name) => new RegExp(`\\b(?:bun|npm|pnpm|yarn)\\s+(?:run\\s+)?${name}\\b`).test(prose));
115
+ const reason = `missing ${names.join(' and ')} script${names.length > 1 ? 's' : ''}${mentioned ? '; prose-declared; not run' : ''}`;
116
+ set(id, id === 'env.build' ? 'n/a' : 'fail', ['committed package.json script declarations'], reason);
117
+ continue;
118
+ }
119
+ if (manager.reason) set(id, 'unknown', evidence.concat(['committed lockfile selection']), manager.reason);
120
+ else set(id, 'unknown', evidence.concat(manager.paths, present.map((name) => `${manager.name} run ${name} (not executed)`)), 'not run');
121
+ }
122
+ }
123
+
124
+ function assess(files) {
125
+ const revision = files.revision;
126
+ const has = (pattern) => files.paths.filter((path) => files.has(path) && pattern.test(path));
127
+ const js = files.has('package.json') || has(/\.[cm]?[jt]sx?$/).length > 0;
128
+ let pkg = {};
129
+ let invalidPackage = false;
130
+ try {
131
+ pkg = JSON.parse(files.read('package.json') || '{}');
132
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) throw new Error('invalid package.json');
133
+ } catch { pkg = {}; invalidPackage = true; }
134
+ const criteria = definitions.map(([id, pillar, kind = 'configured']) => ({ id, pillar, kind, status: 'unknown', evidence: [`git ls-tree -r ${revision}`], reason: js ? 'not run' : 'unsupported stack' }));
135
+ const set = (id, status, evidence, reason = '') => Object.assign(criteria.find((item) => item.id === id), { status, evidence, reason });
136
+ const configured = (id, matches) => set(id, matches.length ? 'pass' : 'fail', matches.length ? matches.map((path) => `git show ${revision}:${path}`) : [`git ls-tree -r ${revision}: no matching configuration`], matches.length ? '' : 'missing configuration');
137
+ if (!js) return criteria;
138
+ configured('docs.agents', files.has('AGENTS.md') ? ['AGENTS.md'] : []);
139
+ const claude = files.read('CLAUDE.md');
140
+ const linked = files.mode('CLAUDE.md') === '120000' ? /^(\.\/)?AGENTS\.md$/.test(claude.trim()) : /@AGENTS\.md\b/.test(claude);
141
+ configured('docs.claude', linked ? ['CLAUDE.md'] : []);
142
+ configured('style.linter', has(/(^|\/)(eslint\.config\.[cm]?[jt]s|\.eslintrc(?:\.[^/]+)?|biome\.jsonc?|oxlint\.json)$/).concat(pkg.eslintConfig ? ['package.json'] : []));
143
+ configured('style.formatter', has(/(^|\/)(\.prettierrc(?:\.[^/]+)?|prettier\.config\.[cm]?[jt]s|biome\.jsonc?|\.dprint\.json|dprint\.json)$/).concat(pkg.prettier ? ['package.json'] : []));
144
+ if (!files.has('tsconfig.json') && !has(/\.[cm]?tsx?$/).length && !pkg.dependencies?.typescript && !pkg.devDependencies?.typescript) {
145
+ set('style.strict', 'n/a', [`git ls-tree -r ${revision}: no TypeScript`], 'no TypeScript');
146
+ } else {
147
+ try {
148
+ set('style.strict', jsonc(files.read('tsconfig.json') || '{}').compilerOptions?.strict === true ? 'pass' : 'fail', [`git show ${revision}:tsconfig.json`], '');
149
+ } catch { set('style.strict', 'unknown', [`git show ${revision}:tsconfig.json`], 'invalid tsconfig'); }
150
+ }
151
+ configured('style.pre-commit', has(/(^|\/)(\.pre-commit-config\.ya?ml|lefthook\.ya?ml|\.lefthook\.ya?ml|\.husky\/pre-commit)$/).concat(pkg.husky?.hooks?.['pre-commit'] ? ['package.json'] : []));
152
+ configured('env.lockfile', has(/^(bun\.lockb?|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock)$/));
153
+ const manager = packageManager(files);
154
+ declaredCommands(files, pkg, manager, set, invalidPackage);
155
+ configured('env.toolchain', has(/^(\.tool-versions|mise\.toml|\.mise\.toml|\.nvmrc|\.devcontainer\.json|\.devcontainer\/(devcontainer\.json|[^/]+\/devcontainer\.json))$/).concat(pkg.engines && Object.keys(pkg.engines).length ? ['package.json'] : []));
156
+ configured('testing.files', has(/(^|\/)((tests?|__tests__)\/.*\.[cm]?[jt]sx?|[^/]+\.(test|spec)\.[cm]?[jt]sx?)$/));
157
+ configured('testing.verify-skill', has(/^\.agents\/skills\/verify-[^/]+\/SKILL\.md$/));
158
+ const workflows = has(/^\.github\/workflows\/[^/]+\.ya?ml$/);
159
+ let prTests = false;
160
+ let prCandidate = false;
161
+ let invalid = false;
162
+ const codeql = [];
163
+ for (const path of workflows) {
164
+ try {
165
+ const workflow = Bun.YAML.parse(files.read(path));
166
+ const trigger = workflow?.on;
167
+ const pr = trigger === 'pull_request' || (Array.isArray(trigger) ? trigger.includes('pull_request') : trigger && Object.hasOwn(trigger, 'pull_request'));
168
+ const steps = Object.values(workflow?.jobs ?? {}).flatMap((job) => job?.steps ?? []);
169
+ const runs = steps.map((step) => step?.run).filter((run) => typeof run === 'string');
170
+ if (steps.some((step) => typeof step?.uses === 'string' && /^github\/codeql-action\//.test(step.uses))) codeql.push(path);
171
+ const invokes = (run, name) => new RegExp(`(?:^|[\\n;&|])\\s*${name}\\s+${name === 'bun' ? 'run\\s+' : '(?:run\\s+)?'}test(?:\\s|[;&|]|$)`).test(run);
172
+ if (pr && runs.some((run) => ['bun', 'npm', 'pnpm', 'yarn'].some((name) => invokes(run, name)))) prCandidate = true;
173
+ if (pr && manager.name && runs.some((run) => invokes(run, manager.name))) prTests = true;
174
+ } catch { invalid = true; }
175
+ }
176
+ const workflowReason = prTests ? '' : invalid ? 'invalid workflow YAML' : prCandidate && manager.reason ? manager.reason : 'missing pull_request test step';
177
+ set('security.pr-tests', prTests ? 'pass' : invalid || (prCandidate && manager.reason) ? 'unknown' : 'fail', workflows.length ? workflows.map((path) => `git show ${revision}:${path}`) : [`git ls-tree -r ${revision}: no workflows`], workflowReason);
178
+ configured('security.updates', has(/(^|\/)(\.github\/(dependabot\.ya?ml|codeql\/[^/]+\.ya?ml)|(?:\.?)renovaterc(?:\.json5?)?|renovate\.json5?)$/).concat(codeql));
179
+ const secrets = has(/(^|\/)(\.env[^/]*|id_(rsa|dsa|ecdsa|ed25519)|[^/]+\.(pem|key|p12|pfx))$/).filter((path) => !/(^|\/)\.env\.(example|sample|template)$/.test(path));
180
+ set('security.secrets', secrets.length ? 'fail' : 'pass', secrets.length ? [...new Set(secrets)].map((path) => `git show ${revision}:${path}`) : [`git ls-tree -r ${revision}: no tracked env or private keys`], secrets.length ? 'tracked env or private keys' : '');
181
+ return criteria;
182
+ }
183
+
184
+ export function summarizePillars(criteria) {
185
+ const result = {};
186
+ for (const item of criteria) {
187
+ const pillar = result[item.pillar] ??= { passes: 0, total: 0, unknown: 0, na: 0 };
188
+ if (item.status === 'n/a') pillar.na++;
189
+ else pillar.total++;
190
+ if (item.status === 'pass') pillar.passes++;
191
+ if (item.status === 'unknown') pillar.unknown++;
192
+ }
193
+ for (const pillar of Object.values(result)) pillar.fraction = `${pillar.passes}/${pillar.total}`;
194
+ return result;
195
+ }
196
+
197
+ function forgeStatus(origin) {
198
+ const match = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)$/.exec(origin);
199
+ const evidence = [];
200
+ const result = (status, reason) => ({ status, reason, evidence: evidence.length ? evidence : ['origin remote'] });
201
+ if (!match) return result('unknown', 'no GitHub origin');
202
+ function api(endpoint) {
203
+ const argv = [process.env.GH || 'gh', 'api', '--hostname', 'github.com', '--method', 'GET', '--paginate', '--slurp', endpoint];
204
+ evidence.push(`gh api --method GET --paginate --slurp ${endpoint}`);
205
+ const response = Bun.spawnSync(argv, { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
206
+ if (response.exitCode !== 0) throw new Error(response.stderr.toString().trim());
207
+ const pages = JSON.parse(response.stdout.toString());
208
+ if (!Array.isArray(pages) || !pages.length) throw new Error('incomplete gh response');
209
+ return pages;
210
+ }
211
+ try {
212
+ const metadata = api(`repos/${match[1]}`);
213
+ if (metadata.length !== 1 || typeof metadata[0]?.default_branch !== 'string' || !metadata[0].default_branch) throw new Error('missing default branch');
214
+ const branch = encodeURIComponent(metadata[0].default_branch);
215
+ let protectionGap = false;
216
+ try {
217
+ const protection = api(`repos/${match[1]}/branches/${branch}/protection`);
218
+ if (protection.length !== 1 || !protection[0] || Array.isArray(protection[0])) throw new Error('incomplete protection response');
219
+ const required = protection[0].required_status_checks;
220
+ const checks = [...(required?.contexts ?? []), ...(required?.checks ?? []).map((check) => check.context)];
221
+ if (checks.some((name) => typeof name === 'string' && name.length)) {
222
+ evidence.push(`default branch ${metadata[0].default_branch}: required checks ${checks.join(', ')}`);
223
+ return result('pass', '');
224
+ }
225
+ } catch (error) {
226
+ if (!/\bHTTP (403|404)\b/.test(error.message)) throw error;
227
+ protectionGap = !/Branch not protected.*(?:404)|(?:404).*Branch not protected/i.test(error.message);
228
+ evidence.push(error.message);
229
+ }
230
+ const pages = api(`repos/${match[1]}/rules/branches/${branch}?per_page=100`);
231
+ if (pages.some((page) => !Array.isArray(page))) throw new Error('incomplete branch rules response');
232
+ const required = pages.flat().filter((rule) => rule?.type === 'required_status_checks').flatMap((rule) => rule.parameters?.required_status_checks ?? []);
233
+ if (required.some((check) => typeof check?.context === 'string' && check.context.length)) {
234
+ evidence.push(`default branch ${metadata[0].default_branch}: active rules require ${required.map((check) => check.context).join(', ')}`);
235
+ return result('pass', '');
236
+ }
237
+ return result(protectionGap ? 'unknown' : 'fail', protectionGap ? 'gh settings unavailable' : 'needs admin');
238
+ } catch (error) {
239
+ evidence.push(error.message);
240
+ return result('unknown', 'gh settings unavailable');
241
+ }
242
+ }
243
+
244
+ function dirty(repo) {
245
+ // Status normally invokes clean/process filters. Observe raw source bytes instead.
246
+ const keys = git(repo, 'config', '--null', '--list').split('\0').map((entry) => entry.split('\n')[0]).filter((key) => /^filter\..*\.(clean|process)$/.test(key));
247
+ const overrides = keys.flatMap((key) => ['-c', `${key}=`, '-c', `${key.replace(/\.(clean|process)$/, '.required')}=false`]);
248
+ return git(repo, ...overrides, 'status', '--porcelain', '--untracked-files=all').length > 0;
249
+ }
250
+
251
+ async function report(args, paths) {
252
+ const repo = args['--repo'];
253
+ let revision;
254
+ try { revision = git(repo, 'rev-parse', '--verify', '--end-of-options', `${args['--rev'] ?? 'HEAD'}^{commit}`).trim(); }
255
+ catch (error) { throw new Error(`${error.message}; pass --rev <existing commit or ref>`); }
256
+ let rawOrigin = '';
257
+ try { rawOrigin = git(repo, 'remote', 'get-url', 'origin').trim(); } catch { /* No origin. */ }
258
+ const origin = normalizeOrigin(rawOrigin, repo);
259
+ const header = {
260
+ inputs: { repo: realpathSync(repo), rev: args['--rev'] ?? 'HEAD', baseline: args['--baseline'] ?? null, run: Boolean(args['--run']) },
261
+ repoIdentity: { roots: git(repo, 'rev-list', '--max-parents=0', revision).trim().split('\n').sort(), origin },
262
+ revision, dirty: dirty(repo),
263
+ observedAt: new Date().toISOString(), ghObservedAt: new Date().toISOString(), scriptVersion, criteriaVersion,
264
+ };
265
+ const files = snapshot(repo, revision);
266
+ const criteria = assess(files);
267
+ let execution;
268
+ if (args['--run']) {
269
+ let pkg = {};
270
+ try { pkg = JSON.parse(files.read('package.json') || '{}'); } catch { /* Static criteria already record invalid JSON. */ }
271
+ execution = await executeDeclared(repo, revision, paths, criteria, packageManager(files), pkg, {
272
+ timeoutMs: Number(args['--timeout-ms'] ?? 120000), logBytes: Number(args['--log-bytes'] ?? 1048576),
273
+ });
274
+ header.network = execution.network;
275
+ header.descendants = execution.descendants;
276
+ header.scratch = execution.scratch;
277
+ }
278
+ header.ghObservedAt = new Date().toISOString();
279
+ const settings = criteria.find((item) => item.id === 'security.status-checks');
280
+ if (settings.reason !== 'unsupported stack') Object.assign(settings, forgeStatus(origin));
281
+ return { header, criteria, pillars: summarizePillars(criteria), ...(execution && { commands: execution.commands, executionError: execution.error }) };
282
+ }
283
+
284
+ function absolute(path) {
285
+ const parts = [];
286
+ for (const part of (path.startsWith('/') ? path : `${process.cwd()}/${path}`).split('/')) {
287
+ if (part === '..') parts.pop();
288
+ else if (part && part !== '.') parts.push(part);
289
+ }
290
+ return `/${parts.join('/')}`;
291
+ }
292
+
293
+ function rejectSymlinks(path) {
294
+ let current = path.startsWith('/') ? '' : process.cwd();
295
+ for (const part of path.split('/').filter(Boolean)) {
296
+ current += `/${part}`;
297
+ try {
298
+ if (lstatSync(current).isSymbolicLink()) throw new Error(`--out symlink rejected: ${current}`);
299
+ } catch (error) {
300
+ if (error.code !== 'ENOENT') throw error;
301
+ }
302
+ }
303
+ }
304
+
305
+ function outputPaths(args) {
306
+ let common;
307
+ try { common = realpathSync(git(args['--repo'], 'rev-parse', '--path-format=absolute', '--git-common-dir').trim()); }
308
+ catch (error) { throw new Error(`${error.message}; pass --repo <existing Git checkout>`); }
309
+ const out = absolute(args['--out']);
310
+ if (!args['--out'].endsWith('.json')) throw new Error('--out must end in .json');
311
+ if (!out.startsWith(`${common}/axstack/readiness/`)) throw new Error('--out must be under <git-common-dir>/axstack/readiness/');
312
+ rejectSymlinks(args['--out']);
313
+ const evidence = out.slice(0, -5);
314
+ rejectSymlinks(evidence);
315
+ return { out, evidence };
316
+ }
317
+
318
+ function writeReport(paths, report) {
319
+ // Stage inside the allowed evidence folder; replacing the inode also avoids hardlink writes.
320
+ mkdirSync(paths.evidence, { recursive: true, mode: 0o700 });
321
+ const staged = `${paths.evidence}/.${crypto.randomUUID()}.report.json`;
322
+ writeFileSync(staged, `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
323
+ try { renameSync(staged, paths.out); }
324
+ catch (error) {
325
+ unlinkSync(staged);
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ function compareBaseline(report, path, io) {
331
+ if (!path) return 0;
332
+ try {
333
+ const before = JSON.parse(readFileSync(path, 'utf8'));
334
+ if (before.header?.criteriaVersion !== report.header.criteriaVersion) throw new Error('criteria version mismatch');
335
+ const identity = before.header?.repoIdentity;
336
+ if (!identity || identity.origin !== report.header.repoIdentity.origin || JSON.stringify(identity.roots) !== JSON.stringify(report.header.repoIdentity.roots)) throw new Error('foreign repository');
337
+ if (!Array.isArray(before.criteria) || before.criteria.some((item) => !item || typeof item.id !== 'string' || !['pass', 'fail', 'unknown', 'n/a'].includes(item.status))) throw new Error('invalid baseline criteria');
338
+ const previous = new Map(before.criteria.map((item) => [item.id, item.status]));
339
+ if (previous.size !== before.criteria.length) throw new Error('duplicate baseline criterion');
340
+ const current = new Map(report.criteria.map((item) => [item.id, item.status]));
341
+ const changes = [...new Set([...previous.keys(), ...current.keys()])].sort().filter((id) => previous.get(id) !== current.get(id)).map((id) => ({ id, before: previous.get(id) ?? null, after: current.get(id) ?? null }));
342
+ const regressions = changes.filter((item) => item.before === 'pass' && item.after === 'fail').map((item) => item.id);
343
+ report.comparison = { changes, regressions };
344
+ if (regressions.length) io.stderr(`Readiness regressions: ${regressions.join(', ')}\n`);
345
+ return regressions.length ? 10 : 0;
346
+ } catch (error) {
347
+ report.comparisonUnavailable = `comparison unavailable: ${error.message}`;
348
+ io.stderr(`${report.comparisonUnavailable}\n`);
349
+ return 2;
350
+ }
351
+ }
352
+
353
+ const help = `Usage: bun readiness.js --repo <path> [--rev <ref>] [--baseline <report.json>] [--run] --out <path>
354
+ Assessment of committed JavaScript/TypeScript repository files.
355
+ Flags: --repo, --rev (default HEAD), --baseline, --run, --out, --help,
356
+ --timeout-ms (positive integer; default 120000), --log-bytes (positive integer; default 1048576).
357
+ Writes only --out and its evidence folder under <git-common-dir>/axstack/readiness/.
358
+ --out must end in .json; symlinks are rejected. Its evidence folder omits that suffix.
359
+ --run also creates an owned 0700 scratch clone outside HOME in TMPDIR (fallback /tmp),
360
+ installs dependencies there and runs declared build/test/lint/typecheck scripts.
361
+ Bun and npm also run implicit pre/post scripts for those declared names.
362
+ pnpm disables declared-name pre/post hooks with --config.enable-pre-post-scripts=false;
363
+ Yarn Berry has no implicit declared-name hooks. Install lifecycle scripts still run.
364
+ Removes scratch afterwards; capped logs and argv/exit/duration JSON receipts are
365
+ written in the evidence folder next to --out. Every command gets closed stdin,
366
+ the PATH/LANG/CI/HOME/TMPDIR allowlist, its own timeout and process-group cleanup.
367
+ Only the frozen install permits network when unshare -rn plus loopback-up probe
368
+ succeeds; otherwise the header says network: not isolated. Limits apply to all steps.
369
+ Each step uses a probed PID namespace with --kill-child when available; otherwise
370
+ the header says descendants: not contained and escaped processes may survive.
371
+ 200 ms drain grace after exit or timeout bounds pipe waits; incomplete logs are recorded.
372
+ SIGINT and SIGTERM terminate the active group and clean scratch before returning.
373
+ SIGKILL cannot trigger cleanup; scratch and active processes may remain.
374
+ Repository code can write outside scratch; --run is not a sandbox.
375
+ Without --run no repository commands execute. GH selects the read-only gh executable.
376
+ Dirty observation disables Git filters and filesystem-monitor hooks.
377
+ Example: bun readiness.js --repo /repo --run --out /repo/.git/axstack/readiness/check.json
378
+ Exit codes: 0 report (including failed criteria), 1 error, 2 incomplete, 10 regression.`;
379
+
380
+ export async function main(argv, io = { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text) }) {
381
+ try {
382
+ if (argv.includes('--help')) {
383
+ io.stdout(`${help}\n`);
384
+ return 0;
385
+ }
386
+ const args = {};
387
+ for (let i = 0; i < argv.length; i++) {
388
+ const flag = argv[i];
389
+ if (flag === '--run' && !args[flag]) { args[flag] = true; continue; }
390
+ if (!['--repo', '--rev', '--baseline', '--out', '--timeout-ms', '--log-bytes'].includes(flag) || !argv[i + 1] || args[flag]) {
391
+ throw new Error(`invalid argument: ${flag}; use --help for flags and an example`);
392
+ }
393
+ args[flag] = argv[++i];
394
+ }
395
+ if (!args['--repo'] || !args['--out']) throw new Error('missing inputs; pass --repo <path> and --out <git-common-dir>/axstack/readiness/<name>.json');
396
+ for (const flag of ['--timeout-ms', '--log-bytes']) {
397
+ if (args[flag] && (!/^\d+$/.test(args[flag]) || !Number.isSafeInteger(Number(args[flag])) || Number(args[flag]) < 1 || Number(args[flag]) > 2147483647)) throw new Error(`invalid ${flag}; pass a positive integer up to 2147483647`);
398
+ }
399
+ const paths = outputPaths(args);
400
+ const result = await report(args, paths);
401
+ const code = compareBaseline(result, args['--baseline'], io);
402
+ writeReport(paths, result);
403
+ const counts = Object.fromEntries(['pass', 'fail', 'unknown', 'n/a'].map((status) => [status, result.criteria.filter((item) => item.status === status).length]));
404
+ io.stdout(`${JSON.stringify(args['--run'] ? {
405
+ revision: result.header.revision, inputs: result.header.inputs, network: result.header.network, descendants: result.header.descendants, executionError: result.executionError,
406
+ reportPath: paths.out, counts, pillars: result.pillars, omittedCriteria: result.criteria.length, omittedCommands: result.commands.length,
407
+ omittedLogBytes: result.commands.reduce((total, step) => total + step.omittedBytes, 0),
408
+ incompleteLogs: result.commands.filter((step) => step.logIncomplete).length,
409
+ } : result)}\n`);
410
+ return code || (result.executionError ? 2 : 0);
411
+ } catch (error) {
412
+ io.stderr(`Readiness error: ${error.message}\n`);
413
+ return 1;
414
+ }
415
+ }
416
+
417
+ if (import.meta.main) {
418
+ process.exitCode = await main(process.argv.slice(2));
419
+ }