@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,276 @@
1
+ /**
2
+ * `models --refresh --from-cli-caches` (plan §2 step 3, §1.3, C15 — un-cut from v1.1's original
3
+ * "returns as an opt-in"). Reads each CLI's own model cache off disk (Codex's `~/.codex/
4
+ * models_cache.json`, Grok's `~/.grok/models_cache.json` — whichever are present) and merges the
5
+ * ids into a **user-level** cache written by THIS module, `~/.code-forge/cache/models.json`,
6
+ * shaped `{status: "seen-in-cache", updated_at, seen: {<provider>: [<id>, ...]}}`. It never
7
+ * touches the project's `.code-forge.yml` — a model seen this way is only ever a WARNING-
8
+ * suppressing fact for `validate.mjs`'s `unknown-model-id` rule (like `known_extra`, but
9
+ * per-machine instead of per-project), never something this module writes into the config a
10
+ * project commits.
11
+ *
12
+ * `home` is always an explicit parameter (default `os.homedir()`), never hardcoded — the only way
13
+ * a test can point this at a fixture HOME without touching the real `~/.codex`/`~/.grok`.
14
+ *
15
+ * **Real cache shapes** (read once from this machine's own `~/.codex/models_cache.json` and
16
+ * `~/.grok/models_cache.json`, 2026-09-24, to build the fixtures under `test/fixtures/cli-caches/`
17
+ * — no secret, identity, etag or auth-looking field was copied into either fixture):
18
+ * - Codex: `{fetched_at, etag, client_version, identity, models: [{slug, display_name,
19
+ * supported_reasoning_levels: [{effort, description}, ...], ...many more fields}, ...]}` — an
20
+ * ARRAY of model OBJECTS, id at `.slug`.
21
+ * - Grok: `{fetched_at, grok_version, auth_method, origin, identity, etag, models: {<id>:
22
+ * {info: {id, model, model_family, reasoning_efforts: [{id, value, label, ...}], ...}, api_key,
23
+ * env_key, api_base_url}, ...}}` — an OBJECT keyed by id, id at `.info.id` (falls back to the
24
+ * object's own key if `info.id` is absent).
25
+ * Both extractors also accept a bare `["id", ...]` array or `{"models": ["id", ...]}` — a
26
+ * deliberately simple form kept for hand-written test fixtures and for forward compatibility with
27
+ * a future CLI cache that already lists bare id strings.
28
+ */
29
+
30
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+ import { randomUUID } from 'node:crypto';
34
+
35
+ /**
36
+ * @typedef {(parsed: unknown) => string[]} ModelIdExtractor
37
+ */
38
+
39
+ /**
40
+ * Codex's real shape: `models` is an ARRAY of objects; the id is `.slug`. Also accepts the
41
+ * simple array-of-strings / `{models: [string]}` forms.
42
+ * @type {ModelIdExtractor}
43
+ */
44
+ function extractCodexModelIds(parsed) {
45
+ const models = Array.isArray(parsed) ? parsed : /** @type {any} */ (parsed)?.models;
46
+ if (!Array.isArray(models)) {
47
+ return [];
48
+ }
49
+ return models
50
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.slug))
51
+ .filter((id) => typeof id === 'string' && id.length > 0);
52
+ }
53
+
54
+ /**
55
+ * Grok's real shape: `models` is an OBJECT keyed by id; the id is `.info.id` (or the key itself
56
+ * when `info.id` is missing/malformed). Also accepts the simple array-of-strings /
57
+ * `{models: [string]}` forms.
58
+ * @type {ModelIdExtractor}
59
+ */
60
+ function extractGrokModelIds(parsed) {
61
+ if (Array.isArray(parsed)) {
62
+ return parsed.filter((id) => typeof id === 'string');
63
+ }
64
+ const models = /** @type {any} */ (parsed)?.models;
65
+ if (Array.isArray(models)) {
66
+ return models.filter((id) => typeof id === 'string');
67
+ }
68
+ if (models && typeof models === 'object') {
69
+ return Object.entries(models)
70
+ .map(([key, entry]) => (typeof entry?.info?.id === 'string' && entry.info.id.length > 0 ? entry.info.id : key))
71
+ .filter((id) => typeof id === 'string' && id.length > 0);
72
+ }
73
+ return [];
74
+ }
75
+
76
+ /** Where each CLI keeps its own model cache, relative to `$HOME`, and how to read its ids. */
77
+ const CLI_CACHE_SOURCES = Object.freeze([
78
+ { provider: 'openai', relativePath: path.join('.codex', 'models_cache.json'), extract: extractCodexModelIds },
79
+ { provider: 'xai', relativePath: path.join('.grok', 'models_cache.json'), extract: extractGrokModelIds },
80
+ ]);
81
+
82
+ /**
83
+ * @param {string} home
84
+ * @returns {string}
85
+ */
86
+ export function userModelsCachePath(home = os.homedir()) {
87
+ return path.join(home, '.code-forge', 'cache', 'models.json');
88
+ }
89
+
90
+ /**
91
+ * @typedef {object} RefreshResult
92
+ * @property {string[]} sourcesRead - absolute paths of the CLI caches that existed, parsed as
93
+ * valid JSON, AND yielded at least one id.
94
+ * @property {{path: string, reason: string}[]} sourcesSkipped - a CLI cache that existed but
95
+ * could not be used — unreadable (`unreadable (EISDIR)`), malformed JSON (`not valid JSON`,
96
+ * optionally `(at position N)` — never a source excerpt), or parsed but yielded 0 ids (a shape
97
+ * this module doesn't recognise). A source being skipped never aborts the other source.
98
+ * @property {Record<string, string[]>} seenThisRun - provider -> ids read from THIS run's sources only.
99
+ * @property {string} cachePath - where the merged user-level cache was written.
100
+ */
101
+
102
+ /**
103
+ * @param {{home?: string}} [opts]
104
+ * @returns {Promise<RefreshResult>}
105
+ */
106
+ export async function refreshFromCliCaches(opts = {}) {
107
+ const home = opts.home ?? os.homedir();
108
+ /** @type {Record<string, string[]>} */
109
+ const seenThisRun = {};
110
+ /** @type {string[]} */
111
+ const sourcesRead = [];
112
+ /** @type {{path: string, reason: string}[]} */
113
+ const sourcesSkipped = [];
114
+
115
+ for (const { provider, relativePath, extract } of CLI_CACHE_SOURCES) {
116
+ const cachePath = path.join(home, relativePath);
117
+ let raw;
118
+ try {
119
+ raw = await readFile(cachePath, 'utf8');
120
+ } catch (err) {
121
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
122
+ if (code === 'ENOENT') {
123
+ continue;
124
+ }
125
+ // EACCES, EISDIR, EMFILE, … — skipped like a malformed file, never aborting the OTHER
126
+ // provider's valid cache (round-2 MINOR). Only the errno code is reported.
127
+ sourcesSkipped.push({ path: cachePath, reason: `unreadable (${safeErrnoCode(code)})` });
128
+ continue;
129
+ }
130
+
131
+ let parsed;
132
+ try {
133
+ parsed = JSON.parse(raw);
134
+ } catch (err) {
135
+ // A malformed source (e.g. the CLI is partway through writing it) never aborts the other
136
+ // provider's valid cache — skip and record why, keep going. NEVER `err.message`: V8's
137
+ // SyntaxError quotes a slice of the source text, and Grok's real cache stores `api_key` in
138
+ // every model entry, so a half-written file would put a key into this reason (round-2
139
+ // MAJOR). Only the numeric position, parsed out, is kept.
140
+ sourcesSkipped.push({ path: cachePath, reason: `not valid JSON${jsonErrorPosition(err)}` });
141
+ continue;
142
+ }
143
+
144
+ const ids = extract(parsed);
145
+ if (ids.length === 0) {
146
+ // Parsed fine but yielded nothing — an unrecognised shape is reported, never silently
147
+ // recorded as a successful empty refresh.
148
+ sourcesSkipped.push({ path: cachePath, reason: 'parsed but no model ids could be read from it' });
149
+ continue;
150
+ }
151
+ seenThisRun[provider] = ids;
152
+ sourcesRead.push(cachePath);
153
+ }
154
+
155
+ const cachePath = userModelsCachePath(home);
156
+ const existingSeen = await readExistingSeen(cachePath);
157
+
158
+ /** @type {Record<string, string[]>} */
159
+ const mergedSeen = { ...existingSeen };
160
+ for (const [provider, ids] of Object.entries(seenThisRun)) {
161
+ const merged = new Set(Object.hasOwn(mergedSeen, provider) ? mergedSeen[provider] : []);
162
+ for (const id of ids) {
163
+ merged.add(id);
164
+ }
165
+ mergedSeen[provider] = [...merged].sort();
166
+ }
167
+
168
+ await writeModelsCacheAtomically(cachePath, {
169
+ status: 'seen-in-cache',
170
+ updated_at: new Date().toISOString(),
171
+ seen: mergedSeen,
172
+ });
173
+
174
+ return { sourcesRead, sourcesSkipped, seenThisRun, cachePath };
175
+ }
176
+
177
+ /**
178
+ * @param {unknown} code
179
+ * @returns {string} an errno code (`EISDIR`) when it has the errno shape, else `unknown error` —
180
+ * the reason string must never carry free text.
181
+ */
182
+ function safeErrnoCode(code) {
183
+ return typeof code === 'string' && /^E[A-Z0-9]+$/.test(code) ? code : 'unknown error';
184
+ }
185
+
186
+ /**
187
+ * @param {unknown} err - a `JSON.parse` SyntaxError.
188
+ * @returns {string} ` (at position N)` when V8 reported one, else `''` — digits only, never the
189
+ * quoted source excerpt V8 also puts in the message.
190
+ */
191
+ function jsonErrorPosition(err) {
192
+ const message = err instanceof Error ? err.message : '';
193
+ const match = /\bposition (\d+)\b/.exec(message);
194
+ return match ? ` (at position ${match[1]})` : '';
195
+ }
196
+
197
+ /** A provider key that must never be written into a map — it names the prototype, not a provider. */
198
+ const FORBIDDEN_PROVIDER_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
199
+
200
+ /**
201
+ * Reads the existing user-level cache's `seen` map, tolerating a missing, unreadable, corrupt, or
202
+ * wrongly-shaped file by discarding it (never crashing a refresh — or a `validate` — over it).
203
+ * Only keeps `seen.<provider>` entries that are themselves arrays of strings — a string value
204
+ * (`new Set("abc")` would silently split it into characters) or any other shape is dropped rather
205
+ * than trusted. `__proto__`/`constructor`/`prototype` keys are dropped (round-2 MINOR): JSON.parse
206
+ * makes `"__proto__"` an OWN key, and assigning it into a plain object would replace that
207
+ * object's prototype with the id array. (A plain object, not a null-prototype one, on purpose:
208
+ * callers and tests compare the result against `{}` with strict deep equality.)
209
+ * @param {string} cachePath
210
+ * @returns {Promise<Record<string, string[]>>}
211
+ */
212
+ async function readExistingSeen(cachePath) {
213
+ /** @type {Record<string, string[]>} */
214
+ const cleaned = {};
215
+ let raw;
216
+ try {
217
+ raw = await readFile(cachePath, 'utf8');
218
+ } catch {
219
+ // ENOENT (never refreshed), EISDIR, EACCES, … — every read error means "nothing usable seen".
220
+ // A refresh then fails loudly at its own write step if the path is genuinely unwritable.
221
+ return cleaned;
222
+ }
223
+
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(raw);
227
+ } catch {
228
+ return cleaned; // corrupt JSON — a refresh is about to overwrite it with a valid file anyway.
229
+ }
230
+
231
+ const seen = parsed && typeof parsed === 'object' ? /** @type {any} */ (parsed).seen : undefined;
232
+ if (!seen || typeof seen !== 'object' || Array.isArray(seen)) {
233
+ return cleaned;
234
+ }
235
+ for (const [provider, ids] of Object.entries(seen)) {
236
+ if (FORBIDDEN_PROVIDER_KEYS.has(provider)) {
237
+ continue;
238
+ }
239
+ if (Array.isArray(ids) && ids.every((id) => typeof id === 'string')) {
240
+ cleaned[provider] = ids;
241
+ }
242
+ }
243
+ return cleaned;
244
+ }
245
+
246
+ /**
247
+ * Writes `contents` to `cachePath` atomically: write to a sibling temp file, then `rename()` it
248
+ * over the target. `rename()` within the same directory is a single filesystem operation, so a
249
+ * reader never observes a partially-written file, and an interrupted write leaves the ORIGINAL
250
+ * file (or no file) intact rather than a truncated one.
251
+ * @param {string} cachePath
252
+ * @param {Record<string, any>} contents
253
+ */
254
+ async function writeModelsCacheAtomically(cachePath, contents) {
255
+ await mkdir(path.dirname(cachePath), { recursive: true });
256
+ const tempPath = path.join(path.dirname(cachePath), `.models.json.${randomUUID()}.tmp`);
257
+ await writeFile(tempPath, `${JSON.stringify(contents, null, 2)}\n`, 'utf8');
258
+ try {
259
+ await rename(tempPath, cachePath);
260
+ } catch (err) {
261
+ await unlink(tempPath).catch(() => {});
262
+ throw err;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Loads the merged user-level cache written by {@link refreshFromCliCaches}, for `validate.mjs`'s
268
+ * `unknown-model-id` rule to pass in as `opts.seenInCache`. Returns `{}` when the cache has never
269
+ * been written, is corrupt, or is wrongly shaped (a project that never ran `--refresh-models`, or
270
+ * whose cache file got damaged, sees the same warnings it always did — never a crash).
271
+ * @param {{home?: string}} [opts]
272
+ * @returns {Promise<Record<string, string[]>>}
273
+ */
274
+ export async function loadSeenInCache(opts = {}) {
275
+ return readExistingSeen(userModelsCachePath(opts.home ?? os.homedir()));
276
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Per-provider schema compilation (plan §1.3, §5.2, O36) — the S2 session's answer schema and
3
+ * (in a later block) `s2.mjs`'s compiled-schema-per-provider both need this.
4
+ *
5
+ * OpenAI's structured-output "strict" mode has three rules a hand-written schema violates by
6
+ * default: (1) every property under an object must be listed in `required` (optional fields are
7
+ * instead modeled as **nullable** required fields), (2) every object must set
8
+ * `additionalProperties: false`, (3) nothing may be missing from `required`. Claude and Grok take
9
+ * the source schema unchanged (they support ordinary JSON Schema `required`/optional).
10
+ *
11
+ * **Nullability.** `makeNullable()` does ONE thing, unconditionally, for anything it cannot PROVE
12
+ * already accepts null: move the WHOLE original sub-schema into one `anyOf` branch and add
13
+ * `{type: 'null'}` as the other. That is correct regardless of what was inside — enum, const, an
14
+ * object, a `$ref`, a `not`, an `if/then/else`, a nested `anyOf` — because `null` gets its own
15
+ * branch that doesn't depend on anything the original constrains. `schemaAcceptsNull()` is
16
+ * deliberately conservative (round-2 MINOR): a sibling `type`/`const`/`enum` that excludes null
17
+ * wins over a union branch that allows it (`{type:'object', anyOf:[…,{type:'null'}]}` rejects
18
+ * null), and `$ref`/`not`/`if`/`then`/`else` are never "proven" — wrapping a schema that already
19
+ * accepted null is harmless, skipping one that didn't is the bug.
20
+ *
21
+ * **`additionalProperties: false`.** Every object node that owns a `properties` map gets
22
+ * `additionalProperties: false` unless it already declares an open-ended one, and a bare
23
+ * `{type: 'object'}` with no `properties` and no `additionalProperties` is closed to
24
+ * `{properties: {}, required: [], additionalProperties: false}`. A node that stays open-ended
25
+ * (`additionalProperties: true` or a schema — one of this project's deliberately open maps such as
26
+ * `thresholds`, `known_extra`) has NO representation in OpenAI strict mode; forcing it to `false`
27
+ * would silently drop the map semantics the source intends. Such nodes are left as they are and
28
+ * reported by {@link openAIStrictViolations}; `compileSchema(…, 'openai', {strict: true})` throws
29
+ * naming every one. B9's S2 answer schema has none; only the full project config schema (this
30
+ * package's own test) does.
31
+ */
32
+
33
+ /**
34
+ * @typedef {Record<string, any>} SchemaNode - see `schema-paths.mjs`'s typedef of the same name —
35
+ * a schema fragment's shape genuinely varies (`properties`, `$ref`, `anyOf`, `$defs`, …).
36
+ */
37
+
38
+ /**
39
+ * @param {SchemaNode} schema
40
+ * @param {"anthropic" | "openai" | "xai"} provider
41
+ * @param {{strict?: boolean}} [opts] - `strict: true` (openai only) throws when the compiled
42
+ * schema still has a node OpenAI strict mode cannot accept, naming each JSON pointer.
43
+ * @returns {SchemaNode} a fresh, independent schema object — never the same reference as `schema`.
44
+ */
45
+ export function compileSchema(schema, provider, opts = {}) {
46
+ const clone = structuredClone(schema);
47
+ if (provider !== 'openai') {
48
+ return clone;
49
+ }
50
+ compileForOpenAI(clone, new Set());
51
+ if (opts.strict === true) {
52
+ const violations = openAIStrictViolations(clone);
53
+ if (violations.length > 0) {
54
+ throw new Error(`schema cannot be made OpenAI-strict at: ${violations.join(', ')} (open-ended object: additionalProperties is not false)`);
55
+ }
56
+ }
57
+ return clone;
58
+ }
59
+
60
+ /**
61
+ * @param {SchemaNode | boolean} schema - a boolean schema (`true`/`false`) is legal JSON Schema.
62
+ * @returns {boolean} true only when `schema` provably validates `null`. Conservative: anything it
63
+ * cannot prove (a `$ref`, `not`, `if`/`then`/`else`) counts as "does not accept null".
64
+ */
65
+ function schemaAcceptsNull(schema) {
66
+ if (schema === true) {
67
+ return true;
68
+ }
69
+ if (!schema || typeof schema !== 'object') {
70
+ return false;
71
+ }
72
+ if ('type' in schema) {
73
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
74
+ if (!types.includes('null')) {
75
+ return false;
76
+ }
77
+ }
78
+ if ('const' in schema && schema.const !== null) {
79
+ return false;
80
+ }
81
+ if (Array.isArray(schema.enum) && !schema.enum.includes(null)) {
82
+ return false;
83
+ }
84
+ for (const unprovable of ['$ref', 'not', 'if', 'then', 'else']) {
85
+ if (unprovable in schema) {
86
+ return false;
87
+ }
88
+ }
89
+ if (Array.isArray(schema.anyOf) && !schema.anyOf.some(schemaAcceptsNull)) {
90
+ return false;
91
+ }
92
+ if (Array.isArray(schema.oneOf) && schema.oneOf.filter(schemaAcceptsNull).length !== 1) {
93
+ return false;
94
+ }
95
+ if (Array.isArray(schema.allOf) && !schema.allOf.every(schemaAcceptsNull)) {
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * Makes `propSchema` nullable, in place, unconditionally wrapping whatever it already was.
103
+ * @param {SchemaNode} propSchema
104
+ */
105
+ function makeNullable(propSchema) {
106
+ if (!propSchema || typeof propSchema !== 'object' || schemaAcceptsNull(propSchema)) {
107
+ return;
108
+ }
109
+ /** @type {SchemaNode} */
110
+ const original = {};
111
+ for (const key of Object.keys(propSchema)) {
112
+ original[key] = propSchema[key];
113
+ delete propSchema[key];
114
+ }
115
+ propSchema.anyOf = [original, { type: 'null' }];
116
+ }
117
+
118
+ /**
119
+ * @param {SchemaNode} node
120
+ * @returns {boolean}
121
+ */
122
+ function isObjectTyped(node) {
123
+ return node.type === 'object' || (Array.isArray(node.type) && node.type.includes('object'));
124
+ }
125
+
126
+ /**
127
+ * Every child schema of `node` with its JSON-pointer suffix — the ONE traversal both the compiler
128
+ * and {@link openAIStrictViolations} use, so neither can skip a keyword the other visits.
129
+ * @param {SchemaNode} node
130
+ * @returns {Array<[string, SchemaNode]>}
131
+ */
132
+ function childSchemas(node) {
133
+ /** @type {Array<[string, SchemaNode]>} */
134
+ const out = [];
135
+ const push = (/** @type {string} */ suffix, /** @type {unknown} */ child) => {
136
+ if (child && typeof child === 'object') {
137
+ out.push([suffix, /** @type {SchemaNode} */ (child)]);
138
+ }
139
+ };
140
+ for (const [key, child] of Object.entries(node.properties ?? {})) push(`/properties/${key}`, child);
141
+ for (const keyword of ['anyOf', 'oneOf', 'allOf', 'prefixItems']) {
142
+ if (Array.isArray(node[keyword])) node[keyword].forEach((child, i) => push(`/${keyword}/${i}`, child));
143
+ }
144
+ for (const keyword of ['items', 'additionalProperties', 'not', 'if', 'then', 'else']) push(`/${keyword}`, node[keyword]);
145
+ for (const keyword of ['patternProperties', '$defs', 'definitions']) {
146
+ for (const [key, child] of Object.entries(node[keyword] ?? {})) push(`/${keyword}/${key}`, child);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * Recursively rewrites every object node: a `properties` map makes every property `required` and
153
+ * nullable and closes the node (unless it already has an open-ended `additionalProperties`); a
154
+ * bare `{type: 'object'}` is closed to an empty strict object. `visited` guards against revisiting
155
+ * the same shared node twice — not against a genuine schema cycle, which this project's schema
156
+ * does not have.
157
+ * @param {SchemaNode} node
158
+ * @param {Set<SchemaNode>} visited
159
+ */
160
+ function compileForOpenAI(node, visited) {
161
+ if (!node || typeof node !== 'object' || visited.has(node)) {
162
+ return;
163
+ }
164
+ visited.add(node);
165
+
166
+ if (node.properties && typeof node.properties === 'object') {
167
+ const keys = Object.keys(node.properties);
168
+ node.required = keys;
169
+ if (node.additionalProperties === undefined) {
170
+ node.additionalProperties = false;
171
+ }
172
+ for (const key of keys) {
173
+ makeNullable(node.properties[key]);
174
+ }
175
+ } else if (isObjectTyped(node) && node.additionalProperties === undefined && node.patternProperties === undefined) {
176
+ node.properties = {};
177
+ node.required = [];
178
+ node.additionalProperties = false;
179
+ }
180
+
181
+ for (const [, child] of childSchemas(node)) {
182
+ compileForOpenAI(child, visited);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Every object node in an (already compiled) schema that OpenAI strict mode cannot accept: an
188
+ * object-typed or `properties`-owning node whose `additionalProperties` is not exactly `false`.
189
+ * @param {SchemaNode} compiled
190
+ * @returns {string[]} JSON pointers (`#/properties/thresholds`), in traversal order.
191
+ */
192
+ export function openAIStrictViolations(compiled) {
193
+ /** @type {string[]} */
194
+ const out = [];
195
+ /** @type {Set<SchemaNode>} */
196
+ const visited = new Set();
197
+ const walk = (/** @type {SchemaNode} */ node, /** @type {string} */ pointer) => {
198
+ if (!node || typeof node !== 'object' || visited.has(node)) {
199
+ return;
200
+ }
201
+ visited.add(node);
202
+ if ((isObjectTyped(node) || (node.properties && typeof node.properties === 'object')) && node.additionalProperties !== false) {
203
+ out.push(pointer);
204
+ }
205
+ for (const [suffix, child] of childSchemas(node)) {
206
+ walk(child, `${pointer}${suffix}`);
207
+ }
208
+ };
209
+ walk(compiled, '#');
210
+ return out;
211
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A small, shared JSON-Schema navigator used by tests (schema-coverage, the config-contract path
3
+ * enumeration) and by `schema-compile.mjs`. Handles exactly what `schema/code-forge.schema.json`
4
+ * actually uses between two dotted-path segments: a `properties` lookup, a typed
5
+ * `additionalProperties` map (`thresholds.<question>`, `known_extra.<provider>`, `keys.<name>`), a
6
+ * `$ref` (`levels.L0` points at `#/$defs/level`), and an `anyOf` branch (`review.second_levels` is
7
+ * `anyOf: [null, {additionalProperties: level}]` — nullable-but-otherwise-a-map). Nothing richer
8
+ * than that (no `oneOf`/`allOf`) appears anywhere in this schema. **B1.1 (v1.3)** added
9
+ * `review.max_rounds_per_file`, `review.recheck_scope`, `review.late_findings`, `budget.block_cases`
10
+ * and `tmp.root` — each is a plain `properties` lookup (`tmp.root` an `anyOf: [string, null]` leaf,
11
+ * the same shape `review.second_levels` already exercises below) — no new traversal shape needed.
12
+ *
13
+ * @typedef {Record<string, any>} SchemaNode - a raw JSON-Schema fragment. Left as an index-typed
14
+ * object rather than a precise union: a schema node's shape genuinely varies (bare `properties`,
15
+ * `$ref`, `anyOf`, `additionalProperties`, `$defs`, …) and this module's whole job is walking
16
+ * between those shapes generically.
17
+ */
18
+
19
+ /**
20
+ * @param {SchemaNode} node
21
+ * @param {SchemaNode} rootSchema
22
+ * @returns {SchemaNode} `node` itself, or the `$defs` target its `$ref` points at.
23
+ */
24
+ export function deref(node, rootSchema) {
25
+ if (node && typeof node === 'object' && typeof node.$ref === 'string') {
26
+ const parts = node.$ref.replace(/^#\//, '').split('/');
27
+ let target = rootSchema;
28
+ for (const part of parts) {
29
+ target = target?.[part];
30
+ }
31
+ return target;
32
+ }
33
+ return node;
34
+ }
35
+
36
+ /**
37
+ * Resolves `segments` starting from `node` (already expected to need dereferencing itself).
38
+ * @param {SchemaNode} node
39
+ * @param {string[]} segments
40
+ * @param {SchemaNode} rootSchema
41
+ * @returns {SchemaNode | undefined}
42
+ */
43
+ function resolveFrom(node, segments, rootSchema) {
44
+ const current = deref(node, rootSchema);
45
+ if (!current) {
46
+ return undefined;
47
+ }
48
+ if (segments.length === 0) {
49
+ return current;
50
+ }
51
+ const [segment, ...rest] = segments;
52
+
53
+ const props = current.properties;
54
+ if (props && Object.prototype.hasOwnProperty.call(props, segment)) {
55
+ return resolveFrom(props[segment], rest, rootSchema);
56
+ }
57
+ if (typeof current.additionalProperties === 'object' && current.additionalProperties !== null) {
58
+ return resolveFrom(current.additionalProperties, rest, rootSchema);
59
+ }
60
+ if (Array.isArray(current.anyOf)) {
61
+ for (const branch of current.anyOf) {
62
+ const resolved = resolveFrom(branch, segments, rootSchema);
63
+ if (resolved !== undefined) {
64
+ return resolved;
65
+ }
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /**
72
+ * Walks a dotted path (`"levels.L0.model"`, `"review.second_levels.L2.provider"`) through the
73
+ * schema, dereferencing `$ref`s and `anyOf` branches as needed at every step.
74
+ *
75
+ * @param {SchemaNode} rootSchema
76
+ * @param {string} dottedPath - the empty string resolves to `rootSchema` itself (zero segments).
77
+ * @returns {SchemaNode | undefined} the leaf sub-schema, or `undefined` if any segment is missing.
78
+ */
79
+ export function resolveSchemaPath(rootSchema, dottedPath) {
80
+ // `''.split('.')` is `['']` (one EMPTY-STRING segment), not zero segments — without this guard
81
+ // the empty path would look up `properties['']` and fail, instead of meaning "the root itself".
82
+ const segments = dottedPath === '' ? [] : dottedPath.split('.');
83
+ return resolveFrom(rootSchema, segments, rootSchema);
84
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Secret-SHAPED text detection, shared by the validator's `secret-looking-value` rule and by every
3
+ * B1 path that prints text derived from a user's files (`validate.mjs` messages, `load.mjs` parse
4
+ * errors, `refresh.mjs` skip reasons, the `validate`/`models` verbs).
5
+ *
6
+ * This is the SHAPE layer. B0's `redact()` is the other layer: it masks secrets a key resolver
7
+ * has explicitly registered, and deliberately never guesses. Config text is never registered — it
8
+ * is exactly where a user pastes a key by mistake — so B1 masks anything shaped like a vendor
9
+ * secret on top of `redact()`.
10
+ */
11
+
12
+ /**
13
+ * Conservative, prefix-shaped patterns for common vendor secret formats. Deliberately NOT a
14
+ * generic high-entropy/long-string heuristic: a model id like `claude-haiku-4-5-20251001` or a
15
+ * `keys.jev` reference like `op://vault/item/field` is exactly the kind of long, dash-heavy,
16
+ * mixed-alnum string a naive entropy check would also flag — refusing every valid config forever.
17
+ * Every pattern anchors on a real-world secret PREFIX no legitimate config value shares.
18
+ *
19
+ * NOT `^…$`-anchored: a token embedded in a longer string (a gates argv element
20
+ * `Authorization: Bearer sk-…`) must be caught too. The lookbehind is the word-boundary stand-in —
21
+ * the prefix may not be glued to a preceding identifier character, so `task-…` or `disk-…` never
22
+ * match the `sk-` pattern. No `g`/`y` flag: `.test()` must carry no `lastIndex` state.
23
+ */
24
+ export const SECRET_LOOKING_PATTERNS = Object.freeze([
25
+ /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{10,}/, // OpenAI / Anthropic API keys
26
+ /(?<![A-Za-z0-9_-])xai-[A-Za-z0-9]{20,}/, // xAI API keys
27
+ /(?<![A-Za-z0-9_-])xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens
28
+ /(?<![A-Za-z0-9_-])gh[opsu]_[A-Za-z0-9]{20,}/, // GitHub tokens (classic prefixes)
29
+ /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PATs
30
+ /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{12,}/, // AWS access key id
31
+ /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{20,}/, // Google API key
32
+ /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/, // JWT
33
+ ]);
34
+
35
+ /** The mask every B1 scrub writes in place of a secret-shaped token (same text as B0's `redact`). */
36
+ export const SECRET_MASK = '[REDACTED]';
37
+
38
+ /**
39
+ * A `keys.<name>` value is a REFERENCE (`env:NAME`, `op://…`, `user`), never a secret, by design.
40
+ * @param {unknown} value
41
+ * @returns {boolean}
42
+ */
43
+ export function isKeyReferenceValue(value) {
44
+ return typeof value === 'string' && (value.startsWith('env:') || value.startsWith('op://') || value === 'user');
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} value
49
+ * @returns {boolean} true when `value` is a string holding at least one secret-shaped token and is
50
+ * not itself a key reference.
51
+ */
52
+ export function looksLikeSecret(value) {
53
+ if (typeof value !== 'string' || isKeyReferenceValue(value)) {
54
+ return false;
55
+ }
56
+ return SECRET_LOOKING_PATTERNS.some((pattern) => pattern.test(value));
57
+ }
58
+
59
+ /**
60
+ * @param {string} text
61
+ * @returns {string[]} every secret-shaped token inside `text` (the matched substrings).
62
+ */
63
+ export function secretTokensIn(text) {
64
+ const tokens = [];
65
+ for (const pattern of SECRET_LOOKING_PATTERNS) {
66
+ for (const match of text.matchAll(new RegExp(pattern.source, 'g'))) {
67
+ tokens.push(match[0]);
68
+ }
69
+ }
70
+ return tokens;
71
+ }
72
+
73
+ /**
74
+ * Masks every secret-shaped token in `text`. Applied to OUTPUT text (a message, a reason), so no
75
+ * key-reference exemption: a message never needs to show a token in full.
76
+ * @param {string} text
77
+ * @returns {string}
78
+ */
79
+ export function maskSecretTokens(text) {
80
+ let out = text;
81
+ for (const pattern of SECRET_LOOKING_PATTERNS) {
82
+ out = out.replace(new RegExp(pattern.source, 'g'), SECRET_MASK);
83
+ }
84
+ return out;
85
+ }