@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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate runner (plan §4.6 row 3: "all gates green in one run — each exit status read"). Runs
|
|
3
|
+
* every configured gate command (`test`, `lint`, `types`, `format`) INDEPENDENTLY — never chained
|
|
4
|
+
* with `&&`, so a red gate never hides the ones after it: "a red gate in the middle of 3 is
|
|
5
|
+
* reported red" means each of the three still gets its own read exit status, not that the run
|
|
6
|
+
* stops early.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { exec } from '../util/exec.mjs';
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('./detect.mjs').Gate} Gate */
|
|
12
|
+
/** @typedef {import('./detect.mjs').GateCommand} GateCommand */
|
|
13
|
+
/** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} GateResult
|
|
17
|
+
* @property {'test'|'lint'|'types'|'format'} gate
|
|
18
|
+
* @property {string[] | null} argv
|
|
19
|
+
* @property {boolean} skipped - true when the gate has no configured command (argv was null).
|
|
20
|
+
* @property {boolean} ok - true when skipped, or when the command exited with code 0 (and, for a
|
|
21
|
+
* `failOnStdout` gate, its stdout was empty).
|
|
22
|
+
* @property {number | null} code
|
|
23
|
+
* @property {string} stdout
|
|
24
|
+
* @property {string} stderr
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Fixed run order — always all four, regardless of an earlier one's result. */
|
|
28
|
+
const GATE_ORDER = /** @type {const} */ (['test', 'lint', 'types', 'format']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A gate command is either a plain argv array or `{argv, failOnStdout: true}` (detect.mjs's
|
|
32
|
+
* `GateCommand`, e.g. `gofmt -l` which always exits 0). Normalized here so the run loop below has
|
|
33
|
+
* one shape to deal with.
|
|
34
|
+
* @param {GateCommand} command
|
|
35
|
+
* @returns {{argv: string[], failOnStdout: boolean}}
|
|
36
|
+
*/
|
|
37
|
+
function normalizeCommand(command) {
|
|
38
|
+
if (Array.isArray(command)) return { argv: command, failOnStdout: false };
|
|
39
|
+
return { argv: command.argv, failOnStdout: command.failOnStdout === true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
|
|
44
|
+
* @param {{cwd: string, timeoutMs?: number}} opts
|
|
45
|
+
* @returns {Promise<{results: GateResult[], allOk: boolean}>}
|
|
46
|
+
*/
|
|
47
|
+
export async function runGates(gates, opts) {
|
|
48
|
+
if (!gates || typeof gates !== 'object') {
|
|
49
|
+
throw new TypeError('runGates: gates must be an object with test/lint/types/format');
|
|
50
|
+
}
|
|
51
|
+
const { cwd, timeoutMs = 300000 } = opts ?? {};
|
|
52
|
+
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
53
|
+
throw new TypeError('runGates: opts.cwd must be a non-empty string');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @type {GateResult[]} */
|
|
57
|
+
const results = [];
|
|
58
|
+
for (const gate of GATE_ORDER) {
|
|
59
|
+
const command = gates[gate] ?? null;
|
|
60
|
+
if (command === null) {
|
|
61
|
+
results.push({ gate, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: '' });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const { argv, failOnStdout } = normalizeCommand(command);
|
|
65
|
+
// Each command is run and its exit status read on its own — never `&&`-chained with the
|
|
66
|
+
// next, so a red gate is reported red without swallowing the gates that follow it.
|
|
67
|
+
const res = await exec(argv, { cwd, timeoutMs });
|
|
68
|
+
const ok = res.result === 'ok' && (!failOnStdout || res.stdout.trim().length === 0);
|
|
69
|
+
results.push({ gate, argv, skipped: false, ok, code: res.code, stdout: res.stdout, stderr: res.stderr });
|
|
70
|
+
}
|
|
71
|
+
return { results, allOk: results.every((r) => r.ok) };
|
|
72
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The safe-edit gate (plan `gates.extra.safe_edit`, default true): abort a block whose diff
|
|
3
|
+
* deletes a test file, or whose test-declaration count shrinks by more than 20 % against the
|
|
4
|
+
* block's base — the two ways a coder can make a red suite look green by removing the evidence
|
|
5
|
+
* instead of fixing the code, per the project's own "verification remains mandatory" rule.
|
|
6
|
+
*
|
|
7
|
+
* The decision (`safeEdit`) is a PURE function over two `{file: count}` maps so it is trivial to
|
|
8
|
+
* test and cannot itself run a process; `checkSafeEdit` is the thin git-backed orchestration that
|
|
9
|
+
* builds those maps for a real base/working-tree pair. Every git call strips inherited `GIT_*` env
|
|
10
|
+
* (a hook's `GIT_DIR` would silently redirect the read) and disables system/global config.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { exec } from '../util/exec.mjs';
|
|
17
|
+
import { showArgv } from '../util/git.mjs';
|
|
18
|
+
|
|
19
|
+
/** Fraction of the base test count below which the shrink aborts the gate (a >20% shrink). */
|
|
20
|
+
export const SHRINK_THRESHOLD = 0.8;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* File-name → stack, by basename pattern. A LIST (not a map keyed by stack) checked in order,
|
|
24
|
+
* because file-classification and declaration-counting must agree on exactly one stack per file —
|
|
25
|
+
* summing every stack's declaration pattern over one file double-counts wherever two stacks'
|
|
26
|
+
* patterns overlap (PHP's `\btest\(`/`\bit\(` also match plain JS `test('x', ...)`/`it('x', ...)`
|
|
27
|
+
* calls character-for-character), which is exactly the bug this per-file classification avoids.
|
|
28
|
+
* @type {ReadonlyArray<{stack: string, pattern: RegExp}>}
|
|
29
|
+
*/
|
|
30
|
+
const TEST_FILE_KINDS = Object.freeze([
|
|
31
|
+
{ stack: 'node', pattern: /\.test\.[cm]?[jt]sx?$/ }, // foo.test.mjs, foo.test.ts
|
|
32
|
+
{ stack: 'node', pattern: /\.spec\.[cm]?[jt]sx?$/ },
|
|
33
|
+
{ stack: 'php', pattern: /Test\.php$/ }, // PHPUnit/Pest class-style
|
|
34
|
+
{ stack: 'python', pattern: /^test_.*\.py$/ }, // pytest
|
|
35
|
+
{ stack: 'go', pattern: /_test\.go$/ },
|
|
36
|
+
// Rust has no test-FILE naming convention (a #[test] can live in any .rs file) — safe-edit's
|
|
37
|
+
// file-level "deleted test file" check does not apply to it; `countTestDeclarations(x, 'rust')`
|
|
38
|
+
// is still directly usable by a caller that already knows a given file is a rust test module.
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Per-stack test-declaration regexes (global, so `matchAll` can count every occurrence). */
|
|
42
|
+
const TEST_DECL_PATTERNS = Object.freeze({
|
|
43
|
+
node: /\b(?:test|it)\s*\(/g,
|
|
44
|
+
php: /(?:public\s+function\s+test[A-Z_]\w*\s*\(|\bit\s*\(|\btest\s*\()/g,
|
|
45
|
+
python: /^\s*def test_\w+/gm,
|
|
46
|
+
rust: /#\[test\]/g,
|
|
47
|
+
go: /^func Test[A-Z]\w*/gm,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} filePath
|
|
52
|
+
* @returns {string | null} the stack `filePath`'s basename matches, by naming convention, or
|
|
53
|
+
* `null` when none does — the file is not treated as a test file at all.
|
|
54
|
+
*/
|
|
55
|
+
function classifyTestFile(filePath) {
|
|
56
|
+
const base = path.basename(filePath);
|
|
57
|
+
const kind = TEST_FILE_KINDS.find(({ pattern }) => pattern.test(base));
|
|
58
|
+
return kind ? kind.stack : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} filePath
|
|
63
|
+
* @returns {boolean} whether `filePath`'s basename matches a known test-file naming convention.
|
|
64
|
+
*/
|
|
65
|
+
export function isTestFile(filePath) {
|
|
66
|
+
return classifyTestFile(filePath) !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Count test declarations in `content`, using ONLY the given stack's pattern — never every
|
|
71
|
+
* pattern summed, which would double-count where two stacks' patterns overlap (see
|
|
72
|
+
* `TEST_FILE_KINDS`'s doc). A caller that does not already know the file's stack should classify
|
|
73
|
+
* it first (`isTestFile` alone only says yes/no; this module classifies internally via
|
|
74
|
+
* `classifyTestFile` for its own git-backed orchestration below).
|
|
75
|
+
* @param {string} content @param {string} stack - one of `TEST_DECL_PATTERNS`'s keys.
|
|
76
|
+
* @returns {number}
|
|
77
|
+
* @throws {TypeError} on an unknown stack.
|
|
78
|
+
*/
|
|
79
|
+
export function countTestDeclarations(content, stack) {
|
|
80
|
+
const pattern = /** @type {Record<string, RegExp>} */ (TEST_DECL_PATTERNS)[stack];
|
|
81
|
+
if (!pattern) {
|
|
82
|
+
throw new TypeError(`countTestDeclarations: unknown stack ${JSON.stringify(stack)} (known: ${Object.keys(TEST_DECL_PATTERNS).join(', ')})`);
|
|
83
|
+
}
|
|
84
|
+
// The pattern object carries the `g` flag's own `lastIndex`; matchAll on a fresh copy avoids
|
|
85
|
+
// any stateful reuse across calls.
|
|
86
|
+
return [...content.matchAll(new RegExp(pattern.source, pattern.flags))].length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {Record<string, number>} CountsByFile - test-declaration count per test-file path.
|
|
91
|
+
* @typedef {{ok: true} | {ok: false, reason: string}} SafeEditResult
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The pure decision (plan acceptance: "aborts on a >20% test-count shrink and on a deleted test
|
|
96
|
+
* file"). Deleted-file check runs first: a deleted file makes the shrink check redundant but a
|
|
97
|
+
* caller should see the more specific reason.
|
|
98
|
+
* @param {{base: CountsByFile, current: CountsByFile}} counts
|
|
99
|
+
* @returns {SafeEditResult}
|
|
100
|
+
*/
|
|
101
|
+
export function safeEdit({ base, current }) {
|
|
102
|
+
const deleted = Object.keys(base).filter((file) => !(file in current));
|
|
103
|
+
if (deleted.length > 0) {
|
|
104
|
+
return { ok: false, reason: `deleted test file(s): ${deleted.sort().join(', ')}` };
|
|
105
|
+
}
|
|
106
|
+
const totalBase = Object.values(base).reduce((a, b) => a + b, 0);
|
|
107
|
+
const totalCurrent = Object.values(current).reduce((a, b) => a + b, 0);
|
|
108
|
+
if (totalBase > 0 && totalCurrent < totalBase * SHRINK_THRESHOLD) {
|
|
109
|
+
return { ok: false, reason: `test count shrank from ${totalBase} to ${totalCurrent} (more than 20%)` };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `process.env` minus every inherited `GIT_*`, with no system/global config. */
|
|
115
|
+
function gitEnv() {
|
|
116
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
117
|
+
const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
|
|
118
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
119
|
+
if (!key.startsWith('GIT_')) env[key] = value;
|
|
120
|
+
}
|
|
121
|
+
return env;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const GIT_TIMEOUT_MS = 30000;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `git ls-tree -r -z --name-only <base>` — NUL-terminated, unquoted paths, the same `-z`
|
|
128
|
+
* convention `gates/scope.mjs` uses and this file's own `currentTestFiles` already uses for the
|
|
129
|
+
* CURRENT side. Without `-z`, git C-quotes a path containing non-ASCII bytes, `"`, `\` or control
|
|
130
|
+
* characters (e.g. `café.test.mjs` → `"caf\303\251.test.mjs"`); a newline-split reader then gets
|
|
131
|
+
* that quoted literal, whose basename ends in `.mjs"` — `classifyTestFile` no longer recognizes
|
|
132
|
+
* it as a test file at all, so a deleted quoted-name test file is silently dropped from the base
|
|
133
|
+
* count instead of tripping the deleted-file check. Not one of `util/git.mjs`'s read-only
|
|
134
|
+
* builders (its `lsFilesArgv` deliberately refuses any `-`-leading element, so this couldn't be
|
|
135
|
+
* built through it either way); `base` is guarded the same way those builders guard their own ref
|
|
136
|
+
* arguments.
|
|
137
|
+
* @param {string} base @returns {string[]}
|
|
138
|
+
*/
|
|
139
|
+
function lsTreeArgv(base) {
|
|
140
|
+
if (typeof base !== 'string' || base.length === 0 || base.startsWith('-')) {
|
|
141
|
+
throw new TypeError(`safe-edit: base must be a non-empty string not starting with "-" (got ${JSON.stringify(base)})`);
|
|
142
|
+
}
|
|
143
|
+
return ['git', 'ls-tree', '-r', '-z', '--name-only', base];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} base @param {string} cwd
|
|
148
|
+
* @returns {Promise<{file: string, stack: string}[]>} every test file tracked at `base`, with the
|
|
149
|
+
* stack its name classified to.
|
|
150
|
+
*/
|
|
151
|
+
async function testFilesAtBase(base, cwd) {
|
|
152
|
+
const res = await exec(lsTreeArgv(base), { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
153
|
+
if (res.result !== 'ok') {
|
|
154
|
+
throw new Error(`safe-edit: git ls-tree -r -z --name-only ${base} failed (exit ${res.code}): ${res.stderr}`);
|
|
155
|
+
}
|
|
156
|
+
/** @type {{file: string, stack: string}[]} */
|
|
157
|
+
const testFiles = [];
|
|
158
|
+
for (const file of splitNul(res.stdout)) {
|
|
159
|
+
const stack = classifyTestFile(file);
|
|
160
|
+
if (stack !== null) testFiles.push({ file, stack });
|
|
161
|
+
}
|
|
162
|
+
return testFiles;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} base @param {string} filePath @param {string} stack @param {string} cwd
|
|
167
|
+
* @returns {Promise<number>} test-declaration count of `filePath` AT `base`.
|
|
168
|
+
*/
|
|
169
|
+
async function countAtBase(base, filePath, stack, cwd) {
|
|
170
|
+
const res = await exec(showArgv(base, filePath), { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
171
|
+
if (res.result !== 'ok') {
|
|
172
|
+
throw new Error(`safe-edit: git show ${base}:${filePath} failed (exit ${res.code}): ${res.stderr}`);
|
|
173
|
+
}
|
|
174
|
+
return countTestDeclarations(res.stdout, stack);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `git ls-files -z` (tracked, incl. staged) — NUL-terminated so a path with whitespace/quotes is
|
|
179
|
+
* read exactly, matching `gates/scope.mjs`'s `-z` convention.
|
|
180
|
+
* @returns {string[]}
|
|
181
|
+
*/
|
|
182
|
+
function lsFilesZArgv() {
|
|
183
|
+
return ['git', 'ls-files', '-z'];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `git ls-files -z --others --exclude-standard` — untracked, not gitignored. @returns {string[]} */
|
|
187
|
+
function lsFilesOthersZArgv() {
|
|
188
|
+
return ['git', 'ls-files', '-z', '--others', '--exclude-standard'];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @param {string} text @returns {string[]} NUL-split entries, empty ones dropped, never trimmed. */
|
|
192
|
+
function splitNul(text) {
|
|
193
|
+
return text.split('\0').filter((entry) => entry.length > 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Every CURRENT test file in the working tree — tracked union untracked-but-not-ignored — each
|
|
198
|
+
* classified to its stack. This is deliberately NOT limited to base's file list: a test moved
|
|
199
|
+
* from one file to a NEW one (a coder splitting `a.test.mjs`'s 10 tests into `a.test.mjs` (2) +
|
|
200
|
+
* `b.test.mjs` (8), still 10 total) must still be counted, or the shrink check would misread a
|
|
201
|
+
* pure reorganization as a real loss of coverage.
|
|
202
|
+
* @param {string} cwd
|
|
203
|
+
* @returns {Promise<{file: string, stack: string}[]>}
|
|
204
|
+
*/
|
|
205
|
+
async function currentTestFiles(cwd) {
|
|
206
|
+
const [tracked, untracked] = await Promise.all([
|
|
207
|
+
exec(lsFilesZArgv(), { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS }),
|
|
208
|
+
exec(lsFilesOthersZArgv(), { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS }),
|
|
209
|
+
]);
|
|
210
|
+
if (tracked.result !== 'ok') {
|
|
211
|
+
throw new Error(`safe-edit: git ls-files -z failed (exit ${tracked.code}): ${tracked.stderr}`);
|
|
212
|
+
}
|
|
213
|
+
if (untracked.result !== 'ok') {
|
|
214
|
+
throw new Error(`safe-edit: git ls-files -z --others --exclude-standard failed (exit ${untracked.code}): ${untracked.stderr}`);
|
|
215
|
+
}
|
|
216
|
+
const files = new Set([...splitNul(tracked.stdout), ...splitNul(untracked.stdout)]);
|
|
217
|
+
/** @type {{file: string, stack: string}[]} */
|
|
218
|
+
const testFiles = [];
|
|
219
|
+
for (const file of files) {
|
|
220
|
+
const stack = classifyTestFile(file);
|
|
221
|
+
if (stack !== null) testFiles.push({ file, stack });
|
|
222
|
+
}
|
|
223
|
+
return testFiles;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {string} filePath @param {string} stack @param {string} cwd
|
|
228
|
+
* @returns {Promise<number | null>} test-declaration count of `filePath` on DISK, or `null` when
|
|
229
|
+
* it no longer exists there (a race between listing it and reading it — not the normal "was it
|
|
230
|
+
* deleted" question, which `safeEdit` answers from the base/current file-name sets themselves).
|
|
231
|
+
*/
|
|
232
|
+
async function countOnDisk(filePath, stack, cwd) {
|
|
233
|
+
try {
|
|
234
|
+
const content = await readFile(path.join(cwd, filePath), 'utf8');
|
|
235
|
+
return countTestDeclarations(content, stack);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return null;
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The git-backed orchestration: lists test files tracked at `base`, and SEPARATELY every test file
|
|
244
|
+
* present in the CURRENT working tree (tracked + untracked, not just base's own file list — see
|
|
245
|
+
* `currentTestFiles`'s doc), counts each with its own stack's pattern, then applies `safeEdit`.
|
|
246
|
+
* @param {{cwd: string, base: string}} opts
|
|
247
|
+
* @returns {Promise<SafeEditResult>}
|
|
248
|
+
*/
|
|
249
|
+
export async function checkSafeEdit({ cwd, base }) {
|
|
250
|
+
const [baseFiles, currentFiles] = await Promise.all([testFilesAtBase(base, cwd), currentTestFiles(cwd)]);
|
|
251
|
+
|
|
252
|
+
/** @type {CountsByFile} */
|
|
253
|
+
const baseCounts = {};
|
|
254
|
+
for (const { file, stack } of baseFiles) {
|
|
255
|
+
baseCounts[file] = await countAtBase(base, file, stack, cwd);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @type {CountsByFile} */
|
|
259
|
+
const currentCounts = {};
|
|
260
|
+
for (const { file, stack } of currentFiles) {
|
|
261
|
+
const count = await countOnDisk(file, stack, cwd);
|
|
262
|
+
if (count !== null) currentCounts[file] = count;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return safeEdit({ base: baseCounts, current: currentCounts });
|
|
266
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The block gate's file set (plan §4.1, O1) and the shared-tree scope check (§4.7, O2).
|
|
3
|
+
*
|
|
4
|
+
* File set = `git diff --name-only -z <base>` (tracked changes) ∪ untracked files from
|
|
5
|
+
* `git status --porcelain -z --untracked-files=all`. **`-z` is load-bearing, not cosmetic:**
|
|
6
|
+
* without it, git wraps any path containing whitespace, quotes, control characters or (under the
|
|
7
|
+
* default `core.quotePath`) non-ASCII bytes in C-style quotes (`"a b.txt"`, `"caf\303\251.md"`),
|
|
8
|
+
* so a newline-split reader gets the quoted literal instead of the real path and every later
|
|
9
|
+
* `ownsFile`/`scopeGate` lookup silently fails to match it — a real file becomes a phantom orphan
|
|
10
|
+
* (or a wrongly-missed out-of-scope file). `-z` disables quoting entirely and NUL-terminates each
|
|
11
|
+
* entry instead, so paths are read as their exact raw bytes; `splitNul` below never `.trim()`s an
|
|
12
|
+
* entry, because a real leading/trailing space in a filename is data, not padding.
|
|
13
|
+
*
|
|
14
|
+
* The scope MATHS itself (which block owns which file, which files are orphans) already lives in
|
|
15
|
+
* B8's `state/registry.mjs` — this module only supplies the file set that maths runs on (the
|
|
16
|
+
* registry's own doc: "the caller supplies the file set … B5 computes it"), and re-exports the
|
|
17
|
+
* registry functions so a consumer needs one import.
|
|
18
|
+
*
|
|
19
|
+
* Every `git` call here strips inherited `GIT_*` env vars (a hook can export `GIT_DIR`/
|
|
20
|
+
* `GIT_WORK_TREE`, silently redirecting the read to an unrelated repo) and disables system/global
|
|
21
|
+
* git config, the same guard every other git-calling module in this package uses. `base` is
|
|
22
|
+
* rejected outright when it starts with `-` (`assertRefNoLeadingDash`) — the SAME argv-injection
|
|
23
|
+
* guard `util/git.mjs`'s builders apply to their own ref arguments (`--output=/some/path` would
|
|
24
|
+
* otherwise be read by git as an OPTION, not a revision) — reimplemented locally here because the
|
|
25
|
+
* `-z` forms below are not among `util/git.mjs`'s existing read-only builders.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import { exec } from '../util/exec.mjs';
|
|
30
|
+
import { diffNoIndexArgv } from '../util/git.mjs';
|
|
31
|
+
|
|
32
|
+
export { computeScopes, ownsFile, scopeGate } from '../state/registry.mjs';
|
|
33
|
+
|
|
34
|
+
/** `process.env` minus every inherited `GIT_*`, with no system/global config. */
|
|
35
|
+
function gitEnv() {
|
|
36
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
37
|
+
const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
|
|
38
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
39
|
+
if (!key.startsWith('GIT_')) env[key] = value;
|
|
40
|
+
}
|
|
41
|
+
return env;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const GIT_TIMEOUT_MS = 30000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reject a git ref argument that starts with `-` before it ever reaches argv — the same guard
|
|
48
|
+
* `util/git.mjs`'s `assertNoLeadingDash` applies, reimplemented here because the `-z` builders
|
|
49
|
+
* below are local (not one of `util/git.mjs`'s existing read-only builders).
|
|
50
|
+
* @param {unknown} value @param {string} name @returns {string}
|
|
51
|
+
* @throws {TypeError}
|
|
52
|
+
*/
|
|
53
|
+
function assertRefNoLeadingDash(value, name) {
|
|
54
|
+
if (typeof value !== 'string' || value.length === 0 || value.startsWith('-')) {
|
|
55
|
+
throw new TypeError(`${name} must be a non-empty string not starting with "-" (got ${JSON.stringify(value)}) — option injection`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `git diff --name-only -z <base>` — NUL-terminated, unquoted paths (see the module doc).
|
|
62
|
+
* @param {string} base @returns {string[]}
|
|
63
|
+
*/
|
|
64
|
+
function diffNameOnlyZArgv(base) {
|
|
65
|
+
return ['git', 'diff', '--name-only', '-z', assertRefNoLeadingDash(base, 'base')];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `git status --porcelain -z --untracked-files=all` — NUL-terminated, unquoted entries. */
|
|
69
|
+
function statusZArgv() {
|
|
70
|
+
return ['git', 'status', '--porcelain', '-z', '--untracked-files=all'];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Split a NUL-terminated `-z` git output into its entries, dropping the trailing empty entry the
|
|
75
|
+
* final terminator produces. Never trims — a real leading/trailing space in a path is data.
|
|
76
|
+
* @param {string} text @returns {string[]}
|
|
77
|
+
*/
|
|
78
|
+
function splitNul(text) {
|
|
79
|
+
return text.split('\0').filter((entry) => entry.length > 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {object} FileSet
|
|
84
|
+
* @property {string[]} tracked - changed tracked files (`git diff --name-only -z <base>`).
|
|
85
|
+
* @property {string[]} untracked - untracked files (`git status -z`, `??` entries).
|
|
86
|
+
* @property {string[]} all - the sorted, de-duplicated union — the file set of §4.1.
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} base @param {string} cwd
|
|
91
|
+
* @returns {Promise<string[]>}
|
|
92
|
+
* @throws {TypeError} when `base` starts with `-` (argv-injection guard) or isn't a non-empty string.
|
|
93
|
+
*/
|
|
94
|
+
export async function trackedChangedFiles(base, cwd) {
|
|
95
|
+
const res = await exec(diffNameOnlyZArgv(base), { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
96
|
+
if (res.result !== 'ok') {
|
|
97
|
+
throw new Error(`trackedChangedFiles: git diff --name-only -z ${base} failed (exit ${res.code}): ${res.stderr}`);
|
|
98
|
+
}
|
|
99
|
+
return splitNul(res.stdout);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string} cwd
|
|
104
|
+
* @returns {Promise<string[]>} untracked paths only (git status `??` entries, exact bytes) —
|
|
105
|
+
* renames, modifications and deletions of tracked files are NOT included; those come from
|
|
106
|
+
* `trackedChangedFiles`.
|
|
107
|
+
*/
|
|
108
|
+
export async function untrackedFiles(cwd) {
|
|
109
|
+
const res = await exec(statusZArgv(), { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
110
|
+
if (res.result !== 'ok') {
|
|
111
|
+
throw new Error(`untrackedFiles: git status failed (exit ${res.code}): ${res.stderr}`);
|
|
112
|
+
}
|
|
113
|
+
// A rename/copy entry's `-z` record is TWO NUL-terminated fields (new path, then the ORIGINAL
|
|
114
|
+
// path with no status prefix at all) — splitNul yields that orig-path as its own bare entry,
|
|
115
|
+
// which the `?? ` prefix filter below naturally excludes since it never starts with `?? `.
|
|
116
|
+
return splitNul(res.stdout)
|
|
117
|
+
.filter((entry) => entry.startsWith('?? '))
|
|
118
|
+
.map((entry) => entry.slice(3));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The complete, UNFILTERED file set of §4.1: tracked changes since `base` union untracked files.
|
|
123
|
+
* A caller that needs it filtered to one block's `owned_files` does so with `ownsFile` (re-exported
|
|
124
|
+
* above) — the scope check specifically needs the unfiltered set (§4.1: "unfiltered for the scope
|
|
125
|
+
* check").
|
|
126
|
+
* @param {{cwd: string, base: string}} opts
|
|
127
|
+
* @returns {Promise<FileSet>}
|
|
128
|
+
*/
|
|
129
|
+
export async function computeFileSet({ cwd, base }) {
|
|
130
|
+
const [tracked, untracked] = await Promise.all([trackedChangedFiles(base, cwd), untrackedFiles(cwd)]);
|
|
131
|
+
return { tracked, untracked, all: [...new Set([...tracked, ...untracked])].sort() };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `git diff --no-index /dev/null <path>` for one NEW/untracked file, plus its `+` line count.
|
|
136
|
+
* The header (`--- a/…`, `+++ b/<path>`) is skipped by POSITION — everything up to and including
|
|
137
|
+
* the first `@@ …@@` hunk marker — not by matching a `+++` PREFIX: a real content line that itself
|
|
138
|
+
* starts with `++` (`++i;`, a markdown/diff fixture) renders as `+++i;` in the diff body and would
|
|
139
|
+
* otherwise be misread as a second header line and dropped, undercounting. Exit code 1
|
|
140
|
+
* (differences found) is the expected, non-error outcome.
|
|
141
|
+
* @param {string} cwd @param {string} relPath
|
|
142
|
+
* @returns {Promise<{diff: string, plusCount: number}>}
|
|
143
|
+
*/
|
|
144
|
+
export async function diffNoIndexWithCount(cwd, relPath) {
|
|
145
|
+
const res = await exec(diffNoIndexArgv(relPath), { cwd, env: gitEnv(), okExitCodes: [0, 1], timeoutMs: GIT_TIMEOUT_MS });
|
|
146
|
+
if (res.result !== 'ok') {
|
|
147
|
+
throw new Error(`diffNoIndexWithCount: git diff --no-index failed for ${relPath} (exit ${res.code}): ${res.stderr}`);
|
|
148
|
+
}
|
|
149
|
+
const lines = res.stdout.split('\n');
|
|
150
|
+
const hunkStart = lines.findIndex((line) => line.startsWith('@@'));
|
|
151
|
+
const body = hunkStart < 0 ? [] : lines.slice(hunkStart + 1);
|
|
152
|
+
const plusCount = body.filter((line) => line.startsWith('+')).length;
|
|
153
|
+
return { diff: res.stdout, plusCount };
|
|
154
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret scanning gate (plan §8, `gates.extra.secret_scan`). Reuses B1's shape-based token
|
|
3
|
+
* detector (`config/secret-patterns.mjs`) rather than duplicating a pattern list — this module's
|
|
4
|
+
* only job is turning "these files' text" into "hits", with the one FAKE-marker allowance the
|
|
5
|
+
* package's own test fixtures rely on (coder rule: every fake key in this repo's tests contains
|
|
6
|
+
* `FAKE`/`fake`, e.g. `sk-ant-FAKE0123456789`).
|
|
7
|
+
*
|
|
8
|
+
* Hits never carry the token text itself — only `{path, line}` — so a caller can log or print a
|
|
9
|
+
* scan result without ever risking a real secret reaching output (plan §8.2).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { secretTokensIn } from '../config/secret-patterns.mjs';
|
|
15
|
+
|
|
16
|
+
/** A token containing this (case-insensitive) is a fixture/test key, never a real one. */
|
|
17
|
+
const FAKE_MARKER = /fake/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} SecretHit
|
|
21
|
+
* @property {string} path
|
|
22
|
+
* @property {number} line - 1-based.
|
|
23
|
+
* @property {number} count - secret-shaped tokens found on that line (fake-marked ones excluded).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @param {string} [filePath] - included on each hit; defaults to `''`.
|
|
29
|
+
* @returns {SecretHit[]}
|
|
30
|
+
*/
|
|
31
|
+
export function scanText(text, filePath = '') {
|
|
32
|
+
/** @type {SecretHit[]} */
|
|
33
|
+
const hits = [];
|
|
34
|
+
const lines = String(text).split('\n');
|
|
35
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
36
|
+
const tokens = secretTokensIn(lines[i]).filter((token) => !FAKE_MARKER.test(token));
|
|
37
|
+
if (tokens.length > 0) hits.push({ path: filePath, line: i + 1, count: tokens.length });
|
|
38
|
+
}
|
|
39
|
+
return hits;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {ReadonlyArray<{path: string, content: string}>} files
|
|
44
|
+
* @returns {SecretHit[]}
|
|
45
|
+
*/
|
|
46
|
+
export function scanFileContents(files) {
|
|
47
|
+
if (!Array.isArray(files)) {
|
|
48
|
+
throw new TypeError('scanFileContents: files must be an array of {path, content}');
|
|
49
|
+
}
|
|
50
|
+
return files.flatMap(({ path: filePath, content }) => scanText(content, filePath));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reads and scans each file under `cwd`. Only a MISSING file (deleted between the scope check and
|
|
55
|
+
* the scan — `ENOENT`, or a dangling symlink target — `ENOTDIR` on a bogus intermediate segment)
|
|
56
|
+
* is skipped rather than failing the whole scan, since it genuinely has no content to scan. Any
|
|
57
|
+
* OTHER read error (`EACCES`, `EISDIR`, `EIO`, …) is a file this scan could NOT actually read —
|
|
58
|
+
* silently treating that the same as "nothing to see" would report the gate green on a file it
|
|
59
|
+
* never scanned, so it is re-thrown and turns the gate red via the caller's catch.
|
|
60
|
+
* @param {string} cwd
|
|
61
|
+
* @param {ReadonlyArray<string>} relPaths
|
|
62
|
+
* @returns {Promise<SecretHit[]>}
|
|
63
|
+
*/
|
|
64
|
+
export async function scanFiles(cwd, relPaths) {
|
|
65
|
+
/** @type {SecretHit[]} */
|
|
66
|
+
const hits = [];
|
|
67
|
+
for (const rel of relPaths) {
|
|
68
|
+
let content;
|
|
69
|
+
try {
|
|
70
|
+
content = await readFile(path.join(cwd, rel), 'utf8');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
73
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') continue;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
hits.push(...scanText(content, rel));
|
|
77
|
+
}
|
|
78
|
+
return hits;
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The block gate's transcript grep (plan §8.6/C4, row 9 of §4.6: "no `rule_break` row —
|
|
3
|
+
* including the transcript grep"). The actual scanner (`scanTranscript`) already lives in B0's
|
|
4
|
+
* `util/forbidden.mjs` (extended by B8 in Wave 1 with the signer-protection ★ entries) — this
|
|
5
|
+
* module is the gate-facing wrapper: it decides pass/fail and shapes a `rule_break`-ready reason,
|
|
6
|
+
* it does not re-implement the scan.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { FORBIDDEN, mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{id: string, line: number}} TranscriptHit
|
|
13
|
+
* @typedef {{ok: boolean, hits: TranscriptHit[]}} TranscriptCheck
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} text - captured coder output (subprocess stdout, Solo `search_output`, a
|
|
18
|
+
* harness log).
|
|
19
|
+
* @param {{extraTokens?: string[]}} [opts] - `extraTokens` merges in `production.markers`/
|
|
20
|
+
* `production.names` (B1 config), the same way `mergeForbidden` does everywhere else.
|
|
21
|
+
* @returns {TranscriptCheck}
|
|
22
|
+
*/
|
|
23
|
+
export function checkTranscript(text, opts = {}) {
|
|
24
|
+
const list = opts.extraTokens && opts.extraTokens.length > 0 ? mergeForbidden(opts.extraTokens) : FORBIDDEN;
|
|
25
|
+
const hits = scanTranscript(text, list);
|
|
26
|
+
return { ok: hits.length === 0, hits };
|
|
27
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-environment detection and the single-JSON-line output convention (Laravel installer
|
|
3
|
+
* pattern, research/installer-patterns.md; coder-rules.md: "Agent detection env vars ...
|
|
4
|
+
* CLAUDECODE/CLAUDE_CODE, CURSOR_AGENT, AI_AGENT — when set, output exactly one JSON line").
|
|
5
|
+
*
|
|
6
|
+
* This module owns the MECHANISM only: detecting the env vars, and writing exactly one JSON line
|
|
7
|
+
* to a stream. It does not build the `init` wizard's own payload shape (`{ok, wrote, harnesses,
|
|
8
|
+
* doctor, log, log_tail}`, plan §2) — that composition belongs to `init`, a later block (B13).
|
|
9
|
+
* Any verb can call {@link maybeEmitAgentJson} with whatever plain-JSON-serializable payload it
|
|
10
|
+
* has, and the "exactly one line" guarantee holds regardless of the payload's shape.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Env var names checked, in this fixed order — first one present (as an own, non-empty string
|
|
15
|
+
* property) wins and is reported as `source`. Order does not change the detection RESULT (any
|
|
16
|
+
* one being present is enough), only which name is reported when more than one is set.
|
|
17
|
+
*/
|
|
18
|
+
export const AGENT_ENV_VARS = Object.freeze(['CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT', 'AI_AGENT']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} AgentDetection
|
|
22
|
+
* @property {boolean} isAgent
|
|
23
|
+
* @property {string|null} source - the env var name that triggered detection, or `null`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {NodeJS.ProcessEnv} [env] - defaults to `process.env`.
|
|
28
|
+
* @returns {AgentDetection}
|
|
29
|
+
*/
|
|
30
|
+
export function detectAgentEnv(env = process.env) {
|
|
31
|
+
for (const name of AGENT_ENV_VARS) {
|
|
32
|
+
if (Object.hasOwn(env, name) && typeof env[name] === 'string' && env[name].length > 0) {
|
|
33
|
+
return { isAgent: true, source: name };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { isAgent: false, source: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Serializes `payload` as ONE line of compact JSON (no pretty-printing — a pretty-printed object
|
|
41
|
+
* spans multiple lines, which breaks "exactly one JSON line on stdout" for any caller parsing
|
|
42
|
+
* line-by-line) and writes it in a SINGLE `stream.write()` call, so nothing else can interleave a
|
|
43
|
+
* partial write between two calls.
|
|
44
|
+
* @param {unknown} payload - must be JSON-serializable; `undefined` is refused (it would silently
|
|
45
|
+
* produce no output at all, which is not "one JSON line").
|
|
46
|
+
* @param {object} [opts]
|
|
47
|
+
* @param {{write: (s: string) => unknown}} [opts.stdout] - defaults to `process.stdout`.
|
|
48
|
+
* @returns {void}
|
|
49
|
+
*/
|
|
50
|
+
export function writeAgentJson(payload, opts = {}) {
|
|
51
|
+
const stdout = opts.stdout ?? process.stdout;
|
|
52
|
+
const serialized = JSON.stringify(payload);
|
|
53
|
+
if (typeof serialized !== 'string') {
|
|
54
|
+
throw new TypeError('writeAgentJson: payload must be JSON-serializable (JSON.stringify returned undefined)');
|
|
55
|
+
}
|
|
56
|
+
stdout.write(`${serialized}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Writes `payload` as one JSON line IF the environment looks like an agent harness, otherwise
|
|
61
|
+
* writes nothing at all. Returns whether it wrote, so a caller (e.g. `init`) can skip its normal
|
|
62
|
+
* interactive/human-readable output in the same branch.
|
|
63
|
+
* @param {unknown} payload
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
66
|
+
* @param {{write: (s: string) => unknown}} [opts.stdout]
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function maybeEmitAgentJson(payload, opts = {}) {
|
|
70
|
+
const { isAgent } = detectAgentEnv(opts.env ?? process.env);
|
|
71
|
+
if (!isAgent) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
writeAgentJson(payload, opts);
|
|
75
|
+
return true;
|
|
76
|
+
}
|