@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,381 @@
1
+ /**
2
+ * Every git call the package makes goes through here. The split is deliberate and load-bearing
3
+ * for `test/util/git.test.mjs`: each helper has a pure `*Argv` builder (no process spawned) plus
4
+ * a thin async wrapper that runs it through `./exec.mjs`. `commit` and `push` are the ONLY
5
+ * writing verbs this module exposes (plan §10.2 acceptance 6) — everything else reads.
6
+ *
7
+ * `git rm` is treated as a write too (it deletes a tracked file, which includes deleting a test
8
+ * file) even though the package never calls it: it is listed only so `./forbidden.mjs` and any
9
+ * future helper here classify it the same way. Force-push, reset --hard, checkout --, restore,
10
+ * clean, stash, and branch -D/--delete --force are simply not modeled as helpers here, because
11
+ * the package has no legitimate reason to run them. This module does NOT run a forbidden-list
12
+ * check before `exec`: what keeps `commit`/`push` inside the §8.4 rules is this module's own
13
+ * argument validation — fixed argv shapes, a branch that must be a plain git branch name (no
14
+ * leading `-` or `+`, no `:` or other refspec syntax — see `assertValidBranchName`), and typed
15
+ * string checks on every value.
16
+ *
17
+ * `fetch` is classified READ, not WRITE, even though it touches the network and updates
18
+ * remote-tracking refs and the object database: the write/read split here is specifically about
19
+ * "can this alter the local branch history or working tree a coder's diff depends on", which is
20
+ * what acceptance clause (6) exists to guard, and `fetch` cannot — it never moves a local branch
21
+ * or touches the working tree. `test/util/git.test.mjs` asserts this classification explicitly
22
+ * rather than leaving it implicit.
23
+ *
24
+ * Every positional ref/path argument that lands in argv WITHOUT a preceding `--` end-of-options
25
+ * marker is validated to not start with `-` (`assertNoLeadingDash`) — a value the caller merely
26
+ * expected to be a git ref (e.g. `base` in `diffNameOnlyArgv`) is exactly how `git diff
27
+ * --upload-pack=evil` style option injection would otherwise reach git. `diffArgv`'s `path` is
28
+ * the one exception: it already sits after a literal `--` in the built argv, so git treats it as
29
+ * a pathspec no matter what it starts with.
30
+ *
31
+ * Every wrapper runs git with `process.env` minus the repository-context variables git itself
32
+ * lists in `git rev-parse --local-env-vars` (`GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`,
33
+ * `GIT_PREFIX`, `GIT_OBJECT_DIRECTORY`, …). Git hooks export them, so a code-forge call made from
34
+ * inside a hook would otherwise act on the hook's repository instead of `cwd` (plan V8).
35
+ * Everything else — `GIT_SSH_COMMAND`, `GIT_AUTHOR_*`, the user's global config — is kept.
36
+ * `fetch`/`push` additionally set `GIT_TERMINAL_PROMPT=0`.
37
+ */
38
+
39
+ import { exec } from './exec.mjs';
40
+
41
+ /** Git subcommands this module treats as read-only (see the `fetch` note above). */
42
+ export const READ_COMMANDS = Object.freeze([
43
+ 'status',
44
+ 'diff',
45
+ 'show',
46
+ 'rev-parse',
47
+ 'merge-base',
48
+ 'fetch',
49
+ 'archive',
50
+ 'log',
51
+ 'ls-files',
52
+ ]);
53
+
54
+ /** Git subcommands this module treats as writes. `commit` and `push` are the only ones exposed as helpers. */
55
+ export const WRITE_COMMANDS = Object.freeze(['commit', 'push', 'rm']);
56
+
57
+ /** Network helpers (`fetch`, `push`) get a default timeout so a credential prompt can't hang forever. */
58
+ const DEFAULT_NETWORK_TIMEOUT_MS = 30000;
59
+
60
+ /** Output of `git rev-parse --local-env-vars` (git 2.5x): the variables that pick a repository. */
61
+ const LOCAL_REPO_ENV_VARS = Object.freeze([
62
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
63
+ 'GIT_CONFIG',
64
+ 'GIT_CONFIG_PARAMETERS',
65
+ 'GIT_CONFIG_COUNT',
66
+ 'GIT_OBJECT_DIRECTORY',
67
+ 'GIT_DIR',
68
+ 'GIT_WORK_TREE',
69
+ 'GIT_IMPLICIT_WORK_TREE',
70
+ 'GIT_GRAFT_FILE',
71
+ 'GIT_INDEX_FILE',
72
+ 'GIT_NO_REPLACE_OBJECTS',
73
+ 'GIT_REPLACE_REF_BASE',
74
+ 'GIT_PREFIX',
75
+ 'GIT_SHALLOW_FILE',
76
+ 'GIT_COMMON_DIR',
77
+ ]);
78
+
79
+ /**
80
+ * `process.env` without the repository-context variables, plus `extra`.
81
+ * @param {Record<string, string>} [extra]
82
+ * @returns {NodeJS.ProcessEnv}
83
+ */
84
+ function gitEnv(extra = {}) {
85
+ /** @type {NodeJS.ProcessEnv} */
86
+ const env = { ...process.env };
87
+ for (const name of LOCAL_REPO_ENV_VARS) {
88
+ delete env[name];
89
+ }
90
+ return { ...env, ...extra };
91
+ }
92
+
93
+ /**
94
+ * @param {string} subcommand
95
+ * @param {string[]} args
96
+ * @returns {string[]}
97
+ */
98
+ function gitArgv(subcommand, args) {
99
+ return ['git', subcommand, ...args];
100
+ }
101
+
102
+ /**
103
+ * Reject a positional argument that isn't sitting after a `--` marker and starts with `-` — git
104
+ * would otherwise parse it as an option instead of the ref/path/branch it's meant to be.
105
+ * @param {unknown} value
106
+ * @param {string} paramName
107
+ * @returns {string}
108
+ */
109
+ function assertNoLeadingDash(value, paramName) {
110
+ if (typeof value !== 'string' || value.length === 0) {
111
+ throw new TypeError(`git.mjs: ${paramName} must be a non-empty string`);
112
+ }
113
+ if (value.startsWith('-')) {
114
+ throw new TypeError(`git.mjs: ${paramName} must not start with "-" (got ${JSON.stringify(value)}) — option injection`);
115
+ }
116
+ return value;
117
+ }
118
+
119
+ /**
120
+ * @param {unknown} value
121
+ * @param {string} paramName
122
+ * @returns {string}
123
+ */
124
+ function assertNonEmptyString(value, paramName) {
125
+ if (typeof value !== 'string' || value.length === 0) {
126
+ throw new TypeError(`git.mjs: ${paramName} must be a non-empty string`);
127
+ }
128
+ return value;
129
+ }
130
+
131
+ /**
132
+ * @param {string[]} args
133
+ * @param {string} paramName
134
+ * @returns {string[]}
135
+ */
136
+ function assertNoLeadingDashes(args, paramName) {
137
+ for (const arg of args) {
138
+ assertNoLeadingDash(arg, `an element of ${paramName}`);
139
+ }
140
+ return args;
141
+ }
142
+
143
+ // ── Read-only argv builders ─────────────────────────────────────────────────
144
+
145
+ /** @returns {string[]} */
146
+ export function statusArgv() {
147
+ return gitArgv('status', ['--porcelain', '--untracked-files=all']);
148
+ }
149
+
150
+ /** @param {string} base @returns {string[]} */
151
+ export function diffNameOnlyArgv(base) {
152
+ return gitArgv('diff', ['--name-only', assertNoLeadingDash(base, 'base')]);
153
+ }
154
+
155
+ /**
156
+ * `path` sits after a literal `--`, so it is always treated as a pathspec by git regardless of
157
+ * its first character — only `base` needs the leading-dash guard here.
158
+ * @param {string} base @param {string} path @returns {string[]}
159
+ */
160
+ export function diffArgv(base, path) {
161
+ return gitArgv('diff', [assertNoLeadingDash(base, 'base'), '--', assertNonEmptyString(path, 'path')]);
162
+ }
163
+
164
+ /**
165
+ * `git diff --no-index /dev/null <path>` — every line of a new/untracked file as a `+` hunk.
166
+ * Exits 1 when differences are found (the expected case), which is not a failure.
167
+ * @param {string} path @returns {string[]}
168
+ */
169
+ export function diffNoIndexArgv(path) {
170
+ return gitArgv('diff', ['--no-index', '/dev/null', assertNoLeadingDash(path, 'path')]);
171
+ }
172
+
173
+ /** @param {string} ref @param {string} path @returns {string[]} */
174
+ export function showArgv(ref, path) {
175
+ return gitArgv('show', [`${assertNoLeadingDash(ref, 'ref')}:${assertNonEmptyString(path, 'path')}`]);
176
+ }
177
+
178
+ /** @param {string} ref @returns {string[]} */
179
+ export function revParseArgv(ref) {
180
+ return gitArgv('rev-parse', [assertNoLeadingDash(ref, 'ref')]);
181
+ }
182
+
183
+ /**
184
+ * Exits 0 when `ancestor` is an ancestor of `descendant`, 1 when it is not — both normal answers.
185
+ * @param {string} ancestor @param {string} descendant @returns {string[]}
186
+ */
187
+ export function isAncestorArgv(ancestor, descendant) {
188
+ return gitArgv('merge-base', [
189
+ '--is-ancestor',
190
+ assertNoLeadingDash(ancestor, 'ancestor'),
191
+ assertNoLeadingDash(descendant, 'descendant'),
192
+ ]);
193
+ }
194
+
195
+ /** @returns {string[]} */
196
+ export function fetchArgv() {
197
+ return gitArgv('fetch', []);
198
+ }
199
+
200
+ /** @param {string} ref @returns {string[]} */
201
+ export function archiveArgv(ref) {
202
+ return gitArgv('archive', [assertNoLeadingDash(ref, 'ref')]);
203
+ }
204
+
205
+ /**
206
+ * `args` is a raw passthrough today (no block/block owns a caller yet); every element is
207
+ * guarded against a leading `-` for the same option-injection reason as the single-ref builders
208
+ * above, which also closes the `--output=<file>`/`-o`/`--exec=<cmd>` "read helper writes a file
209
+ * or runs a command" hole. A future caller that needs a real git-log flag extends this guard
210
+ * (or moves to typed options) deliberately, rather than this staying an open passthrough.
211
+ * @param {string[]} [args] @returns {string[]}
212
+ */
213
+ export function logArgv(args = []) {
214
+ return gitArgv('log', assertNoLeadingDashes(args, 'args'));
215
+ }
216
+
217
+ /** @param {string[]} [args] @returns {string[]} */
218
+ export function lsFilesArgv(args = []) {
219
+ return gitArgv('ls-files', assertNoLeadingDashes(args, 'args'));
220
+ }
221
+
222
+ // ── The two allowed writes ──────────────────────────────────────────────────
223
+
224
+ /** @param {string} message @returns {string[]} */
225
+ export function commitArgv(message) {
226
+ return gitArgv('commit', ['-m', assertNonEmptyString(message, 'message')]);
227
+ }
228
+
229
+ /**
230
+ * Why a branch value is not a plain git branch name, or null when it is. Mirrors
231
+ * `git check-ref-format --branch` (git-check-ref-format(1)), plus two refspec-specific refusals:
232
+ * `git push origin <branch>` reads the value as a REFSPEC, so a leading `+` is a force-push and a
233
+ * `:` deletes (`:main`) or pushes a different ref (`src:dst`).
234
+ * @param {string} branch
235
+ * @returns {string | null}
236
+ */
237
+ function branchNameProblem(branch) {
238
+ if (branch.startsWith('-')) {
239
+ return 'must not start with "-" — option injection (--force/--mirror/--delete)';
240
+ }
241
+ if (branch.startsWith('+')) {
242
+ return 'must not start with "+" — a +refspec is a force-push';
243
+ }
244
+ if (branch.includes(':')) {
245
+ return 'must not contain ":" — refspec syntax (":main" deletes, "a:b" pushes another ref)';
246
+ }
247
+ // Space, DEL and ASCII control characters, plus git's forbidden ref characters.
248
+ if (/[\u0000- \u007f~^?*[\\]/.test(branch)) {
249
+ return 'must not contain whitespace, control characters or any of ~ ^ ? * [ \\';
250
+ }
251
+ if (branch.includes('..') || branch.includes('@{') || branch === '@') {
252
+ return 'must not contain "..", "@{" or be "@"';
253
+ }
254
+ if (branch.endsWith('/') || branch.endsWith('.') || branch.includes('//')) {
255
+ return 'must not end with "/" or ".", or contain "//"';
256
+ }
257
+ if (branch.split('/').some((part) => part.startsWith('.') || part.endsWith('.lock'))) {
258
+ return 'no path component may start with "." or end with ".lock"';
259
+ }
260
+ return null;
261
+ }
262
+
263
+ /**
264
+ * @param {unknown} branch
265
+ * @returns {string}
266
+ */
267
+ function assertValidBranchName(branch) {
268
+ if (typeof branch !== 'string' || branch.trim().length === 0) {
269
+ throw new TypeError('git.mjs: pushArgv branch must be a non-empty string');
270
+ }
271
+ const problem = branchNameProblem(branch);
272
+ if (problem) {
273
+ throw new TypeError(`git.mjs: pushArgv branch ${JSON.stringify(branch)} ${problem}`);
274
+ }
275
+ return branch;
276
+ }
277
+
278
+ /**
279
+ * @param {{branch?: string, setUpstream?: boolean}} [opts]
280
+ * @returns {string[]}
281
+ * @throws {TypeError} if `branch` is not a plain git branch name (option injection or refspec
282
+ * syntax — see `branchNameProblem`), or if `setUpstream` is true without a `branch`.
283
+ */
284
+ export function pushArgv(opts = {}) {
285
+ const { branch, setUpstream = false } = opts;
286
+ if (setUpstream && !branch) {
287
+ throw new TypeError('git.mjs: pushArgv({ setUpstream: true }) requires a branch');
288
+ }
289
+ if (branch === undefined) {
290
+ return gitArgv('push', []);
291
+ }
292
+ assertValidBranchName(branch);
293
+ return setUpstream ? gitArgv('push', ['-u', 'origin', branch]) : gitArgv('push', ['origin', branch]);
294
+ }
295
+
296
+ // ── Async wrappers ──────────────────────────────────────────────────────────
297
+
298
+ /** @param {string} cwd */
299
+ export function status(cwd) {
300
+ return exec(statusArgv(), { cwd, env: gitEnv() });
301
+ }
302
+
303
+ /** @param {string} base @param {string} cwd */
304
+ export function diffNameOnly(base, cwd) {
305
+ return exec(diffNameOnlyArgv(base), { cwd, env: gitEnv() });
306
+ }
307
+
308
+ /** @param {string} base @param {string} path @param {string} cwd */
309
+ export function diff(base, path, cwd) {
310
+ return exec(diffArgv(base, path), { cwd, env: gitEnv() });
311
+ }
312
+
313
+ /** @param {string} path @param {string} cwd */
314
+ export function diffNoIndex(path, cwd) {
315
+ return exec(diffNoIndexArgv(path), { cwd, env: gitEnv(), okExitCodes: [0, 1] });
316
+ }
317
+
318
+ /** @param {string} ref @param {string} path @param {string} cwd */
319
+ export function show(ref, path, cwd) {
320
+ return exec(showArgv(ref, path), { cwd, env: gitEnv() });
321
+ }
322
+
323
+ /** @param {string} ref @param {string} cwd */
324
+ export function revParse(ref, cwd) {
325
+ return exec(revParseArgv(ref), { cwd, env: gitEnv() });
326
+ }
327
+
328
+ /**
329
+ * @param {string} ancestor @param {string} descendant @param {string} cwd
330
+ * @returns {Promise<boolean>}
331
+ * @throws {Error} if the underlying `git merge-base --is-ancestor` call errors for a reason
332
+ * other than "not an ancestor" (exit 1) — e.g. exit 128 on an unknown ref — so that case is
333
+ * never silently reported as `false`.
334
+ */
335
+ export async function isAncestor(ancestor, descendant, cwd) {
336
+ const res = await exec(isAncestorArgv(ancestor, descendant), { cwd, env: gitEnv(), okExitCodes: [0, 1] });
337
+ if (res.result !== 'ok') {
338
+ throw new Error(
339
+ `git.mjs: isAncestor(${ancestor}, ${descendant}) failed (code ${res.code}): ${res.stderr || res.error || 'unknown error'}`,
340
+ );
341
+ }
342
+ return res.code === 0;
343
+ }
344
+
345
+ /**
346
+ * @param {string} cwd
347
+ * @param {{timeoutMs?: number}} [opts]
348
+ */
349
+ export function fetch(cwd, opts = {}) {
350
+ const { timeoutMs = DEFAULT_NETWORK_TIMEOUT_MS } = opts;
351
+ return exec(fetchArgv(), { cwd, timeoutMs, env: gitEnv({ GIT_TERMINAL_PROMPT: '0' }) });
352
+ }
353
+
354
+ /** @param {string} ref @param {string} cwd */
355
+ export function archive(ref, cwd) {
356
+ return exec(archiveArgv(ref), { cwd, env: gitEnv() });
357
+ }
358
+
359
+ /** @param {string} cwd @param {string[]} [args] */
360
+ export function log(cwd, args) {
361
+ return exec(logArgv(args), { cwd, env: gitEnv() });
362
+ }
363
+
364
+ /** @param {string} cwd @param {string[]} [args] */
365
+ export function lsFiles(cwd, args) {
366
+ return exec(lsFilesArgv(args), { cwd, env: gitEnv() });
367
+ }
368
+
369
+ /** @param {string} message @param {string} cwd */
370
+ export function commit(message, cwd) {
371
+ return exec(commitArgv(message), { cwd, env: gitEnv() });
372
+ }
373
+
374
+ /**
375
+ * @param {string} cwd
376
+ * @param {{branch?: string, setUpstream?: boolean, timeoutMs?: number}} [opts]
377
+ */
378
+ export function push(cwd, opts = {}) {
379
+ const { timeoutMs = DEFAULT_NETWORK_TIMEOUT_MS, ...pushOpts } = opts;
380
+ return exec(pushArgv(pushOpts), { cwd, timeoutMs, env: gitEnv({ GIT_TERMINAL_PROMPT: '0' }) });
381
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The leveled logger — the "log path" of `./redact.mjs`'s three guarded output paths (the other
3
+ * two are `redact.writeSafe` for raw stream writes and `redact.redactJSON` for structured dumps).
4
+ * Every line printed here is redacted first; nothing in this module bypasses that.
5
+ *
6
+ * Each argument is redacted according to its own shape, not by JSON-serializing everything and
7
+ * redacting the joined string as one blob:
8
+ * - a `string` goes through `redact()` directly.
9
+ * - an `Error` (including one nested as `.cause`) is formatted to `name: message` + stack +
10
+ * cause chain, each part redacted.
11
+ * - anything else goes through `redactJSON()` (structural — also redacts by key, once
12
+ * `redact.mjs` does; a plain post-hoc string redact of an already-serialized object cannot).
13
+ * That split exists because a value-blind, string-only redact of a value the caller is trying to
14
+ * see can otherwise vanish outright: `JSON.stringify(undefined)` is `undefined`, not `"undefined"`
15
+ * — joining it into a message silently drops the value instead of printing it.
16
+ */
17
+
18
+ import { redact, redactJSON } from './redact.mjs';
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @returns {string}
23
+ */
24
+ function safeJSONString(value) {
25
+ const serialized = JSON.stringify(value);
26
+ return typeof serialized === 'string' ? serialized : String(value);
27
+ }
28
+
29
+ /**
30
+ * @param {Error} err
31
+ * @returns {string}
32
+ */
33
+ function formatError(err) {
34
+ const parts = [err.stack ?? `${err.name}: ${err.message}`];
35
+ if (err.cause !== undefined) {
36
+ parts.push(`Caused by: ${err.cause instanceof Error ? formatError(err.cause) : safeJSONString(err.cause)}`);
37
+ }
38
+ return parts.join('\n');
39
+ }
40
+
41
+ /**
42
+ * @param {unknown} value
43
+ * @returns {string}
44
+ */
45
+ function stringifyArg(value) {
46
+ if (typeof value === 'string') {
47
+ return redact(value);
48
+ }
49
+ if (value instanceof Error) {
50
+ return redact(formatError(value));
51
+ }
52
+ try {
53
+ return redactJSON(value);
54
+ } catch {
55
+ return redact(safeJSONString(value));
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {NodeJS.WritableStream} stream
61
+ * @param {string} level
62
+ * @param {unknown[]} args
63
+ */
64
+ function write(stream, level, args) {
65
+ const message = args.map(stringifyArg).join(' ');
66
+ // Prefix EVERY line, not just the first: an unprefixed line inside a value (a real newline in
67
+ // a caller's string, or a multi-line stack trace) would otherwise look like a second, forged
68
+ // log entry — log-line injection.
69
+ const prefixed = message
70
+ .split('\n')
71
+ .map((line) => `[code-forge:${level}] ${line}`)
72
+ .join('\n');
73
+ stream.write(`${prefixed}\n`);
74
+ }
75
+
76
+ /** @param {...unknown} args */
77
+ export function info(...args) {
78
+ write(process.stdout, 'info', args);
79
+ }
80
+
81
+ /** @param {...unknown} args */
82
+ export function warn(...args) {
83
+ write(process.stderr, 'warn', args);
84
+ }
85
+
86
+ /** @param {...unknown} args */
87
+ export function error(...args) {
88
+ write(process.stderr, 'error', args);
89
+ }
90
+
91
+ /** Values that turn CODE_FORGE_DEBUG on; anything else (including "0" or "false") leaves it off. */
92
+ const DEBUG_ENABLED_VALUES = new Set(['1', 'true', 'yes']);
93
+
94
+ /** @param {...unknown} args */
95
+ export function debug(...args) {
96
+ const flag = process.env.CODE_FORGE_DEBUG;
97
+ if (flag !== undefined && DEBUG_ENABLED_VALUES.has(flag.toLowerCase())) {
98
+ write(process.stderr, 'debug', args);
99
+ }
100
+ }