@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,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small shared helpers over a forbidden-list RENDER — the shape `renderForClaude`/`renderForGrok`
|
|
3
|
+
* (`{id, rules: string[], enforced}[]`) or `renderForCodex` (`{id, patterns: string[][],
|
|
4
|
+
* enforced}[]`) from `src/util/forbidden.mjs` (B0/B8) return. Used by `builders/claude.mjs` and
|
|
5
|
+
* `builders/grok.mjs` so the "flatten every entry's rules into one argv run" logic exists once,
|
|
6
|
+
* not twice, and by all three coder builders to refuse an empty render (fix round 1, MAJOR: a
|
|
7
|
+
* coder role must always carry a non-empty forbidden list — a renderer regression that returns
|
|
8
|
+
* nothing must be a thrown error, not a silently-empty `--disallowedTools`/rules file).
|
|
9
|
+
*
|
|
10
|
+
* `idsMissingFromArgv` moved OUT of this file in fix round 1 (MINOR, dead-code finding): nothing
|
|
11
|
+
* under `src/` ever called it — only tests did — so it now lives in
|
|
12
|
+
* `test/engines/builders/argv-check.mjs`, alongside the `.every`-not-`.some` fix and two new
|
|
13
|
+
* position-aware helpers the review asked for.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{id: string, rules?: ReadonlyArray<string>, patterns?: ReadonlyArray<ReadonlyArray<string>>, enforced: boolean}} RenderedEntry
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {ReadonlyArray<{id: string, rules: ReadonlyArray<string>, enforced: boolean}>} rendered
|
|
22
|
+
* @returns {string[]} every rule string of every entry, in entry order, entry-internal order
|
|
23
|
+
* preserved (an `enforced: false` entry contributes an empty `rules` array, so it drops out
|
|
24
|
+
* naturally rather than needing a separate filter).
|
|
25
|
+
*/
|
|
26
|
+
export function flattenRuleStrings(rendered) {
|
|
27
|
+
return rendered.flatMap((entry) => [...entry.rules]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a render has AT LEAST ONE usable rule/pattern anywhere across its entries — the guard a
|
|
32
|
+
* coder-role builder throws on when this is false (a coder must always carry a real forbidden
|
|
33
|
+
* list; an empty one is a renderer regression, never a legitimate state). Works for both the
|
|
34
|
+
* Claude/Grok shape (`rules`) and the Codex shape (`patterns`) without the caller needing to know
|
|
35
|
+
* which.
|
|
36
|
+
* @param {ReadonlyArray<RenderedEntry>} rendered
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function hasAnyRenderedRule(rendered) {
|
|
40
|
+
return rendered.some((entry) => (entry.rules?.length ?? 0) > 0 || (entry.patterns?.length ?? 0) > 0);
|
|
41
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subprocess-mode coder sentinel contract (plan §5.2, §5.3, C11) and the `ACK <sha8>
|
|
3
|
+
* lines=<n>` wire receipt.
|
|
4
|
+
*
|
|
5
|
+
* **Sentinel.** A coder prints `===BLOCK <id> COMPLETE===` on success or `===BLOCK <id> FAILED:
|
|
6
|
+
* <reason>===` on failure (§10.7's forecast-exceeded stop line — `===BLOCK <id> FAILED: forecast
|
|
7
|
+
* exceeded, split plan <id>a/<id>b===` — is one concrete instance of the FAILED shape). A coder can
|
|
8
|
+
* print MORE than one of these across a long-running transcript (a retry after a local fix, a
|
|
9
|
+
* revised final report); `parseSentinel` always takes the LAST occurrence, never the first (plan
|
|
10
|
+
* §5.2: "`sentinel.mjs` takes the **last** occurrence"). Both patterns are anchored to the WHOLE
|
|
11
|
+
* trimmed line (`^...$`), so a coder quoting the sentinel text inside ordinary prose ("I will not
|
|
12
|
+
* print ===BLOCK B4 COMPLETE=== yet") never matches — only a line that IS exactly the sentinel does.
|
|
13
|
+
*
|
|
14
|
+
* **ACK.** Before any tool call, a coder replies `ACK <sha8> lines=<n>` to the wire receipt it was
|
|
15
|
+
* sent — the sha of the brief file, truncated to 8 hex characters (matched case-insensitively;
|
|
16
|
+
* compared lowercased), and the brief's line count. `parseAck` returns the LAST matching line (fix
|
|
17
|
+
* round 1, MAJOR): the protocol is "no/wrong ACK within the launch window ⇒ resend once" (§5.2),
|
|
18
|
+
* so when a caller passes the transcript up through a resend, the LAST ACK is the one that reflects
|
|
19
|
+
* whether the resend succeeded — a first-match reading would report the pre-resend failure even
|
|
20
|
+
* after a successful resend. An optional `after` marker lets a caller scope the search to
|
|
21
|
+
* everything strictly after the last occurrence of that marker (e.g. the literal wire-receipt text
|
|
22
|
+
* that was just (re)sent), which also defends against an unrelated earlier line that happens to
|
|
23
|
+
* look like an ACK (an echoed instruction, a quoted example) being mistaken for the real one.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @typedef {{status: "complete", blockId: string} | {status: "failed", blockId: string, reason: string}} SentinelResult */
|
|
27
|
+
|
|
28
|
+
const COMPLETE_RE = /^===BLOCK\s+(\S+)\s+COMPLETE===$/;
|
|
29
|
+
const FAILED_RE = /^===BLOCK\s+(\S+)\s+FAILED:\s*(.*?)\s*===$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} output - the full captured transcript (subprocess stdout, or a Solo
|
|
33
|
+
* `get_process_output` join).
|
|
34
|
+
* @param {string} [blockId] - when given, only sentinel lines for THIS block id count — a shared
|
|
35
|
+
* pane/log can interleave another block's lines.
|
|
36
|
+
* @returns {SentinelResult | null} the LAST matching sentinel line, or `null` if none is present.
|
|
37
|
+
*/
|
|
38
|
+
export function parseSentinel(output, blockId) {
|
|
39
|
+
if (typeof output !== 'string') {
|
|
40
|
+
throw new TypeError('parseSentinel: output must be a string');
|
|
41
|
+
}
|
|
42
|
+
/** @type {SentinelResult | null} */
|
|
43
|
+
let last = null;
|
|
44
|
+
for (const rawLine of output.split('\n')) {
|
|
45
|
+
const line = rawLine.trim();
|
|
46
|
+
const complete = COMPLETE_RE.exec(line);
|
|
47
|
+
if (complete && (!blockId || complete[1] === blockId)) {
|
|
48
|
+
last = { status: 'complete', blockId: complete[1] };
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const failed = FAILED_RE.exec(line);
|
|
52
|
+
if (failed && (!blockId || failed[1] === blockId)) {
|
|
53
|
+
last = { status: 'failed', blockId: failed[1], reason: failed[2] };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return last;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @typedef {{sha8: string, lines: number}} ParsedAck */
|
|
60
|
+
|
|
61
|
+
/** Case-insensitive: normalized to lowercase before comparison in {@link verifyAck}. */
|
|
62
|
+
const ACK_RE = /^ACK\s+([0-9a-fA-F]{8})\s+lines=(\d+)\s*$/;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} output
|
|
66
|
+
* @param {{after?: string} | null} [opts] - `after`: only text strictly after the LAST occurrence
|
|
67
|
+
* of this marker is searched (the whole `output` is searched when omitted or not found). `opts`
|
|
68
|
+
* may be `null`; `after` must be a non-empty string when given (TypeError otherwise).
|
|
69
|
+
* @returns {ParsedAck | null} the LAST `ACK <sha8> lines=<n>` line in the searched window (fix
|
|
70
|
+
* round 1, MAJOR — was the first).
|
|
71
|
+
*/
|
|
72
|
+
export function parseAck(output, opts = {}) {
|
|
73
|
+
if (typeof output !== 'string') {
|
|
74
|
+
throw new TypeError('parseAck: output must be a string');
|
|
75
|
+
}
|
|
76
|
+
const after = opts?.after;
|
|
77
|
+
if (after !== undefined && (typeof after !== 'string' || after.length === 0)) {
|
|
78
|
+
// An empty marker would make `lastIndexOf` return output.length and empty the window.
|
|
79
|
+
throw new TypeError('parseAck: opts.after must be a non-empty string when given');
|
|
80
|
+
}
|
|
81
|
+
let window = output;
|
|
82
|
+
if (after !== undefined) {
|
|
83
|
+
const idx = output.lastIndexOf(after);
|
|
84
|
+
if (idx !== -1) window = output.slice(idx + after.length);
|
|
85
|
+
}
|
|
86
|
+
/** @type {ParsedAck | null} */
|
|
87
|
+
let last = null;
|
|
88
|
+
for (const rawLine of window.split('\n')) {
|
|
89
|
+
const match = ACK_RE.exec(rawLine.trim());
|
|
90
|
+
if (match) {
|
|
91
|
+
last = { sha8: match[1].toLowerCase(), lines: Number(match[2]) };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return last;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @typedef {{status: "ok", ack: ParsedAck} | {status: "parked", reason: string}} AckVerification */
|
|
98
|
+
|
|
99
|
+
const SHA8_RE = /^[0-9a-fA-F]{8}$/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} output
|
|
103
|
+
* @param {string} expectedSha8 - exactly 8 hex characters (case-insensitive).
|
|
104
|
+
* @param {number} [expectedLines] - when given, a line-count mismatch also parks (the brief was
|
|
105
|
+
* truncated in flight).
|
|
106
|
+
* @param {{after?: string} | null} [opts] - forwarded to {@link parseAck}.
|
|
107
|
+
* @returns {AckVerification}
|
|
108
|
+
* @throws {TypeError} if `expectedSha8` is not exactly 8 hex characters — a caller passing a full
|
|
109
|
+
* 40-character sha (or any other malformed value) gets a clear refusal here instead of a
|
|
110
|
+
* confusing "no ACK" / "sha mismatch" `parked` result. Also if `expectedLines` is given but is
|
|
111
|
+
* not a non-negative integer (null, NaN, a numeric string), instead of a misleading
|
|
112
|
+
* "ACK lines mismatch" park.
|
|
113
|
+
*/
|
|
114
|
+
export function verifyAck(output, expectedSha8, expectedLines, opts = {}) {
|
|
115
|
+
if (typeof expectedSha8 !== 'string' || !SHA8_RE.test(expectedSha8)) {
|
|
116
|
+
throw new TypeError(`verifyAck: expectedSha8 must be exactly 8 hex characters, got ${JSON.stringify(expectedSha8)}`);
|
|
117
|
+
}
|
|
118
|
+
if (expectedLines !== undefined && !(Number.isInteger(expectedLines) && expectedLines >= 0)) {
|
|
119
|
+
throw new TypeError(`verifyAck: expectedLines must be a non-negative integer when given, got ${JSON.stringify(expectedLines)}`);
|
|
120
|
+
}
|
|
121
|
+
const normalizedExpected = expectedSha8.toLowerCase();
|
|
122
|
+
const ack = parseAck(output, opts);
|
|
123
|
+
if (!ack) {
|
|
124
|
+
return { status: 'parked', reason: 'no ACK line found in the searched window' };
|
|
125
|
+
}
|
|
126
|
+
if (ack.sha8 !== normalizedExpected) {
|
|
127
|
+
return { status: 'parked', reason: `ACK sha mismatch: expected ${normalizedExpected}, got ${ack.sha8}` };
|
|
128
|
+
}
|
|
129
|
+
if (expectedLines !== undefined && ack.lines !== expectedLines) {
|
|
130
|
+
return { status: 'parked', reason: `ACK lines mismatch: expected ${expectedLines}, got ${ack.lines}` };
|
|
131
|
+
}
|
|
132
|
+
return { status: 'ok', ack };
|
|
133
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-usage extraction from a provider CLI's own JSON output (plan §6.1: "Tokens: reported when
|
|
3
|
+
* the CLI's JSON carries usage (`claude -p --output-format json` does `[A2]`; Codex/Grok
|
|
4
|
+
* best-effort `[A14]`), else `estimated`. **Tokens are facts, dollars are estimates.**").
|
|
5
|
+
*
|
|
6
|
+
* **Not independently verified on this Mac** — the plan's own `[A2]`/`[A14]` tags mean the exact
|
|
7
|
+
* JSON field names were not read from a real run for this revision (only `--help` text was; §0.6.8
|
|
8
|
+
* tolerates this for the ledger's `tokens_source` field). This parser is deliberately lenient: it
|
|
9
|
+
* tries the field names each vendor's own API/CLI documentation uses elsewhere, and falls back to
|
|
10
|
+
* `tokensSource: "estimated"` rather than throwing when none match — a shape drift here must never
|
|
11
|
+
* crash a review or a coder run, only degrade the ledger's tokens field, exactly as §6.1 describes.
|
|
12
|
+
*
|
|
13
|
+
* **Fix round 1 (isolated per-file review):**
|
|
14
|
+
* - **Claude**: `input_tokens` alone undercounts a cached run — Anthropic's Messages API (which
|
|
15
|
+
* Claude Code's own JSON result reuses) reports prompt-cache reads/writes as SEPARATE fields
|
|
16
|
+
* (`cache_creation_input_tokens`, `cache_read_input_tokens`), NOT folded into `input_tokens`.
|
|
17
|
+
* `tokensIn` is now their sum, and the individual cache fields are kept on the returned record
|
|
18
|
+
* (`cacheCreationTokens`/`cacheReadTokens`) so a caller doesn't lose the split.
|
|
19
|
+
* - `PARSERS[cli]`/`Object.hasOwn`: the cli lookup is now guarded so `parseUsage('constructor',
|
|
20
|
+
* …)` (and `'toString'`, `'__proto__'`, …) hits the documented "unknown cli" error instead of a
|
|
21
|
+
* prototype-chain member.
|
|
22
|
+
* - Codex/Grok no longer mix naming schemes within one payload: `tokensIn` from `input_tokens` and
|
|
23
|
+
* `tokensOut` from `completion_tokens` (two different vendors' field names) never happens now —
|
|
24
|
+
* one scheme is picked (input_tokens/output_tokens if BOTH present, else prompt_tokens/
|
|
25
|
+
* completion_tokens if BOTH present, else `estimated`).
|
|
26
|
+
* - JSDoc on `parseCodexUsage` now names exactly which Codex `--json` event a caller must pass.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{tokensIn: number, tokensOut: number, tokensSource: "reported", cacheCreationTokens?: number, cacheReadTokens?: number}
|
|
31
|
+
* | {tokensIn: null, tokensOut: null, tokensSource: "estimated"}} UsageResult
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const ESTIMATED = Object.freeze({ tokensIn: null, tokensOut: null, tokensSource: /** @type {"estimated"} */ ('estimated') });
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {unknown} value
|
|
38
|
+
* @returns {value is Record<string, unknown>}
|
|
39
|
+
*/
|
|
40
|
+
function isPlainObject(value) {
|
|
41
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @returns {number | undefined}
|
|
47
|
+
*/
|
|
48
|
+
function asNonNegativeInt(value) {
|
|
49
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Claude Code `-p --output-format json` result shape: a top-level `usage` object with
|
|
54
|
+
* `input_tokens`/`output_tokens` (Anthropic Messages API field names, which Claude Code's own JSON
|
|
55
|
+
* result reuses), PLUS `cache_creation_input_tokens`/`cache_read_input_tokens` when prompt caching
|
|
56
|
+
* was used — both optional, defaulting to 0, folded into `tokensIn` and also kept split.
|
|
57
|
+
* @param {unknown} parsed
|
|
58
|
+
* @returns {UsageResult}
|
|
59
|
+
*/
|
|
60
|
+
function parseClaudeUsage(parsed) {
|
|
61
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.usage)) return ESTIMATED;
|
|
62
|
+
const usage = parsed.usage;
|
|
63
|
+
const baseIn = asNonNegativeInt(usage.input_tokens);
|
|
64
|
+
const tokensOut = asNonNegativeInt(usage.output_tokens);
|
|
65
|
+
if (baseIn === undefined || tokensOut === undefined) return ESTIMATED;
|
|
66
|
+
// Optional cache fields: absent OR `null` is legitimate (no caching happened; the Messages API
|
|
67
|
+
// types both as `integer | null`) and treated as 0 without setting the split field. A
|
|
68
|
+
// present, non-null, malformed value (negative, non-integer) must not be silently ignored — it
|
|
69
|
+
// falls back to the whole result being `estimated`, same as a malformed base field would.
|
|
70
|
+
const hasCacheCreation = usage.cache_creation_input_tokens != null;
|
|
71
|
+
const hasCacheRead = usage.cache_read_input_tokens != null;
|
|
72
|
+
const cacheCreation = hasCacheCreation ? asNonNegativeInt(usage.cache_creation_input_tokens) : 0;
|
|
73
|
+
const cacheRead = hasCacheRead ? asNonNegativeInt(usage.cache_read_input_tokens) : 0;
|
|
74
|
+
if (cacheCreation === undefined || cacheRead === undefined) return ESTIMATED;
|
|
75
|
+
return {
|
|
76
|
+
tokensIn: baseIn + cacheCreation + cacheRead,
|
|
77
|
+
tokensOut,
|
|
78
|
+
tokensSource: 'reported',
|
|
79
|
+
...(hasCacheCreation ? { cacheCreationTokens: cacheCreation } : {}),
|
|
80
|
+
...(hasCacheRead ? { cacheReadTokens: cacheRead } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {Record<string, unknown>} usage
|
|
86
|
+
* @returns {{tokensIn: number, tokensOut: number} | undefined} both fields from ONE naming scheme
|
|
87
|
+
* only — `input_tokens`/`output_tokens` if BOTH are present and valid, else `prompt_tokens`/
|
|
88
|
+
* `completion_tokens` if BOTH are, else `undefined`. Never mixes a field from one scheme with a
|
|
89
|
+
* field from the other (fix round 1, MINOR).
|
|
90
|
+
*/
|
|
91
|
+
function oneSchemeTokens(usage) {
|
|
92
|
+
const anthropicIn = asNonNegativeInt(usage.input_tokens);
|
|
93
|
+
const anthropicOut = asNonNegativeInt(usage.output_tokens);
|
|
94
|
+
if (anthropicIn !== undefined && anthropicOut !== undefined) {
|
|
95
|
+
return { tokensIn: anthropicIn, tokensOut: anthropicOut };
|
|
96
|
+
}
|
|
97
|
+
const openaiIn = asNonNegativeInt(usage.prompt_tokens);
|
|
98
|
+
const openaiOut = asNonNegativeInt(usage.completion_tokens);
|
|
99
|
+
if (openaiIn !== undefined && openaiOut !== undefined) {
|
|
100
|
+
return { tokensIn: openaiIn, tokensOut: openaiOut };
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Codex `--json` prints one JSONL EVENT per line, not a single result object; usage lives on the
|
|
107
|
+
* turn-completion event (the one carrying a `usage` object — Codex's own docs do not name a fixed
|
|
108
|
+
* event-type string this build can pin against yet, `[A14]`). **The caller is responsible for
|
|
109
|
+
* locating and passing that ONE parsed event object** — this function does not scan a list of
|
|
110
|
+
* events or sum usage across turns; a caller with a multi-turn run must do that summation itself
|
|
111
|
+
* before calling this, or accept per-turn `estimated`/`reported` rows.
|
|
112
|
+
* @param {unknown} parsed - one already-located, already-parsed Codex event object.
|
|
113
|
+
* @returns {UsageResult}
|
|
114
|
+
*/
|
|
115
|
+
function parseCodexUsage(parsed) {
|
|
116
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.usage)) return ESTIMATED;
|
|
117
|
+
const tokens = oneSchemeTokens(parsed.usage);
|
|
118
|
+
return tokens ? { ...tokens, tokensSource: 'reported' } : ESTIMATED;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Grok `usage` subcommand / JSON output: best-effort, same single-scheme field acceptance as
|
|
123
|
+
* Codex — no verified sample exists for either.
|
|
124
|
+
* @param {unknown} parsed
|
|
125
|
+
* @returns {UsageResult}
|
|
126
|
+
*/
|
|
127
|
+
function parseGrokUsage(parsed) {
|
|
128
|
+
return parseCodexUsage(parsed);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @type {Readonly<Record<"claude"|"codex"|"grok", (parsed: unknown) => UsageResult>>} */
|
|
132
|
+
const PARSERS = Object.freeze({ claude: parseClaudeUsage, codex: parseCodexUsage, grok: parseGrokUsage });
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {"claude"|"codex"|"grok"} cli
|
|
136
|
+
* @param {unknown} parsedJSON - already `JSON.parse`d CLI output (this module never parses raw
|
|
137
|
+
* text itself — a malformed JSON string is the caller's problem to report, not silently degrade
|
|
138
|
+
* into "estimated" here).
|
|
139
|
+
* @returns {UsageResult}
|
|
140
|
+
* @throws {Error} for an unknown `cli` — guarded with `Object.hasOwn` so a prototype-chain name
|
|
141
|
+
* (`"constructor"`, `"toString"`, `"__proto__"`, …) hits this same error instead of an unrelated
|
|
142
|
+
* crash or a wrong result (fix round 1, MINOR).
|
|
143
|
+
*/
|
|
144
|
+
export function parseUsage(cli, parsedJSON) {
|
|
145
|
+
if (typeof cli !== 'string' || !Object.hasOwn(PARSERS, cli)) {
|
|
146
|
+
throw new Error(`parseUsage: unknown cli "${cli}"`);
|
|
147
|
+
}
|
|
148
|
+
return PARSERS[/** @type {"claude"|"codex"|"grok"} */ (cli)](parsedJSON);
|
|
149
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance-clause coverage (plan §4.6 row 4, O17): "every clause names ≥ 1 test id; each named
|
|
3
|
+
* test exists in the tree and passed in this gate run (parsed from the runner output)." This is
|
|
4
|
+
* DETERMINISTIC — S1's `scope` question (§3.2) judges only whether hunks OUTSIDE the named tests
|
|
5
|
+
* are unrelated; whether a named test id actually passed is a fact, never a model's opinion.
|
|
6
|
+
*
|
|
7
|
+
* `parseTapPassed` reads real TAP (RFC-ish "TAP version 13") output — the default `node --test`
|
|
8
|
+
* reporter on this Mac (verified: `node --test` with no `--test-reporter` flag prints
|
|
9
|
+
* `TAP version 13` / `ok N - <name>` / `not ok N - <name>`, 2026-09-24) — into passed/failed name
|
|
10
|
+
* sets. A project on a different runner supplies its own `passed` set built however fits that
|
|
11
|
+
* runner's output; `checkAcceptance` itself does not care where the set came from.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{clause: string, tests: string[]}} AcceptanceClause
|
|
16
|
+
* @typedef {{ok: true} | {ok: false, reason: string, clause: string, test: string}} AcceptanceResult
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A TAP directive suffix on a test description (`name # SKIP reason`, `name # TODO reason`). */
|
|
20
|
+
const TAP_DIRECTIVE = /^(.*?)\s*#\s*(SKIP|TODO)\b/i;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} text - the captured `<name...>` portion of an `ok`/`not ok` TAP line.
|
|
24
|
+
* @returns {{name: string, directive: 'SKIP' | 'TODO' | null}}
|
|
25
|
+
*/
|
|
26
|
+
function parseNameAndDirective(text) {
|
|
27
|
+
const m = TAP_DIRECTIVE.exec(text);
|
|
28
|
+
return m ? { name: m[1].trim(), directive: /** @type {'SKIP'|'TODO'} */ (m[2].toUpperCase()) } : { name: text.trim(), directive: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} output - raw TAP text from a test-runner invocation.
|
|
33
|
+
* @returns {{passed: Set<string>, failed: Set<string>}}
|
|
34
|
+
*/
|
|
35
|
+
export function parseTapPassed(output) {
|
|
36
|
+
/** @type {Set<string>} */
|
|
37
|
+
const passed = new Set();
|
|
38
|
+
/** @type {Set<string>} */
|
|
39
|
+
const failed = new Set();
|
|
40
|
+
for (const rawLine of String(output).split('\n')) {
|
|
41
|
+
const line = rawLine.trim();
|
|
42
|
+
const ok = /^ok\s+\d+\s+-\s+(.+)$/.exec(line);
|
|
43
|
+
if (ok) {
|
|
44
|
+
const { name, directive } = parseNameAndDirective(ok[1]);
|
|
45
|
+
// `node --test` reports a skipped or todo test as an "ok" TAP line with a `# SKIP`/`# TODO`
|
|
46
|
+
// directive — it did not actually run to a real pass, so it must NOT satisfy an acceptance
|
|
47
|
+
// clause (O17: "passed in this gate run", not merely "not reported as failed").
|
|
48
|
+
if (directive === null) passed.add(name);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const notOk = /^not ok\s+\d+\s+-\s+(.+)$/.exec(line);
|
|
52
|
+
if (notOk) {
|
|
53
|
+
failed.add(parseNameAndDirective(notOk[1]).name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { passed, failed };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {ReadonlyArray<AcceptanceClause>} clauses - each must name ≥ 1 test id.
|
|
61
|
+
* @param {Set<string> | ReadonlyArray<string>} passed - test ids that passed in THIS gate run.
|
|
62
|
+
* @param {Set<string> | ReadonlyArray<string>} [failed] - test ids that FAILED in this gate run.
|
|
63
|
+
* `parseTapPassed` flattens every file's/describe-block's TAP subtests into one global name set,
|
|
64
|
+
* so two unrelated tests can share a name; when one passes and the other fails, that name lands
|
|
65
|
+
* in BOTH sets. `failed` is checked independently of `passed` so that ambiguity never accepts a
|
|
66
|
+
* clause on the strength of some OTHER test with the same name having passed.
|
|
67
|
+
* @returns {AcceptanceResult} refused (naming the offending clause and test) on the FIRST clause
|
|
68
|
+
* whose acceptance is not fully covered; `{ok: true}` only when every clause's every named test
|
|
69
|
+
* id is in `passed` and NOT in `failed`.
|
|
70
|
+
*/
|
|
71
|
+
export function checkAcceptance(clauses, passed, failed = new Set()) {
|
|
72
|
+
if (!Array.isArray(clauses) || clauses.length === 0) {
|
|
73
|
+
return { ok: false, reason: 'no acceptance clauses given', clause: '', test: '' };
|
|
74
|
+
}
|
|
75
|
+
const passedSet = passed instanceof Set ? passed : new Set(passed);
|
|
76
|
+
const failedSet = failed instanceof Set ? failed : new Set(failed);
|
|
77
|
+
for (const entry of clauses) {
|
|
78
|
+
const clause = entry?.clause;
|
|
79
|
+
const tests = entry?.tests;
|
|
80
|
+
if (typeof clause !== 'string' || clause.length === 0 || !Array.isArray(tests) || tests.length === 0) {
|
|
81
|
+
return { ok: false, reason: 'clause names no test id', clause: String(clause ?? ''), test: '' };
|
|
82
|
+
}
|
|
83
|
+
for (const test of tests) {
|
|
84
|
+
if (failedSet.has(test) || !passedSet.has(test)) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: `clause "${clause}" names test "${test}", which did not exist or did not pass in this gate run`,
|
|
88
|
+
clause,
|
|
89
|
+
test,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { ok: true };
|
|
95
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate auto-detection (plan §2.2, `src/gates/detect.mjs`): given a project root, decide which
|
|
3
|
+
* stack it is and produce the four gate commands (`test`, `lint`, `types`, `format`) as argv
|
|
4
|
+
* arrays, or `null` when the stack has no tool for that gate. Detection is pure filesystem
|
|
5
|
+
* evidence (`fs.existsSync`/`readFileSync`) — no `git`, no child process, no network.
|
|
6
|
+
*
|
|
7
|
+
* Evidence is checked in the table's own order (PHP/Pest, then `package.json` scripts, Cargo,
|
|
8
|
+
* pyproject, go.mod); the first stack whose marker file exists wins. A project with no marker
|
|
9
|
+
* file at all is `unknown`: every gate is `null`, and `code` (a later block) refuses to run a
|
|
10
|
+
* block whose `gates.test` is null — a block without a test gate cannot produce evidence.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A gate command is normally a plain argv array (its own exit code decides red/green). Some tools
|
|
18
|
+
* (`gofmt -l`) exit 0 regardless of whether anything is unformatted and instead print the
|
|
19
|
+
* offending file names to stdout — for those, the descriptor carries `failOnStdout: true` so
|
|
20
|
+
* `run.mjs` treats non-empty stdout as red too, since a bare exit-code check can never fail.
|
|
21
|
+
* @typedef {string[] | {argv: string[], failOnStdout: true}} GateCommand
|
|
22
|
+
* @typedef {GateCommand | null} Gate
|
|
23
|
+
*/
|
|
24
|
+
/** @typedef {{stack: string, test: Gate, lint: Gate, types: Gate, format: Gate}} DetectedGates */
|
|
25
|
+
|
|
26
|
+
/** @param {string} root @param {string} rel @returns {boolean} */
|
|
27
|
+
function has(root, rel) {
|
|
28
|
+
return existsSync(path.join(root, rel));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} root @param {string} rel
|
|
33
|
+
* @returns {Record<string, any> | null} parsed JSON, or null when the file is absent or invalid.
|
|
34
|
+
*/
|
|
35
|
+
function readJSON(root, rel) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string} root @param {string} rel @returns {string | null} */
|
|
44
|
+
function readText(root, rel) {
|
|
45
|
+
try {
|
|
46
|
+
return readFileSync(path.join(root, rel), 'utf8');
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── PHP / Pest ───────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** @param {string} root @returns {DetectedGates | null} */
|
|
55
|
+
function detectPhp(root) {
|
|
56
|
+
if (!has(root, 'composer.json') || !has(root, 'vendor/bin/pest')) return null;
|
|
57
|
+
const test = has(root, 'artisan') ? ['php', 'artisan', 'test', '--compact'] : ['vendor/bin/pest'];
|
|
58
|
+
const types = has(root, 'phpstan.neon') || has(root, 'phpstan.neon.dist') ? ['vendor/bin/phpstan'] : null;
|
|
59
|
+
const format = has(root, 'pint.json') || has(root, 'vendor/bin/pint') ? ['vendor/bin/pint', '--test'] : null;
|
|
60
|
+
return { stack: 'php-pest', test, lint: null, types, format };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Node (package.json scripts) ────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `pm` resolution order (§2.2): the `packageManager` field, then the lockfile present
|
|
67
|
+
* (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn), else `npm`.
|
|
68
|
+
* @param {string} root @param {Record<string, any>} pkg @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function resolvePackageManager(root, pkg) {
|
|
71
|
+
if (typeof pkg.packageManager === 'string' && pkg.packageManager.length > 0) {
|
|
72
|
+
const name = pkg.packageManager.split('@')[0];
|
|
73
|
+
if (name === 'pnpm' || name === 'yarn' || name === 'npm' || name === 'bun') return name;
|
|
74
|
+
}
|
|
75
|
+
if (has(root, 'pnpm-lock.yaml')) return 'pnpm';
|
|
76
|
+
if (has(root, 'yarn.lock')) return 'yarn';
|
|
77
|
+
return 'npm';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A `package.json` `devDependencies`/`dependencies` entry named `dep`.
|
|
82
|
+
* @param {Record<string, any>} pkg @param {string} dep @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
function hasDependency(pkg, dep) {
|
|
85
|
+
return Boolean(pkg.dependencies?.[dep] || pkg.devDependencies?.[dep]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} root @returns {DetectedGates | null} */
|
|
89
|
+
function detectNode(root) {
|
|
90
|
+
const pkg = readJSON(root, 'package.json');
|
|
91
|
+
if (!pkg || typeof pkg !== 'object') return null;
|
|
92
|
+
const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
93
|
+
if (typeof scripts.test !== 'string') return null;
|
|
94
|
+
|
|
95
|
+
const pm = resolvePackageManager(root, pkg);
|
|
96
|
+
// `bun test` invokes Bun's OWN built-in test runner and ignores the package.json `test` script
|
|
97
|
+
// entirely — `bun run test` is the form that actually runs the configured script, matching
|
|
98
|
+
// every other package manager's `[pm, "test"]` shorthand behaviour.
|
|
99
|
+
const test = pm === 'bun' ? [pm, 'run', 'test'] : [pm, 'test'];
|
|
100
|
+
|
|
101
|
+
let lint = null;
|
|
102
|
+
if (typeof scripts.lint === 'string') lint = [pm, 'run', 'lint'];
|
|
103
|
+
else if (typeof scripts['lint:check'] === 'string') lint = [pm, 'run', 'lint:check'];
|
|
104
|
+
|
|
105
|
+
let types = null;
|
|
106
|
+
if (typeof scripts.typecheck === 'string') types = [pm, 'run', 'typecheck'];
|
|
107
|
+
else if (typeof scripts['vue-tsc'] === 'string') types = [pm, 'run', 'vue-tsc'];
|
|
108
|
+
else if (has(root, 'tsconfig.json')) types = ['npx', 'tsc', '--noEmit'];
|
|
109
|
+
|
|
110
|
+
let format = null;
|
|
111
|
+
if (typeof scripts['format:check'] === 'string') format = [pm, 'run', 'format:check'];
|
|
112
|
+
else if (has(root, '.prettierrc') || has(root, '.prettierrc.json') || has(root, '.prettierrc.js') || hasDependency(pkg, 'prettier')) {
|
|
113
|
+
format = ['npx', 'prettier', '--check', '.'];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { stack: 'node', test, lint, types, format };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Cargo ───────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** @param {string} root @returns {DetectedGates | null} */
|
|
122
|
+
function detectRust(root) {
|
|
123
|
+
if (!has(root, 'Cargo.toml')) return null;
|
|
124
|
+
return {
|
|
125
|
+
stack: 'rust',
|
|
126
|
+
test: ['cargo', 'test'],
|
|
127
|
+
lint: ['cargo', 'clippy', '--', '-D', 'warnings'],
|
|
128
|
+
types: null,
|
|
129
|
+
format: ['cargo', 'fmt', '--check'],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Python (pyproject.toml) ─────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/** @param {string} text @returns {boolean} a `[tool.mypy]` section is present. */
|
|
136
|
+
function hasMypySection(text) {
|
|
137
|
+
return /^\[tool\.mypy(\..+)?\]\s*$/m.test(text);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {string} root @returns {DetectedGates | null} */
|
|
141
|
+
function detectPython(root) {
|
|
142
|
+
const text = readText(root, 'pyproject.toml');
|
|
143
|
+
if (text === null) return null;
|
|
144
|
+
return {
|
|
145
|
+
stack: 'python',
|
|
146
|
+
test: ['pytest'],
|
|
147
|
+
lint: ['ruff', 'check'],
|
|
148
|
+
types: hasMypySection(text) ? ['mypy'] : null,
|
|
149
|
+
format: ['ruff', 'format', '--check'],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Go ───────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/** @param {string} root @returns {DetectedGates | null} */
|
|
156
|
+
function detectGo(root) {
|
|
157
|
+
if (!has(root, 'go.mod')) return null;
|
|
158
|
+
return {
|
|
159
|
+
stack: 'go',
|
|
160
|
+
test: ['go', 'test', './...'],
|
|
161
|
+
lint: ['go', 'vet', './...'],
|
|
162
|
+
types: null,
|
|
163
|
+
// `gofmt -l` prints the names of unformatted files to stdout but ALWAYS exits 0 — a bare
|
|
164
|
+
// exit-code check can never turn this gate red. `failOnStdout` tells run.mjs to also treat
|
|
165
|
+
// non-empty stdout as a failure.
|
|
166
|
+
format: { argv: ['gofmt', '-l', '.'], failOnStdout: true },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Detectors in table order — the first stack whose marker evidence exists wins. */
|
|
171
|
+
const DETECTORS = Object.freeze([detectPhp, detectNode, detectRust, detectPython, detectGo]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} root - an absolute path to a project root.
|
|
175
|
+
* @returns {DetectedGates} the detected stack and its four gate commands (argv or null each).
|
|
176
|
+
* `stack: 'unknown'` with every gate `null` when no marker evidence matches.
|
|
177
|
+
*/
|
|
178
|
+
export function detectGates(root) {
|
|
179
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
180
|
+
throw new TypeError('detectGates: root must be a non-empty string');
|
|
181
|
+
}
|
|
182
|
+
for (const detector of DETECTORS) {
|
|
183
|
+
const result = detector(root);
|
|
184
|
+
if (result) return result;
|
|
185
|
+
}
|
|
186
|
+
return { stack: 'unknown', test: null, lint: null, types: null, format: null };
|
|
187
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The invariants gate (plan §1.3 `gates.extra.invariants`: a project-configured PATH, unlike
|
|
3
|
+
* `secret_scan`/`safe_edit` which are booleans). A project may keep its own structural checks —
|
|
4
|
+
* comment-only diffs, forbidden patterns, naming conventions — as one executable script; when
|
|
5
|
+
* `gates.extra.invariants` names it, this gate runs it exactly like `run.mjs` runs `test`/`lint`/
|
|
6
|
+
* `types`/`format`: one argv, its own exit status read on its own, never chained.
|
|
7
|
+
*
|
|
8
|
+
* Unset (`undefined`/empty) is a pass-through, not a failure — a project with no invariants script
|
|
9
|
+
* simply has nothing extra to check.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { exec } from '../util/exec.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} InvariantsResult
|
|
17
|
+
* @property {boolean} skipped - true when no `invariantsPath` was configured.
|
|
18
|
+
* @property {boolean} ok
|
|
19
|
+
* @property {number | null} code
|
|
20
|
+
* @property {string} stdout
|
|
21
|
+
* @property {string} stderr
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{cwd: string, invariantsPath?: string | null, timeoutMs?: number}} opts
|
|
26
|
+
* @returns {Promise<InvariantsResult>}
|
|
27
|
+
*/
|
|
28
|
+
export async function runInvariants(opts) {
|
|
29
|
+
const { cwd, invariantsPath, timeoutMs = 120000 } = opts ?? {};
|
|
30
|
+
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
31
|
+
throw new TypeError('runInvariants: opts.cwd must be a non-empty string');
|
|
32
|
+
}
|
|
33
|
+
if (!invariantsPath) {
|
|
34
|
+
return { skipped: true, ok: true, code: null, stdout: '', stderr: '' };
|
|
35
|
+
}
|
|
36
|
+
// A bare filename (no slash) would otherwise be looked up on PATH instead of the project root,
|
|
37
|
+
// and a relative path would resolve against the CHILD process's cwd only by accident (it
|
|
38
|
+
// happens to be the same as `cwd` here, but that's not guaranteed once this call has a caller
|
|
39
|
+
// with its own cwd) — resolve against the project root explicitly either way.
|
|
40
|
+
const resolvedPath = path.resolve(cwd, invariantsPath);
|
|
41
|
+
const res = await exec([resolvedPath], { cwd, timeoutMs });
|
|
42
|
+
return { skipped: false, ok: res.result === 'ok', code: res.code, stdout: res.stdout, stderr: res.stderr };
|
|
43
|
+
}
|