@codedology/code-forge 0.1.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.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The journaled red→green runner (plan §7.2, O13, O35). A product mechanism run by the CLI on a
|
|
3
|
+
* consumer's tree — in the block's measurement export (`isolation: export`, B10a) or, under the
|
|
4
|
+
* proof lock, in the shared tree (`isolation: lock`). Never used on code-forge's own build (R14).
|
|
5
|
+
*
|
|
6
|
+
* One call proves one test (`file`, optionally `::case` for the label; the caller's `argv` is the
|
|
7
|
+
* test command already filtered to it):
|
|
8
|
+
*
|
|
9
|
+
* 1. refuse when a journal exists in the work dir (an earlier run was interrupted: `proof restore`);
|
|
10
|
+
* 2. snapshot the files the red step will change — bytes (base64), sha256, mode, or "absent" —
|
|
11
|
+
* into `<workDir>/.code-forge/proof-journal.json`, written atomically BEFORE any change;
|
|
12
|
+
* 3. write the red state:
|
|
13
|
+
* mechanism `revert` — every non-test source file at its base version (read from the repo
|
|
14
|
+
* with a temporary index + `checkout-index`, never `git stash`/`checkout --`; binary-safe);
|
|
15
|
+
* a file absent at base is removed;
|
|
16
|
+
* mechanism `assertion-deletion` — for a `characterization` test (behaviour that already
|
|
17
|
+
* exists at base) or a test with no source change: every single-line assertion statement
|
|
18
|
+
* of the test file is deleted and replaced by a forced failure of the same assertion
|
|
19
|
+
* library (`assert.fail(…)` / `$this->fail(…)`). RED then proves the test's assertions are
|
|
20
|
+
* reached by the filtered run and decide its outcome; a test whose assertions never run
|
|
21
|
+
* stays green and proves nothing;
|
|
22
|
+
* 4. run `argv` and classify the output with `red-parse` (RED only on an assertion failure);
|
|
23
|
+
* 5. restore every journaled file, verify each sha256, delete the journal — in a `finally`, so an
|
|
24
|
+
* ordinary error of the red step (a write error, a command that does not start) restores too;
|
|
25
|
+
* 6. when the red step was `RED`, run `argv` again: exit 0 ⇒ `GREEN`.
|
|
26
|
+
*
|
|
27
|
+
* Signals: while the test child runs, `exec` forwards SIGINT/SIGTERM/SIGHUP to its process group
|
|
28
|
+
* and the runner dies of the signal. The journal is left in place on purpose: restoring from a
|
|
29
|
+
* signal handler could itself be interrupted, so the one recovery path is `proof restore`, which
|
|
30
|
+
* verifies every hash. A SIGKILL leaves the same journal.
|
|
31
|
+
*
|
|
32
|
+
* Prints one line per step (`RED <label>` · `GREEN <label>` · `RED_INVALID <label> (<kind>)` ·
|
|
33
|
+
* `NOT_RED <label>` · `NOT_GREEN <label>`) and, when `record` is given, writes a signed `proof`
|
|
34
|
+
* row through B10a/B6 with `mechanism` and `red_kind`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { createHash } from 'node:crypto';
|
|
38
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { StateError } from '../state/paths.mjs';
|
|
41
|
+
import { exec } from '../util/exec.mjs';
|
|
42
|
+
import { revParse } from '../util/git.mjs';
|
|
43
|
+
import { currentRunRoot } from '../util/tmp.mjs';
|
|
44
|
+
import { ISOLATIONS, recordProof } from './export.mjs';
|
|
45
|
+
import { parseRed } from './red-parse.mjs';
|
|
46
|
+
|
|
47
|
+
/** Where the journal lives, relative to the work dir. */
|
|
48
|
+
export const JOURNAL_REL = path.join('.code-forge', 'proof-journal.json');
|
|
49
|
+
export const MECHANISMS = Object.freeze(/** @type {const} */ (['revert', 'assertion-deletion']));
|
|
50
|
+
|
|
51
|
+
const GIT_TIMEOUT_MS = 120000;
|
|
52
|
+
const DEFAULT_TEST_TIMEOUT_MS = 600000;
|
|
53
|
+
const JOURNAL_VERSION = 1;
|
|
54
|
+
const DELETED_MARKER = 'code-forge: assertion deleted';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {{path: string, existed: false} | {path: string, existed: true, sha256: string, mode: number, content: string}} JournalEntry
|
|
58
|
+
* @typedef {{version: number, pid: number, created_at: string, mechanism: string, block?: string, test: string, files: JournalEntry[]}} Journal
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** @param {Buffer} buf @returns {string} */
|
|
62
|
+
export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
63
|
+
|
|
64
|
+
/** @param {string} workDir @returns {string} */
|
|
65
|
+
export const journalPath = (workDir) => path.join(workDir, JOURNAL_REL);
|
|
66
|
+
|
|
67
|
+
/** `process.env` minus every `GIT_*` (a hook's `GIT_DIR` must not redirect us), plus `extra`. */
|
|
68
|
+
function gitEnv(/** @type {Record<string, string>} */ extra = {}) {
|
|
69
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
70
|
+
const env = {};
|
|
71
|
+
for (const [key, value] of Object.entries(process.env)) if (!key.startsWith('GIT_')) env[key] = value;
|
|
72
|
+
return { ...env, ...extra };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string[]} args @param {string} cwd @param {{input?: string, env?: Record<string, string>}} [opts] */
|
|
76
|
+
async function git(args, cwd, opts = {}) {
|
|
77
|
+
const res = await exec(['git', ...args], { cwd, env: gitEnv(opts.env), input: opts.input, timeoutMs: GIT_TIMEOUT_MS });
|
|
78
|
+
if (res.result !== 'ok') throw new StateError('git-failed', `git ${args[0]} failed (exit ${res.code})`);
|
|
79
|
+
return res.stdout;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A repo-relative POSIX path with no `.`/`..`/empty segment, never under `.code-forge/`.
|
|
84
|
+
* @param {unknown} p @param {string} key @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function assertRelPath(p, key) {
|
|
87
|
+
if (typeof p !== 'string' || p.length === 0 || path.isAbsolute(p) || p.includes('\\') || p.includes('\0')) {
|
|
88
|
+
throw new StateError('bad-path', `${key}: every entry must be a repo-relative POSIX path`);
|
|
89
|
+
}
|
|
90
|
+
const segments = p.split('/');
|
|
91
|
+
if (segments.some((s) => s === '' || s === '.' || s === '..')) throw new StateError('bad-path', `${key}: no ".", ".." or empty segments`);
|
|
92
|
+
if (segments[0] === '.code-forge') throw new StateError('bad-path', `${key}: nothing under .code-forge/`);
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {string} p @returns {Promise<import('node:fs').Stats | null>} */
|
|
97
|
+
async function lstatOrNull(p) {
|
|
98
|
+
try {
|
|
99
|
+
return await lstat(p);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return null;
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {string} workDir @returns {Promise<Journal | null>} */
|
|
107
|
+
export async function readJournal(workDir) {
|
|
108
|
+
let text;
|
|
109
|
+
try {
|
|
110
|
+
text = await readFile(journalPath(workDir), 'utf8');
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return null;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
const journal = JSON.parse(text);
|
|
116
|
+
if (journal?.version !== JOURNAL_VERSION || !Array.isArray(journal.files)) throw new StateError('bad-journal', `${JOURNAL_REL} is not a version ${JOURNAL_VERSION} journal`);
|
|
117
|
+
for (const entry of journal.files) assertRelPath(entry?.path, 'journal');
|
|
118
|
+
return journal;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Snapshot `files` (bytes + hash + mode, or absent) and write the journal atomically.
|
|
123
|
+
* @param {string} workDir @param {string[]} files @param {Omit<Journal, 'version' | 'pid' | 'created_at' | 'files'>} meta
|
|
124
|
+
* @returns {Promise<Journal>}
|
|
125
|
+
*/
|
|
126
|
+
async function writeJournal(workDir, files, meta) {
|
|
127
|
+
/** @type {JournalEntry[]} */
|
|
128
|
+
const entries = [];
|
|
129
|
+
for (const rel of files) {
|
|
130
|
+
const abs = path.join(workDir, rel);
|
|
131
|
+
const st = await lstatOrNull(abs);
|
|
132
|
+
if (st === null) {
|
|
133
|
+
entries.push({ path: rel, existed: false });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!st.isFile()) throw new StateError('bad-path', `red-green: ${rel} is not a regular file`);
|
|
137
|
+
const bytes = await readFile(abs);
|
|
138
|
+
entries.push({ path: rel, existed: true, sha256: sha256(bytes), mode: st.mode & 0o777, content: bytes.toString('base64') });
|
|
139
|
+
}
|
|
140
|
+
/** @type {Journal} */
|
|
141
|
+
const journal = { version: JOURNAL_VERSION, pid: process.pid, created_at: new Date().toISOString(), ...meta, files: entries };
|
|
142
|
+
const file = journalPath(workDir);
|
|
143
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
144
|
+
await writeFile(`${file}.tmp`, `${JSON.stringify(journal)}\n`, { mode: 0o600 });
|
|
145
|
+
await rename(`${file}.tmp`, file);
|
|
146
|
+
return journal;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `proof restore`: put every journaled file back byte for byte, verify every sha256, delete the
|
|
151
|
+
* journal. A mismatch keeps the journal and throws.
|
|
152
|
+
* @param {{workDir: string}} opts
|
|
153
|
+
* @returns {Promise<{restored: string[]}>} repo-relative paths, journal order
|
|
154
|
+
* @throws {StateError} `no-journal`, `bad-journal`, `hash-mismatch`
|
|
155
|
+
*/
|
|
156
|
+
export async function restoreJournal({ workDir }) {
|
|
157
|
+
if (typeof workDir !== 'string' || !path.isAbsolute(workDir)) throw new StateError('bad-path', 'restore: the work dir must be an absolute path');
|
|
158
|
+
const journal = await readJournal(workDir);
|
|
159
|
+
if (journal === null) throw new StateError('no-journal', `no red→green journal in ${workDir} — nothing to restore`);
|
|
160
|
+
for (const entry of journal.files) {
|
|
161
|
+
const abs = path.join(workDir, entry.path);
|
|
162
|
+
if (entry.existed) {
|
|
163
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
164
|
+
await rm(abs, { force: true }); // a symlink left in its place is replaced, never followed
|
|
165
|
+
await writeFile(abs, Buffer.from(entry.content, 'base64'), { mode: entry.mode });
|
|
166
|
+
await chmod(abs, entry.mode);
|
|
167
|
+
} else {
|
|
168
|
+
await rm(abs, { force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const bad = [];
|
|
172
|
+
for (const entry of journal.files) {
|
|
173
|
+
const abs = path.join(workDir, entry.path);
|
|
174
|
+
const st = await lstatOrNull(abs);
|
|
175
|
+
if (entry.existed ? !st?.isFile() || sha256(await readFile(abs)) !== entry.sha256 : st !== null) bad.push(entry.path);
|
|
176
|
+
}
|
|
177
|
+
if (bad.length > 0) throw new StateError('hash-mismatch', `restore: ${bad.length} file(s) do not match the journal (${bad.join(', ')}); the journal is kept`);
|
|
178
|
+
await rm(journalPath(workDir), { force: true });
|
|
179
|
+
return { restored: journal.files.map((e) => e.path) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The base version of each path, as bytes, or null when the path is absent at base. Read through
|
|
184
|
+
* a temporary index and `checkout-index` into a scratch dir (the real index is never touched).
|
|
185
|
+
* @param {string} repoDir @param {string} base @param {string[]} files
|
|
186
|
+
* @returns {Promise<Map<string, Buffer | null>>}
|
|
187
|
+
*/
|
|
188
|
+
async function readBaseFiles(repoDir, base, files) {
|
|
189
|
+
const resolved = await revParse(`${base}^{commit}`, repoDir);
|
|
190
|
+
if (resolved.result !== 'ok') throw new StateError('bad-ref', 'red-green: the base is not a commit in this repository');
|
|
191
|
+
const sha = resolved.stdout.trim();
|
|
192
|
+
const listed = new Set((await git(['ls-tree', '-r', '-z', '--full-tree', '--name-only', sha], repoDir)).split('\0').filter(Boolean));
|
|
193
|
+
const present = files.filter((f) => listed.has(f));
|
|
194
|
+
/** @type {Map<string, Buffer | null>} */
|
|
195
|
+
const out = new Map(files.map((f) => [f, null]));
|
|
196
|
+
if (present.length === 0) return out;
|
|
197
|
+
const scratch = await mkdtemp(path.join(currentRunRoot(), 'red-green-'));
|
|
198
|
+
try {
|
|
199
|
+
const env = { GIT_INDEX_FILE: path.join(scratch, 'index') };
|
|
200
|
+
const into = path.join(scratch, 'tree');
|
|
201
|
+
await git(['read-tree', sha], repoDir, { env });
|
|
202
|
+
await git(['checkout-index', '-f', '-z', '--stdin', `--prefix=${into}${path.sep}`], repoDir, { env, input: `${present.join('\0')}\0` });
|
|
203
|
+
for (const rel of present) out.set(rel, await readFile(path.join(into, rel)));
|
|
204
|
+
return out;
|
|
205
|
+
} finally {
|
|
206
|
+
await rm(scratch, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Assertion deletion rules — a FAIL-CLOSED, line-based heuristic (no parser dependency). A line is
|
|
212
|
+
* rewritten only when it is provably one whole assertion statement: it starts and ends in plain
|
|
213
|
+
* code (not inside a block comment, template literal, multi-line string or heredoc), touches no
|
|
214
|
+
* block comment, template literal or heredoc, holds exactly one statement (a call chain with
|
|
215
|
+
* balanced parentheses, one terminating `;` — or, in JS, a closing `)` that the next line cannot
|
|
216
|
+
* continue), and may end with a `//` (or PHP `#`) line comment. It must also sit at STATEMENT
|
|
217
|
+
* POSITION in the enclosing context, tracked across lines by the lexer's bracket stack:
|
|
218
|
+
* (a) the innermost open bracket at the line's start is a `{` block (or none), and the code
|
|
219
|
+
* token before the line is `;`, `{`, `}` or the start of the file — never an arrow body
|
|
220
|
+
* (`() =>` + newline), an array element or a call argument;
|
|
221
|
+
* (b) the statement closes on that line, at the same bracket depth it started;
|
|
222
|
+
* (c) the next code token starts a new statement (no leading `)`, `]`, `,`, `.`, `?`, operator).
|
|
223
|
+
* Otherwise the forced failure would land in an expression (`assert.fail(…);` followed by `)`),
|
|
224
|
+
* a syntax error that yields RED_INVALID instead of RED. Any other line that mentions an
|
|
225
|
+
* assertion is refused, so the tree is never touched on a guess. Per language:
|
|
226
|
+
* `statement` — the whole-statement prefix, matched on the masked line (strings/comments blanked);
|
|
227
|
+
* `token` — an assertion mention anywhere in masked code; a token outside an accepted
|
|
228
|
+
* statement (and outside an import/`use`/`require` line) refuses the file;
|
|
229
|
+
* `replace` — the forced failure written in place of the statement, same library.
|
|
230
|
+
*/
|
|
231
|
+
const DELETION_RULES = Object.freeze([
|
|
232
|
+
{
|
|
233
|
+
lang: /** @type {const} */ ('js'),
|
|
234
|
+
files: /\.(?:mjs|cjs|js|mts|cts|ts|jsx|tsx)$/,
|
|
235
|
+
statement: /^(\s*)(?:(\w+)\.)?(assert|expect)\b(?:\.\w+)?\s*\(/,
|
|
236
|
+
token: /\bassert\b|\bexpect\s*\(/,
|
|
237
|
+
exempt: /^\s*(?:import\b|export\b.*\bfrom\b)|\brequire\s*\(/,
|
|
238
|
+
/** @param {RegExpExecArray} m */
|
|
239
|
+
replace: (m) => (m[3] === 'expect' ? (m[2] ? null : `${m[1]}expect('${DELETED_MARKER}').toBe('');`) : `${m[1]}${m[2] ? `${m[2]}.` : ''}assert.fail('${DELETED_MARKER}');`),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
lang: /** @type {const} */ ('php'),
|
|
243
|
+
files: /\.php$/,
|
|
244
|
+
statement: /^(\s*)(?:(\$this)->assert\w*|(expect))\s*\(/,
|
|
245
|
+
token: /\$this->assert|\bexpect\s*\(|\b(?:self|static|Assert)::assert/,
|
|
246
|
+
exempt: /^\s*(?:use|namespace)\b/,
|
|
247
|
+
/** @param {RegExpExecArray} m */
|
|
248
|
+
replace: (m) => `${m[1]}$this->fail('${DELETED_MARKER}');`,
|
|
249
|
+
},
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
/** The code token that may precede a statement: `;`, `{`, `}` or the start of the file. */
|
|
253
|
+
const STATEMENT_BEFORE = new Set(['', ';', '{', '}']);
|
|
254
|
+
/** A next code token that continues the expression instead of starting a new statement. */
|
|
255
|
+
const CONTINUES_EXPRESSION = /^(?:[()[\]`.,?:+\-*/%<>=&|^~!]|(?:in|instanceof)\b)/;
|
|
256
|
+
/** @type {Record<string, string>} closing bracket → its opener */
|
|
257
|
+
const CLOSER_OF = { ')': '(', ']': '[', '}': '{' };
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @typedef {{masked: string, start: string, end: string, complex: boolean, openAtStart: string, openAtEnd: string, prev: string}} LexedLine
|
|
261
|
+
* `masked`: the line with string contents blanked and comments removed (same length or shorter);
|
|
262
|
+
* `start`/`end`: the lexer state at the line's start/end (`code` = plain code);
|
|
263
|
+
* `complex`: the line touched a block comment, a template literal or a heredoc;
|
|
264
|
+
* `openAtStart`/`openAtEnd`: the stack of open brackets (`(`, `[`, `{`, innermost last) at the
|
|
265
|
+
* line's start/end — tracked across lines, so a line knows its enclosing context;
|
|
266
|
+
* `prev`: the last code character before this line (`''` at the start of the file).
|
|
267
|
+
*/
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A small lexer: per line, the code with string literal contents blanked and comments dropped,
|
|
271
|
+
* plus the bracket context each line starts and ends in.
|
|
272
|
+
* @param {string[]} lines @param {'js' | 'php'} lang
|
|
273
|
+
* @returns {{lexed: LexedLine[], end: string, open: string, mismatch: number | null}}
|
|
274
|
+
* `open`: brackets still open at the end of the file; `mismatch`: the first line (0-based) that
|
|
275
|
+
* closed a bracket of the wrong kind or closed one that was never opened.
|
|
276
|
+
*/
|
|
277
|
+
function lex(lines, lang) {
|
|
278
|
+
/** @type {string} */
|
|
279
|
+
let state = 'code'; // code | block | template | ' | " | heredoc
|
|
280
|
+
let heredocId = '';
|
|
281
|
+
/** @type {string[]} */
|
|
282
|
+
const open = [];
|
|
283
|
+
/** @type {number | null} */
|
|
284
|
+
let mismatch = null;
|
|
285
|
+
let prev = '';
|
|
286
|
+
/** @type {LexedLine[]} */
|
|
287
|
+
const lexed = [];
|
|
288
|
+
for (const [index, line] of lines.entries()) {
|
|
289
|
+
const start = state;
|
|
290
|
+
const openAtStart = open.join('');
|
|
291
|
+
let masked = '';
|
|
292
|
+
let complex = state === 'block' || state === 'template' || state === 'heredoc';
|
|
293
|
+
if (state === 'heredoc') {
|
|
294
|
+
const closes = new RegExp(`^\\s*${heredocId}\\b`).test(line);
|
|
295
|
+
if (closes) {
|
|
296
|
+
state = 'code';
|
|
297
|
+
masked = line.replace(new RegExp(`^\\s*${heredocId}`), '');
|
|
298
|
+
}
|
|
299
|
+
lexed.push({ masked: closes ? masked : '', start, end: state, complex: true, openAtStart, openAtEnd: openAtStart, prev });
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
303
|
+
const ch = line[i];
|
|
304
|
+
const next = line[i + 1];
|
|
305
|
+
if (state === 'block') {
|
|
306
|
+
if (ch === '*' && next === '/') {
|
|
307
|
+
state = 'code';
|
|
308
|
+
i += 1;
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (state === 'template' || state === "'" || state === '"') {
|
|
313
|
+
const close = state === 'template' ? '`' : state;
|
|
314
|
+
if (ch === '\\') i += 1;
|
|
315
|
+
else if (ch === close) {
|
|
316
|
+
masked += ch;
|
|
317
|
+
state = 'code';
|
|
318
|
+
} else masked += ' ';
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
// plain code
|
|
322
|
+
if (ch === '/' && next === '*') {
|
|
323
|
+
state = 'block';
|
|
324
|
+
complex = true;
|
|
325
|
+
i += 1;
|
|
326
|
+
} else if ((ch === '/' && next === '/') || (lang === 'php' && ch === '#' && next !== '[')) {
|
|
327
|
+
break; // a line comment: the rest of the line is dropped
|
|
328
|
+
} else if (lang === 'js' && ch === '`') {
|
|
329
|
+
state = 'template';
|
|
330
|
+
complex = true;
|
|
331
|
+
masked += ch;
|
|
332
|
+
} else if (ch === "'" || ch === '"') {
|
|
333
|
+
state = ch;
|
|
334
|
+
masked += ch;
|
|
335
|
+
} else if (lang === 'php' && ch === '<' && line.startsWith('<<<', i)) {
|
|
336
|
+
const m = /^<<<\s*(['"]?)([A-Za-z_]\w*)\1/.exec(line.slice(i));
|
|
337
|
+
if (m) {
|
|
338
|
+
state = 'heredoc';
|
|
339
|
+
heredocId = m[2];
|
|
340
|
+
complex = true;
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
masked += ch;
|
|
344
|
+
} else {
|
|
345
|
+
if (ch === '(' || ch === '[' || ch === '{') open.push(ch);
|
|
346
|
+
else if (ch in CLOSER_OF && open.pop() !== CLOSER_OF[ch] && mismatch === null) mismatch = index;
|
|
347
|
+
masked += ch;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
lexed.push({ masked, start, end: state, complex, openAtStart, openAtEnd: open.join(''), prev });
|
|
351
|
+
const code = masked.trimEnd();
|
|
352
|
+
if (code.length > 0) prev = code[code.length - 1];
|
|
353
|
+
}
|
|
354
|
+
return { lexed, end: state, open: open.join(''), mismatch };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Is the masked code (comment already dropped) exactly one call-chain statement starting at the
|
|
359
|
+
* first `(`? Parentheses balance, depth-0 text is only a member chain (`.x(`, `->x(`), and the
|
|
360
|
+
* only `;` is the last character.
|
|
361
|
+
* @param {string} code @param {'js' | 'php'} lang
|
|
362
|
+
* @returns {'semicolon' | 'bare' | null} how it ends, or null (not one statement)
|
|
363
|
+
*/
|
|
364
|
+
function oneStatement(code, lang) {
|
|
365
|
+
const text = code.trimEnd();
|
|
366
|
+
let depth = 0;
|
|
367
|
+
for (let i = text.indexOf('('); i < text.length; i += 1) {
|
|
368
|
+
const ch = text[i];
|
|
369
|
+
if (ch === '(') depth += 1;
|
|
370
|
+
else if (ch === ')') {
|
|
371
|
+
depth -= 1;
|
|
372
|
+
if (depth < 0) return null;
|
|
373
|
+
} else if (depth === 0) {
|
|
374
|
+
if (ch === ';') return i === text.length - 1 ? 'semicolon' : null;
|
|
375
|
+
if (!/[\w\s.$>-]/.test(ch)) return null;
|
|
376
|
+
if (ch === '-' && text[i + 1] !== '>') return null;
|
|
377
|
+
if (ch === '>' && text[i - 1] !== '-') return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (depth !== 0) return null;
|
|
381
|
+
return lang === 'js' && text.endsWith(')') ? 'bare' : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The test file with every assertion statement deleted and replaced by a forced failure. Fail
|
|
386
|
+
* closed: any assertion mention that is not provably one whole single-line statement refuses the
|
|
387
|
+
* file before the tree is touched.
|
|
388
|
+
* @param {string} file - repo-relative, picks the language rule
|
|
389
|
+
* @param {Buffer} bytes
|
|
390
|
+
* @returns {{bytes: Buffer, deleted: number}}
|
|
391
|
+
* @throws {StateError} `unsupported-test`, `unparseable-test`, `unclassified-assertion`, `no-assertions`
|
|
392
|
+
*/
|
|
393
|
+
export function deleteAssertions(file, bytes) {
|
|
394
|
+
const rule = DELETION_RULES.find((r) => r.files.test(file));
|
|
395
|
+
if (!rule) throw new StateError('unsupported-test', `assertion deletion: no rule for ${path.posix.extname(file) || 'this file type'}`);
|
|
396
|
+
const lines = bytes.toString('utf8').split('\n');
|
|
397
|
+
const { lexed, end, open, mismatch } = lex(lines, rule.lang);
|
|
398
|
+
if (end !== 'code' || open.length > 0) throw new StateError('unparseable-test', `assertion deletion: ${file} ends inside a comment, string, heredoc or an open bracket`);
|
|
399
|
+
if (mismatch !== null) throw new StateError('unparseable-test', `assertion deletion: ${file} closes a bracket it did not open at line ${mismatch + 1}`);
|
|
400
|
+
const refuse = (/** @type {number} */ i) => {
|
|
401
|
+
throw new StateError('unclassified-assertion', `assertion deletion: the assertion at line ${i + 1} is not one whole single-line statement at statement position`);
|
|
402
|
+
};
|
|
403
|
+
let deleted = 0;
|
|
404
|
+
const out = lines.map((line, i) => {
|
|
405
|
+
const { masked, start, end: lineEnd, complex, openAtStart, openAtEnd, prev } = lexed[i];
|
|
406
|
+
if (start !== 'code') return line; // inside a comment/string/heredoc: not code
|
|
407
|
+
const m = rule.statement.exec(masked);
|
|
408
|
+
if (!m) {
|
|
409
|
+
if (rule.token.test(masked) && !rule.exempt.test(masked)) refuse(i);
|
|
410
|
+
return line;
|
|
411
|
+
}
|
|
412
|
+
if (complex || lineEnd !== 'code') refuse(i);
|
|
413
|
+
// (a) statement position: innermost open bracket is a `{` block (or none), after `;` `{` `}` or the file start
|
|
414
|
+
if ((openAtStart.length > 0 && !openAtStart.endsWith('{')) || !STATEMENT_BEFORE.has(prev)) refuse(i);
|
|
415
|
+
// (b) exactly one statement, closed on this line at the depth it started
|
|
416
|
+
if (oneStatement(masked, rule.lang) === null || openAtEnd !== openAtStart) refuse(i);
|
|
417
|
+
// (c) the next code token starts a new statement (a bare `)` ending is an ASI hazard otherwise)
|
|
418
|
+
const next = lexed.slice(i + 1).find((l) => l.masked.trim().length > 0);
|
|
419
|
+
if (next && CONTINUES_EXPRESSION.test(next.masked.trim())) refuse(i);
|
|
420
|
+
const replacement = rule.replace(m);
|
|
421
|
+
if (replacement === null) refuse(i);
|
|
422
|
+
deleted += 1;
|
|
423
|
+
return replacement;
|
|
424
|
+
});
|
|
425
|
+
if (deleted === 0) throw new StateError('no-assertions', `assertion deletion: ${file} has no single-line assertion statement`);
|
|
426
|
+
return { bytes: Buffer.from(out.join('\n'), 'utf8'), deleted };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** @returns {NodeJS.ProcessEnv} the test child's env: a node:test parent's worker marker never leaks into it */
|
|
430
|
+
function testEnv() {
|
|
431
|
+
const { NODE_TEST_CONTEXT, ...env } = process.env;
|
|
432
|
+
return env;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* @typedef {object} RedGreenOptions
|
|
437
|
+
* @property {string} workDir - absolute: the export dir, or the workspace under the proof lock
|
|
438
|
+
* @property {string} [repoDir] - the git repository the base is read from (default `workDir`)
|
|
439
|
+
* @property {string} base - the block's base sha
|
|
440
|
+
* @property {string[]} argv - the test command, already filtered to this test
|
|
441
|
+
* @property {{file: string, case?: string, characterization?: boolean}} test
|
|
442
|
+
* @property {string[]} [sources] - the block's non-test changed files (repo-relative)
|
|
443
|
+
* @property {'export' | 'lock'} isolation
|
|
444
|
+
* @property {number} [timeoutMs]
|
|
445
|
+
* @property {(line: string) => void} [print]
|
|
446
|
+
* @property {{runId: string, blockId: string, writeRow: import('../state/run.mjs').WriteRow}} [record]
|
|
447
|
+
*/
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Run one red→green proof. See the module comment for the steps.
|
|
451
|
+
* @param {RedGreenOptions} opts
|
|
452
|
+
* @returns {Promise<{label: string, mechanism: 'revert' | 'assertion-deletion', red: import('./red-parse.mjs').RedParse, green: 'GREEN' | 'NOT_GREEN' | null, proven: boolean}>}
|
|
453
|
+
* @throws {StateError} `journal-present`, `bad-path`, `bad-isolation`, `bad-ref`, and the assertion-deletion refusals
|
|
454
|
+
*/
|
|
455
|
+
export async function runRedGreen(opts) {
|
|
456
|
+
const { workDir, base, argv, test, sources = [], isolation, timeoutMs = DEFAULT_TEST_TIMEOUT_MS, print = () => {}, record } = opts;
|
|
457
|
+
if (typeof workDir !== 'string' || !path.isAbsolute(workDir)) throw new StateError('bad-path', 'red-green: workDir must be an absolute path');
|
|
458
|
+
const repoDir = opts.repoDir ?? workDir;
|
|
459
|
+
if (!path.isAbsolute(repoDir)) throw new StateError('bad-path', 'red-green: repoDir must be an absolute path');
|
|
460
|
+
if (!ISOLATIONS.includes(/** @type {any} */ (isolation))) throw new StateError('bad-isolation', `red-green: isolation must be one of ${ISOLATIONS.join(', ')}`);
|
|
461
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string')) throw new StateError('usage', 'red-green: argv must be a non-empty array of strings');
|
|
462
|
+
assertRelPath(test?.file, 'test.file');
|
|
463
|
+
for (const s of sources) assertRelPath(s, 'sources');
|
|
464
|
+
if (sources.includes(test.file)) throw new StateError('bad-path', 'red-green: the test file cannot also be a source file');
|
|
465
|
+
if (typeof base !== 'string' || base.length === 0 || base.startsWith('-')) throw new StateError('bad-ref', 'red-green: base must be a commit');
|
|
466
|
+
|
|
467
|
+
const label = test.case ? `${test.file}::${test.case}` : test.file;
|
|
468
|
+
const mechanism = test.characterization === true || sources.length === 0 ? 'assertion-deletion' : 'revert';
|
|
469
|
+
if ((await readJournal(workDir)) !== null) {
|
|
470
|
+
throw new StateError('journal-present', `a red→green journal exists in ${workDir} — an earlier run was interrupted; run code-forge proof restore first`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Compute the red state BEFORE touching the tree, so a refusal changes nothing.
|
|
474
|
+
/** @type {Map<string, Buffer | null>} */
|
|
475
|
+
let redState;
|
|
476
|
+
if (mechanism === 'revert') {
|
|
477
|
+
redState = await readBaseFiles(repoDir, base, [...new Set(sources)].sort());
|
|
478
|
+
} else {
|
|
479
|
+
const abs = path.join(workDir, test.file);
|
|
480
|
+
if (!(await lstatOrNull(abs))?.isFile()) throw new StateError('bad-path', `red-green: ${test.file} is not a regular file`);
|
|
481
|
+
redState = new Map([[test.file, deleteAssertions(test.file, await readFile(abs)).bytes]]);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
await writeJournal(workDir, [...redState.keys()], { mechanism, test: label, ...(record ? { block: record.blockId } : {}) });
|
|
485
|
+
/** @type {import('./red-parse.mjs').RedParse} */
|
|
486
|
+
let red;
|
|
487
|
+
try {
|
|
488
|
+
for (const [rel, bytes] of redState) {
|
|
489
|
+
const abs = path.join(workDir, rel);
|
|
490
|
+
await rm(abs, { force: true });
|
|
491
|
+
if (bytes !== null) {
|
|
492
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
493
|
+
await writeFile(abs, bytes);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
red = parseRed(await exec(argv, { cwd: workDir, env: testEnv(), timeoutMs }));
|
|
497
|
+
} finally {
|
|
498
|
+
// Any ordinary error in the red step still restores (signals keep the journal: module comment).
|
|
499
|
+
await restoreJournal({ workDir });
|
|
500
|
+
}
|
|
501
|
+
print(red.verdict === 'RED_INVALID' ? `RED_INVALID ${label} (${red.red_kind})` : `${red.verdict} ${label}`);
|
|
502
|
+
|
|
503
|
+
/** @type {'GREEN' | 'NOT_GREEN' | null} */
|
|
504
|
+
let green = null;
|
|
505
|
+
if (red.verdict === 'RED') {
|
|
506
|
+
const greenRun = await exec(argv, { cwd: workDir, env: testEnv(), timeoutMs });
|
|
507
|
+
green = greenRun.result === 'ok' ? 'GREEN' : 'NOT_GREEN';
|
|
508
|
+
print(`${green} ${label}`);
|
|
509
|
+
}
|
|
510
|
+
const proven = red.verdict === 'RED' && green === 'GREEN';
|
|
511
|
+
if (record) {
|
|
512
|
+
await recordProof({
|
|
513
|
+
runId: record.runId,
|
|
514
|
+
writeRow: record.writeRow,
|
|
515
|
+
row: { block: record.blockId, isolation, step: 'red-green', test: label, mechanism, red_kind: red.red_kind, red: red.verdict, green, proven, failed: red.failed },
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return { label, mechanism, red, green, proven };
|
|
519
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `red-parse` (plan §7.2, O13): what did the red step of a red→green run actually prove?
|
|
3
|
+
*
|
|
4
|
+
* A red step proves something ONLY when the filtered test run failed on an assertion. A run that
|
|
5
|
+
* failed because a module did not load, the file did not compile or the process died proves
|
|
6
|
+
* nothing ("red for the wrong reason") and is `RED_INVALID`. A run that passed is `NOT_RED`.
|
|
7
|
+
*
|
|
8
|
+
* verdict red_kind when
|
|
9
|
+
* ─────────── ─────────── ─────────────────────────────────────────────────────────────────
|
|
10
|
+
* NOT_RED none exit code 0 and no signal
|
|
11
|
+
* RED_INVALID import a module / class / file the test needs did not load
|
|
12
|
+
* RED_INVALID compile a syntax or parse error
|
|
13
|
+
* RED_INVALID fatal a PHP fatal, a crash, a kill signal or a timeout
|
|
14
|
+
* RED assertion an assertion failure line and none of the above
|
|
15
|
+
* RED_INVALID error failed, but no assertion line (an uncaught TypeError, …)
|
|
16
|
+
*
|
|
17
|
+
* The fatal families are checked FIRST: one test file that did not load invalidates the run even
|
|
18
|
+
* when another case failed on an assertion — the red count would otherwise include a case that
|
|
19
|
+
* never ran. Shapes recognised: `node:test` (spec and TAP reporters), Pest/PHPUnit (collision
|
|
20
|
+
* printer and plain), Jest/Vitest `AssertionError`/`expect(` failures. Colours are stripped first.
|
|
21
|
+
* The result never carries an excerpt of the output — only the kind and a fixed reason.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @typedef {'NOT_RED' | 'RED' | 'RED_INVALID'} RedVerdict */
|
|
25
|
+
/** @typedef {'none' | 'assertion' | 'import' | 'compile' | 'fatal' | 'error'} RedKind */
|
|
26
|
+
/** @typedef {{verdict: RedVerdict, red_kind: RedKind, framework: 'node:test' | 'pest' | 'unknown', failed: number | null, reason: string}} RedParse */
|
|
27
|
+
|
|
28
|
+
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
29
|
+
|
|
30
|
+
/** Ordered: the first family with a hit decides. Each entry: [red_kind, reason, patterns]. */
|
|
31
|
+
const INVALID_FAMILIES = /** @type {const} */ ([
|
|
32
|
+
[
|
|
33
|
+
'import',
|
|
34
|
+
'a module, class or file the test needs did not load',
|
|
35
|
+
[
|
|
36
|
+
/\bERR_MODULE_NOT_FOUND\b/,
|
|
37
|
+
/\bERR_PACKAGE_PATH_NOT_EXPORTED\b/,
|
|
38
|
+
/\bCannot find (?:module|package) ['"]/,
|
|
39
|
+
/\bdoes not provide an export named\b/,
|
|
40
|
+
/\bClass "?[A-Za-z_\\][\w\\]*"? not found\b/,
|
|
41
|
+
/\bFailed opening required\b/,
|
|
42
|
+
/\bInterface "?[A-Za-z_\\][\w\\]*"? not found\b/,
|
|
43
|
+
],
|
|
44
|
+
],
|
|
45
|
+
['compile', 'a syntax or parse error', [/^\s*SyntaxError\b/m, /\bPHP Parse error\b/, /\bParseError\b/, /^\s*Parse error:/m]],
|
|
46
|
+
['fatal', 'the test process died (fatal error or crash)', [/\bPHP Fatal error\b/, /^\s*Fatal error:/m, /\bSegmentation fault\b/, /\bAllowed memory size of \d+ bytes exhausted\b/]],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const ASSERTION_PATTERNS = Object.freeze([
|
|
50
|
+
/\bERR_ASSERTION\b/, // node:assert (spec reporter prints `AssertionError [ERR_ASSERTION]`, TAP prints `code: 'ERR_ASSERTION'`)
|
|
51
|
+
/\bAssertionError\b/, // node, Jest/Vitest, pytest
|
|
52
|
+
/\bFailed asserting that\b/, // PHPUnit and Pest expectations
|
|
53
|
+
/\bExpectationFailedException\b/, // PHPUnit's exception class
|
|
54
|
+
/\bexpect\(.*\)\.\w+\(/, // Jest/Vitest failure header `expect(received).toBe(expected)`
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const FAILED_COUNTS = Object.freeze([
|
|
58
|
+
/^\s*ℹ fail (\d+)\s*$/m, // node:test spec
|
|
59
|
+
/^# fail (\d+)\s*$/m, // node:test TAP
|
|
60
|
+
/^\s*Tests:\s+(\d+) failed\b/m, // Pest
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** @param {string} text @returns {'node:test' | 'pest' | 'unknown'} */
|
|
64
|
+
function frameworkOf(text) {
|
|
65
|
+
if (/^\s*ℹ tests \d+\s*$/m.test(text) || /^# tests \d+\s*$/m.test(text) || /^TAP version \d+/m.test(text)) return 'node:test';
|
|
66
|
+
if (/^\s*Tests:\s+\d+ (?:failed|passed)\b/m.test(text) || /^\s*(?:FAIL|FAILED)\s+Tests\\/m.test(text)) return 'pest';
|
|
67
|
+
return 'unknown';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {string} text @returns {number | null} */
|
|
71
|
+
function failedCount(text) {
|
|
72
|
+
for (const pattern of FAILED_COUNTS) {
|
|
73
|
+
const m = pattern.exec(text);
|
|
74
|
+
if (m) return Number(m[1]);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify the red step of a red→green run.
|
|
81
|
+
* @param {{code: number | null, signal?: string | null, timedOut?: boolean, stdout?: string, stderr?: string}} run
|
|
82
|
+
* an `exec` result (or its shape)
|
|
83
|
+
* @returns {RedParse}
|
|
84
|
+
*/
|
|
85
|
+
export function parseRed(run) {
|
|
86
|
+
const text = `${run.stdout ?? ''}\n${run.stderr ?? ''}`.replace(ANSI, '');
|
|
87
|
+
const framework = frameworkOf(text);
|
|
88
|
+
const failed = failedCount(text);
|
|
89
|
+
const base = { framework, failed };
|
|
90
|
+
if (run.code === 0 && !run.signal && !run.timedOut) {
|
|
91
|
+
return { ...base, verdict: 'NOT_RED', red_kind: 'none', reason: 'the test passed without the change' };
|
|
92
|
+
}
|
|
93
|
+
if (run.timedOut || run.signal) {
|
|
94
|
+
return { ...base, verdict: 'RED_INVALID', red_kind: 'fatal', reason: run.timedOut ? 'the test run timed out' : 'the test run was killed by a signal' };
|
|
95
|
+
}
|
|
96
|
+
for (const [kind, reason, patterns] of INVALID_FAMILIES) {
|
|
97
|
+
if (patterns.some((p) => p.test(text))) return { ...base, verdict: 'RED_INVALID', red_kind: kind, reason };
|
|
98
|
+
}
|
|
99
|
+
if (ASSERTION_PATTERNS.some((p) => p.test(text))) {
|
|
100
|
+
return { ...base, verdict: 'RED', red_kind: 'assertion', reason: 'an assertion failed' };
|
|
101
|
+
}
|
|
102
|
+
return { ...base, verdict: 'RED_INVALID', red_kind: 'error', reason: 'the run failed without an assertion failure' };
|
|
103
|
+
}
|