@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.
Files changed (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. 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
+ }