@codedology/code-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The config validator (plan §1.3 "Validator refusals" + §10.3 B1 acceptance: "14 refusals ⇒ 14
|
|
3
|
+
* tests"). Two independent layers feed one result:
|
|
4
|
+
*
|
|
5
|
+
* 1. **Schema validation** (Ajv, draft 2020-12, `additionalProperties: false` almost everywhere)
|
|
6
|
+
* catches shape errors: unknown keys, wrong types, missing required fields.
|
|
7
|
+
* 2. **Domain rules** (`DOMAIN_RULES` below, 14 of them as of B1.2/v1.3) catch things no JSON Schema can express:
|
|
8
|
+
* cross-field comparisons (two levels resolving to the same tuple), a value that needs a
|
|
9
|
+
* second config value to be reachable (`second_levels.L2.provider` vs. the effective L2
|
|
10
|
+
* provider) and rules that reach outside the config object entirely (is a CLI on PATH, is a
|
|
11
|
+
* model id something a later `--refresh-models` run has seen). Every domain rule is its own
|
|
12
|
+
* named, independently testable function — most take `cfg` alone; two (`fallback-unknown-
|
|
13
|
+
* or-cli-absent`, `engine-subprocess-no-cli`) take an **injected** `hasCliOnPath` function
|
|
14
|
+
* instead of touching `process.env.PATH` themselves, so a unit test never needs a real CLI on
|
|
15
|
+
* PATH to prove either branch.
|
|
16
|
+
*
|
|
17
|
+
* The 14 rules (B1's original 14 plus B1.1's `caps-coders-exceeds-cap`, minus B1.2's removal of
|
|
18
|
+
* `proof-tool-absent-for-high-tier` — Q16 answered "cut", so `proof.tiers.high.tool` no longer
|
|
19
|
+
* exists to be absent, plan §10.4 Wave 6), and how the plan's prose bullets map onto them (§1.3's
|
|
20
|
+
* "review.multimodel: true with second_provider empty **or with**
|
|
21
|
+
* resolve(L2).provider == second_levels.L2.provider" is two independently-firing conditions, so it
|
|
22
|
+
* is implemented — and tested — as two rules):
|
|
23
|
+
* 1 level-missing ERROR
|
|
24
|
+
* 2 unknown-model-id WARN (known_extra / seen-in-cache lift it, C15)
|
|
25
|
+
* 3 duplicate-level-tuple ERROR
|
|
26
|
+
* 4 stop-at-not-l3 ERROR
|
|
27
|
+
* 5 multimodel-missing-second-provider ERROR
|
|
28
|
+
* 6 multimodel-effective-l2-collision ERROR ("effective-L2 consensus rule")
|
|
29
|
+
* 7 judge-family-collision ERROR (consensus mode only)
|
|
30
|
+
* 8 orchestrator-l3-multimodel-off WARN
|
|
31
|
+
* 9 gates-not-argv-array ERROR
|
|
32
|
+
* 10 secret-looking-value ERROR (names the key)
|
|
33
|
+
* 11 fallback-unknown-or-cli-absent WARN ("fallback validation")
|
|
34
|
+
* 12 engine-subprocess-no-cli ERROR
|
|
35
|
+
* 13 shadow-rate-range ERROR ("shadow_rate range")
|
|
36
|
+
* 14 caps-coders-exceeds-cap WARN (v1.3/R10/B1.1: caps.coders > 2)
|
|
37
|
+
*
|
|
38
|
+
* **B1.2 (v1.3, Q16 cut) removed rule:** `proof-tool-absent-for-high-tier` (formerly #13) — the
|
|
39
|
+
* key it warned about (`proof.tiers.high.tool`) left the schema in the same amendment, along with
|
|
40
|
+
* `proof.tiers.high.min_msi`, `proof.js` and `proof.nightly` (the tool-made-mutation-only keys).
|
|
41
|
+
* `proof.tiers.high.paths` alone no longer implies a missing tool, so the rule has nothing left to
|
|
42
|
+
* check. The 15 -> 14 count and the renumbering below (old 14/15 -> new 13/14) are this block's
|
|
43
|
+
* amendment.
|
|
44
|
+
*
|
|
45
|
+
* **No config value ever reaches a message (fix round 3, MAJOR).** The `validate` verb prints
|
|
46
|
+
* every message, and a user can paste an API key into ANY string field — a model id, an effort,
|
|
47
|
+
* a fallback, `escalation.stop_at`. So a message names the key PATH and the rule, never the value
|
|
48
|
+
* found there. The only config-derived words a message may contain are (a) key-path segments,
|
|
49
|
+
* each passed through `safeSegment` (a secret pasted in as a map KEY is masked too), and (b) a
|
|
50
|
+
* provider name, only when it is one of the schema's own closed `$defs.provider.enum` words
|
|
51
|
+
* (`safeProvider`) — anything else prints as `(unrecognised provider)`. As a second layer every
|
|
52
|
+
* message is then scrubbed by `finalizeMessage`: B0's `redact()` (registered secrets) plus a local
|
|
53
|
+
* pass that masks every secret-looking token found anywhere in THIS config, so a future rule that
|
|
54
|
+
* regresses and interpolates a value still cannot print one.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
// Named import, not default: TypeScript's NodeNext resolution treats ajv's CJS-authored .d.ts as
|
|
58
|
+
// non-constructable through the default-export path (`new (import default)()` fails checkJs with
|
|
59
|
+
// TS2351) — the named class export resolves cleanly under both checkJs and at runtime. `[facts]`
|
|
60
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
61
|
+
import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
|
|
62
|
+
import { redact } from '../util/redact.mjs';
|
|
63
|
+
import { isKnownId } from './known-ids.mjs';
|
|
64
|
+
import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {object} ValidationIssue
|
|
68
|
+
* @property {string} rule
|
|
69
|
+
* @property {"error" | "warning"} severity
|
|
70
|
+
* @property {string} message
|
|
71
|
+
* @property {string} [keyword] - schema issues only: the Ajv keyword that failed (`propertyNames`, `type`, …).
|
|
72
|
+
* @property {string} [path] - schema issues only: the (sanitised) JSON pointer of the failing instance.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {object} ValidationResult
|
|
77
|
+
* @property {boolean} valid - `false` when at least one ERROR-severity issue was found (schema or domain).
|
|
78
|
+
* @property {ValidationIssue[]} errors
|
|
79
|
+
* @property {ValidationIssue[]} warnings
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
|
83
|
+
const ajvValidate = ajv.compile(schema);
|
|
84
|
+
|
|
85
|
+
// ── Secret detection (rule 10, and the message scrub every rule's output passes through) ──
|
|
86
|
+
// The patterns themselves live in `./secret-patterns.mjs` (shared with load/refresh/the verbs).
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collects every secret-looking string in `cfg` — values AND keys, whole string AND each matched
|
|
90
|
+
* token — for `finalizeMessage`'s local scrub.
|
|
91
|
+
* @param {unknown} node
|
|
92
|
+
* @param {Set<string>} out
|
|
93
|
+
*/
|
|
94
|
+
function collectSecretStrings(node, out) {
|
|
95
|
+
const consider = (/** @type {string} */ text) => {
|
|
96
|
+
if (looksLikeSecret(text)) {
|
|
97
|
+
out.add(text);
|
|
98
|
+
for (const token of secretTokensIn(text)) {
|
|
99
|
+
out.add(token);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
if (typeof node === 'string') {
|
|
104
|
+
consider(node);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(node)) {
|
|
108
|
+
for (const item of node) {
|
|
109
|
+
collectSecretStrings(item, out);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (node !== null && typeof node === 'object') {
|
|
114
|
+
for (const [key, value] of Object.entries(node)) {
|
|
115
|
+
consider(key);
|
|
116
|
+
collectSecretStrings(value, out);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The second layer every message passes through: B0's `redact()` (secrets a key resolver has
|
|
123
|
+
* registered), then a local mask of every secret-looking string this config contains (longest
|
|
124
|
+
* first, so a whole value is masked before one of its own tokens could split it), then a final
|
|
125
|
+
* shape-based mask of any secret-shaped token still in the text.
|
|
126
|
+
* @param {string} message
|
|
127
|
+
* @param {string[]} secretsLongestFirst
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function finalizeMessage(message, secretsLongestFirst) {
|
|
131
|
+
let out = redact(message);
|
|
132
|
+
for (const secret of secretsLongestFirst) {
|
|
133
|
+
out = out.split(secret).join(SECRET_MASK);
|
|
134
|
+
}
|
|
135
|
+
return maskSecretTokens(out);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Safe renderers: the ONLY ways config-derived text may enter a message ──
|
|
139
|
+
|
|
140
|
+
/** The schema's own closed provider vocabulary — a word from here is a constant, not user data. */
|
|
141
|
+
const PROVIDER_NAMES = Object.freeze(/** @type {string[]} */ (schema.$defs.provider.enum));
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {unknown} provider
|
|
145
|
+
* @returns {string} `"anthropic"` (quoted) for a known provider, `(unrecognised provider)` otherwise.
|
|
146
|
+
*/
|
|
147
|
+
function safeProvider(provider) {
|
|
148
|
+
return typeof provider === 'string' && PROVIDER_NAMES.includes(provider) ? `"${provider}"` : '(unrecognised provider)';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A key-path segment as it may appear in a message: masked when the KEY itself looks like a secret
|
|
153
|
+
* (a token pasted in as a `keys.<name>` / `known_extra.<provider>` map key), verbatim otherwise.
|
|
154
|
+
* @param {string} segment
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
function safeSegment(segment) {
|
|
158
|
+
return looksLikeSecret(segment) ? '<redacted-key>' : segment;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} pointer - an Ajv `instancePath` (`/a/b/0`).
|
|
163
|
+
* @returns {string} the same pointer, every segment through `safeSegment`.
|
|
164
|
+
*/
|
|
165
|
+
function safePointer(pointer) {
|
|
166
|
+
if (!pointer) {
|
|
167
|
+
return '(root)';
|
|
168
|
+
}
|
|
169
|
+
return pointer
|
|
170
|
+
.split('/')
|
|
171
|
+
.map((segment, i) => (i === 0 ? segment : safeSegment(segment.replace(/~1/g, '/').replace(/~0/g, '~'))))
|
|
172
|
+
.join('/');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 0. schema (Ajv) ─────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Ajv's own `message` templates never interpolate the instance value (only schema-side words:
|
|
179
|
+
* a type name, an allowed-values list, a limit). The parts that come from the CONFIG are the
|
|
180
|
+
* instance path and, for `propertyNames`/`additionalProperties`, the offending key — each rendered
|
|
181
|
+
* through `safeSegment`. An `enum`/`pattern` error that Ajv emits FOR a property name (it carries
|
|
182
|
+
* `err.propertyName`) is folded into the `propertyNames` error that always follows it, so one bad
|
|
183
|
+
* key produces exactly one issue.
|
|
184
|
+
* @param {Record<string, any>} cfg
|
|
185
|
+
* @returns {ValidationIssue[]}
|
|
186
|
+
*/
|
|
187
|
+
function runSchemaValidation(cfg) {
|
|
188
|
+
const ok = ajvValidate(cfg);
|
|
189
|
+
if (ok) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
return (ajvValidate.errors ?? [])
|
|
193
|
+
.filter((err) => /** @type {any} */ (err).propertyName === undefined)
|
|
194
|
+
.map((err) => {
|
|
195
|
+
const where = safePointer(err.instancePath);
|
|
196
|
+
let detail = err.message ?? 'is invalid';
|
|
197
|
+
if (err.keyword === 'propertyNames' && typeof err.params?.propertyName === 'string') {
|
|
198
|
+
detail = `property name "${safeSegment(err.params.propertyName)}" is not allowed (propertyNames)`;
|
|
199
|
+
} else if (err.keyword === 'additionalProperties' && typeof err.params?.additionalProperty === 'string') {
|
|
200
|
+
detail = `must NOT have additional property "${safeSegment(err.params.additionalProperty)}"`;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
rule: 'schema',
|
|
204
|
+
severity: /** @type {const} */ ('error'),
|
|
205
|
+
keyword: err.keyword,
|
|
206
|
+
path: where,
|
|
207
|
+
message: `${where} ${detail}`,
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Small, reusable helpers over an already-parsed config object ───────────
|
|
213
|
+
|
|
214
|
+
const LEVEL_NAMES = /** @type {const} */ (['L0', 'L1', 'L2', 'L3']);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `validateConfig` is documented to accept a config that isn't schema-valid yet (domain rules run
|
|
218
|
+
* even when Ajv already failed it), so every helper below must tolerate a wrongly-shaped value
|
|
219
|
+
* without throwing — the schema layer is what REPORTS a shape violation; a domain rule just needs
|
|
220
|
+
* to not crash on one. `asStringArray` is the one place that trust boundary is enforced: anything
|
|
221
|
+
* that isn't an array of strings becomes `[]` rather than something `.includes()` might throw on
|
|
222
|
+
* (a number) or silently mis-match against (a plain string, which `Set`/`Array` iteration would
|
|
223
|
+
* otherwise split into individual characters).
|
|
224
|
+
* @param {unknown} value
|
|
225
|
+
* @returns {string[]}
|
|
226
|
+
*/
|
|
227
|
+
function asStringArray(value) {
|
|
228
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* An OWN entry of a (possibly hostile) map as a string array — `Object.hasOwn`, never a bare index,
|
|
233
|
+
* so a provider named `constructor`/`__proto__` can't resolve to an inherited member.
|
|
234
|
+
* @param {unknown} map
|
|
235
|
+
* @param {string} key
|
|
236
|
+
* @returns {string[]}
|
|
237
|
+
*/
|
|
238
|
+
function ownStringList(map, key) {
|
|
239
|
+
if (!map || typeof map !== 'object' || !Object.hasOwn(map, key)) {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
return asStringArray(/** @type {Record<string, unknown>} */ (map)[key]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {Record<string, any>} cfg
|
|
247
|
+
* @param {"L0"|"L1"|"L2"|"L3"} levelName
|
|
248
|
+
* @returns {{provider: string, model: string, effort: string | undefined} | null} `null` when the
|
|
249
|
+
* level, its model, OR its resolved provider is missing/not-a-string — callers that need a
|
|
250
|
+
* level to exist check `level-missing` first; callers that read `.provider` never see anything
|
|
251
|
+
* but a real string here.
|
|
252
|
+
*/
|
|
253
|
+
function effectiveLevel(cfg, levelName) {
|
|
254
|
+
const level = cfg?.levels?.[levelName];
|
|
255
|
+
if (!hasModel(level)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const provider = level.provider ?? cfg?.provider;
|
|
259
|
+
if (typeof provider !== 'string') {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
return { provider, model: level.model, effort: level.effort };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {unknown} level
|
|
267
|
+
* @returns {level is {model: string, provider?: unknown, effort?: string}}
|
|
268
|
+
*/
|
|
269
|
+
function hasModel(level) {
|
|
270
|
+
return Boolean(level) && typeof (/** @type {any} */ (level).model) === 'string' && /** @type {any} */ (level).model.length > 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The effective provider for the SECOND reviewer's L2 slot: `review.second_levels.L2.provider`
|
|
275
|
+
* when set, else `review.second_provider` (plan §1.3: `second_levels` is a per-level override on
|
|
276
|
+
* top of `second_provider`, mirroring how `levels.Lx.provider` overrides the top-level `provider`).
|
|
277
|
+
* @param {Record<string, any>} cfg
|
|
278
|
+
* @returns {string | undefined}
|
|
279
|
+
*/
|
|
280
|
+
function effectiveSecondL2Provider(cfg) {
|
|
281
|
+
return cfg?.review?.second_levels?.L2?.provider ?? cfg?.review?.second_provider ?? undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── 1. level-missing (ERROR) ────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Two distinct causes, two distinct messages (round-2 MINOR): a level with no model at all, vs.
|
|
288
|
+
* a level that HAS a model but whose provider can't be resolved (neither `levels.Lx.provider` nor
|
|
289
|
+
* the top-level `provider` is a string) — the second used to be reported as "missing or has no
|
|
290
|
+
* model", pointing the user at the wrong fix.
|
|
291
|
+
*/
|
|
292
|
+
function checkLevelMissing(cfg) {
|
|
293
|
+
const issues = [];
|
|
294
|
+
for (const name of LEVEL_NAMES) {
|
|
295
|
+
if (effectiveLevel(cfg, name)) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const message = hasModel(cfg?.levels?.[name])
|
|
299
|
+
? `levels.${name} has no provider (neither levels.${name}.provider nor the top-level provider is set)`
|
|
300
|
+
: `levels.${name} is missing or has no model`;
|
|
301
|
+
issues.push({ rule: 'level-missing', severity: 'error', message });
|
|
302
|
+
}
|
|
303
|
+
return issues;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── 2. unknown-model-id (WARN) ──────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {Record<string, any>} cfg
|
|
310
|
+
* @param {{seenInCache?: Record<string, string[]>}} [opts]
|
|
311
|
+
*/
|
|
312
|
+
function checkUnknownModelId(cfg, opts = {}) {
|
|
313
|
+
const issues = [];
|
|
314
|
+
for (const name of LEVEL_NAMES) {
|
|
315
|
+
const level = effectiveLevel(cfg, name);
|
|
316
|
+
if (!level) continue;
|
|
317
|
+
const knownExtra = ownStringList(cfg?.known_extra, level.provider);
|
|
318
|
+
const seen = ownStringList(opts.seenInCache, level.provider);
|
|
319
|
+
if (!isKnownId(level.provider, level.model) && !knownExtra.includes(level.model) && !seen.includes(level.model)) {
|
|
320
|
+
issues.push({
|
|
321
|
+
rule: 'unknown-model-id',
|
|
322
|
+
severity: 'warning',
|
|
323
|
+
message: `levels.${name}.model is not a known ${safeProvider(level.provider)} id, not in known_extra, and not seen-in-cache`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return issues;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── 3. duplicate-level-tuple (ERROR) ────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
function checkDuplicateLevelTuple(cfg) {
|
|
333
|
+
/** @type {Map<string, string>} */
|
|
334
|
+
const seenTuples = new Map();
|
|
335
|
+
const issues = [];
|
|
336
|
+
for (const name of LEVEL_NAMES) {
|
|
337
|
+
const level = effectiveLevel(cfg, name);
|
|
338
|
+
if (!level) continue;
|
|
339
|
+
const tuple = JSON.stringify([level.provider, level.model, level.effort ?? '']);
|
|
340
|
+
const first = seenTuples.get(tuple);
|
|
341
|
+
if (first) {
|
|
342
|
+
issues.push({
|
|
343
|
+
rule: 'duplicate-level-tuple',
|
|
344
|
+
severity: 'error',
|
|
345
|
+
message: `levels.${first} and levels.${name} resolve to the same (provider, model, effort)`,
|
|
346
|
+
});
|
|
347
|
+
} else {
|
|
348
|
+
seenTuples.set(tuple, name);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return issues;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── 4. stop-at-not-l3 (ERROR) ───────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function checkStopAtNotL3(cfg) {
|
|
357
|
+
const stopAt = cfg?.escalation?.stop_at;
|
|
358
|
+
if (stopAt !== undefined && stopAt !== 'L3') {
|
|
359
|
+
return [{ rule: 'stop-at-not-l3', severity: 'error', message: 'escalation.stop_at must be "L3"' }];
|
|
360
|
+
}
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── 5. multimodel-missing-second-provider (ERROR) ───────────────────────────
|
|
365
|
+
|
|
366
|
+
function checkMultimodelMissingSecondProvider(cfg) {
|
|
367
|
+
if (cfg?.review?.multimodel === true && !cfg?.review?.second_provider) {
|
|
368
|
+
return [
|
|
369
|
+
{
|
|
370
|
+
rule: 'multimodel-missing-second-provider',
|
|
371
|
+
severity: 'error',
|
|
372
|
+
message: 'review.multimodel is true but review.second_provider is empty',
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
}
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── 6. multimodel-effective-l2-collision (ERROR) ────────────────────────────
|
|
380
|
+
|
|
381
|
+
function checkMultimodelEffectiveL2Collision(cfg) {
|
|
382
|
+
if (cfg?.review?.multimodel !== true) {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
const primary = effectiveLevel(cfg, 'L2');
|
|
386
|
+
const second = effectiveSecondL2Provider(cfg);
|
|
387
|
+
if (primary && second && primary.provider === second) {
|
|
388
|
+
return [
|
|
389
|
+
{
|
|
390
|
+
rule: 'multimodel-effective-l2-collision',
|
|
391
|
+
severity: 'error',
|
|
392
|
+
message: `the effective L2 provider (${safeProvider(primary.provider)}) equals the second reviewer's effective L2 provider (review.second_levels.L2.provider, else review.second_provider) — multimodel needs two different providers`,
|
|
393
|
+
},
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── 7. judge-family-collision (ERROR, consensus mode only) ─────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Fires when the L3 judge's provider equals EITHER reviewer's provider (not "both" — the old
|
|
403
|
+
* `&&` version could only ever fire when `reviewer1.provider === reviewer2.provider`, which is
|
|
404
|
+
* EXACTLY rule 6's condition, so rule 7 could never fire independently of rule 6). The judge must
|
|
405
|
+
* be independent of BOTH reviewers, so colliding with just one is already a real defect.
|
|
406
|
+
*/
|
|
407
|
+
function checkJudgeFamilyCollision(cfg) {
|
|
408
|
+
if (cfg?.review?.multimodel !== true) {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
const judge = effectiveLevel(cfg, 'L3');
|
|
412
|
+
if (!judge) {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
const reviewer1 = effectiveLevel(cfg, 'L2');
|
|
416
|
+
const reviewer2Provider = effectiveSecondL2Provider(cfg);
|
|
417
|
+
const collidesWithReviewer1 = Boolean(reviewer1) && judge.provider === reviewer1.provider;
|
|
418
|
+
const collidesWithReviewer2 = Boolean(reviewer2Provider) && judge.provider === reviewer2Provider;
|
|
419
|
+
if (collidesWithReviewer1 || collidesWithReviewer2) {
|
|
420
|
+
const which = collidesWithReviewer1 && collidesWithReviewer2 ? 'both reviewers' : collidesWithReviewer1 ? 'reviewer 1 (L2)' : 'reviewer 2 (second_levels.L2)';
|
|
421
|
+
return [
|
|
422
|
+
{
|
|
423
|
+
rule: 'judge-family-collision',
|
|
424
|
+
severity: 'error',
|
|
425
|
+
message: `the L3 judge's provider (${safeProvider(judge.provider)}) equals ${which}'s provider — the judge can't independently check a reviewer it shares a family with`,
|
|
426
|
+
},
|
|
427
|
+
];
|
|
428
|
+
}
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── 8. orchestrator-l3-multimodel-off (WARN) ────────────────────────────────
|
|
433
|
+
|
|
434
|
+
function checkOrchestratorL3MultimodelOff(cfg) {
|
|
435
|
+
if (cfg?.orchestrator === 'L3' && cfg?.review?.multimodel !== true) {
|
|
436
|
+
return [
|
|
437
|
+
{
|
|
438
|
+
rule: 'orchestrator-l3-multimodel-off',
|
|
439
|
+
severity: 'warning',
|
|
440
|
+
message: 'orchestrator is L3 but review.multimodel is off',
|
|
441
|
+
},
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── 9. gates-not-argv-array (ERROR) ─────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
const GATE_KEYS = /** @type {const} */ (['test', 'lint', 'types', 'format']);
|
|
450
|
+
|
|
451
|
+
function isArgvArray(value) {
|
|
452
|
+
// A non-empty array of strings whose first element (the command itself) is non-empty — `[]`
|
|
453
|
+
// is rejected too: `null` already documents "disabled", so an empty argv would run nothing.
|
|
454
|
+
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string') && value[0].length > 0;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function checkGatesNotArgvArray(cfg) {
|
|
458
|
+
const issues = [];
|
|
459
|
+
for (const key of GATE_KEYS) {
|
|
460
|
+
const value = cfg?.gates?.[key];
|
|
461
|
+
if (value !== undefined && value !== null && !isArgvArray(value)) {
|
|
462
|
+
issues.push({
|
|
463
|
+
rule: 'gates-not-argv-array',
|
|
464
|
+
severity: 'error',
|
|
465
|
+
message: `gates.${key} must be a non-empty argv array of strings, or null`,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return issues;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── 10. secret-looking-value (ERROR) ────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Checks every string VALUE and every map KEY. A key that itself looks like a secret is reported
|
|
476
|
+
* at its parent path with the key masked — the message must never print the key it is refusing.
|
|
477
|
+
* @param {unknown} node
|
|
478
|
+
* @param {string} path
|
|
479
|
+
* @param {ValidationIssue[]} out
|
|
480
|
+
*/
|
|
481
|
+
function walkForSecrets(node, path, out) {
|
|
482
|
+
if (typeof node === 'string') {
|
|
483
|
+
if (looksLikeSecret(node)) {
|
|
484
|
+
out.push({
|
|
485
|
+
rule: 'secret-looking-value',
|
|
486
|
+
severity: 'error',
|
|
487
|
+
message: `${path || '(root)'} looks like a secret value — config must hold references, never plaintext secrets`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (Array.isArray(node)) {
|
|
493
|
+
node.forEach((item, i) => walkForSecrets(item, `${path}[${i}]`, out));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (node !== null && typeof node === 'object') {
|
|
497
|
+
for (const [key, value] of Object.entries(node)) {
|
|
498
|
+
const childPath = path ? `${path}.${safeSegment(key)}` : safeSegment(key);
|
|
499
|
+
if (looksLikeSecret(key)) {
|
|
500
|
+
out.push({
|
|
501
|
+
rule: 'secret-looking-value',
|
|
502
|
+
severity: 'error',
|
|
503
|
+
message: `${childPath} has a key that looks like a secret — config must hold references, never plaintext secrets`,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
walkForSecrets(value, childPath, out);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function checkSecretLookingValue(cfg) {
|
|
512
|
+
const issues = [];
|
|
513
|
+
walkForSecrets(cfg, '', issues);
|
|
514
|
+
return issues;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── 11. fallback-unknown-or-cli-absent (WARN) ───────────────────────────────
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* `levels.<name>.fallback` is documented (and schema-required, when present) to be an array of
|
|
521
|
+
* `{provider, model, effort?}` objects — but `validateConfig` must not crash when it isn't (a
|
|
522
|
+
* string, an object, an array containing `null`, …): that shape violation is ALREADY the schema
|
|
523
|
+
* layer's job to report as a `rule: "schema"` error. This function only ever reads a fallback
|
|
524
|
+
* entry once it has confirmed, itself, that the entry is a plain object with string
|
|
525
|
+
* `provider`/`model` — anything else is skipped, never dereferenced.
|
|
526
|
+
* @param {Record<string, any>} cfg
|
|
527
|
+
* @param {{seenInCache?: Record<string, string[]>, hasCliOnPath?: (provider: string) => boolean}} [opts]
|
|
528
|
+
*/
|
|
529
|
+
function checkFallbackUnknownOrCliAbsent(cfg, opts = {}) {
|
|
530
|
+
const issues = [];
|
|
531
|
+
for (const name of LEVEL_NAMES) {
|
|
532
|
+
const rawFallback = cfg?.levels?.[name]?.fallback;
|
|
533
|
+
const fallbackList = Array.isArray(rawFallback) ? rawFallback : [];
|
|
534
|
+
for (const [i, fb] of fallbackList.entries()) {
|
|
535
|
+
if (fb === null || typeof fb !== 'object' || typeof fb.provider !== 'string' || typeof fb.model !== 'string') {
|
|
536
|
+
continue; // malformed entry — the schema layer's own required/type checks report this
|
|
537
|
+
}
|
|
538
|
+
const knownExtra = ownStringList(cfg?.known_extra, fb.provider);
|
|
539
|
+
const seen = ownStringList(opts.seenInCache, fb.provider);
|
|
540
|
+
const modelUnknown =
|
|
541
|
+
!isKnownId(fb.provider, fb.model) && !knownExtra.includes(fb.model) && !seen.includes(fb.model);
|
|
542
|
+
const cliAbsent = typeof opts.hasCliOnPath === 'function' && !opts.hasCliOnPath(fb.provider);
|
|
543
|
+
if (modelUnknown || cliAbsent) {
|
|
544
|
+
issues.push({
|
|
545
|
+
rule: 'fallback-unknown-or-cli-absent',
|
|
546
|
+
severity: 'warning',
|
|
547
|
+
message: `levels.${name}.fallback[${i}] (provider ${safeProvider(fb.provider)})${modelUnknown ? ' has an unknown model id' : ''}${
|
|
548
|
+
modelUnknown && cliAbsent ? ' and' : ''
|
|
549
|
+
}${cliAbsent ? ' has no CLI on PATH' : ''}`,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return issues;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ── 12. engine-subprocess-no-cli (ERROR) ────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Checks EVERY distinct effective provider `engine: subprocess` could end up spawning — all four
|
|
561
|
+
* levels' own (possibly per-level-overridden) provider, plus the second reviewer's effective L2
|
|
562
|
+
* provider when multimodel is on — not just the top-level `cfg.provider`.
|
|
563
|
+
* @param {Record<string, any>} cfg
|
|
564
|
+
* @param {{hasCliOnPath?: (provider: string) => boolean}} [opts]
|
|
565
|
+
*/
|
|
566
|
+
function checkEngineSubprocessNoCli(cfg, opts = {}) {
|
|
567
|
+
if (cfg?.engine !== 'subprocess' || typeof opts.hasCliOnPath !== 'function') {
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
/** @type {Set<string>} */
|
|
571
|
+
const providers = new Set();
|
|
572
|
+
for (const name of LEVEL_NAMES) {
|
|
573
|
+
const level = effectiveLevel(cfg, name);
|
|
574
|
+
if (level) {
|
|
575
|
+
providers.add(level.provider);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (cfg?.review?.multimodel === true) {
|
|
579
|
+
const second = effectiveSecondL2Provider(cfg);
|
|
580
|
+
if (typeof second === 'string') {
|
|
581
|
+
providers.add(second);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const missing = [...providers].filter((provider) => !opts.hasCliOnPath(provider));
|
|
585
|
+
if (missing.length > 0) {
|
|
586
|
+
const named = [...new Set(missing.map(safeProvider))].sort();
|
|
587
|
+
return [
|
|
588
|
+
{
|
|
589
|
+
rule: 'engine-subprocess-no-cli',
|
|
590
|
+
severity: 'error',
|
|
591
|
+
message: `engine is "subprocess" but no CLI was found on PATH for provider(s): ${named.join(', ')}`,
|
|
592
|
+
},
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
return [];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── 13. shadow-rate-range (ERROR) ───────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
function checkShadowRateRange(cfg) {
|
|
601
|
+
const rate = cfg?.calibration?.shadow_rate;
|
|
602
|
+
// `!(rate >= 0 && rate <= 0.5)`, not `rate < 0 || rate > 0.5` — NaN fails BOTH `<`/`>`
|
|
603
|
+
// comparisons, so the old form silently let YAML's `.nan` through.
|
|
604
|
+
if (typeof rate === 'number' && !(rate >= 0 && rate <= 0.5)) {
|
|
605
|
+
return [
|
|
606
|
+
{
|
|
607
|
+
rule: 'shadow-rate-range',
|
|
608
|
+
severity: 'error',
|
|
609
|
+
message: 'calibration.shadow_rate must be a number within [0, 0.5]',
|
|
610
|
+
},
|
|
611
|
+
];
|
|
612
|
+
}
|
|
613
|
+
return [];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── 14. caps-coders-exceeds-cap (WARN, v1.3/B1.1) ───────────────────────────
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* R10 caps concurrent coders at 2 for THIS build, but a consumer's config is free to set more —
|
|
620
|
+
* the schema itself only enforces `minimum: 1` (plan §1.3: "`caps.coders > 2` ⇒ warn (R10 binds
|
|
621
|
+
* this build; a consumer may run more with the warning on record)"). Guarded on a real finite
|
|
622
|
+
* number so a non-numeric value — already a schema-layer error on its own — never also fires this
|
|
623
|
+
* rule.
|
|
624
|
+
* @param {Record<string, any>} cfg
|
|
625
|
+
*/
|
|
626
|
+
function checkCapsCodersExceedsCap(cfg) {
|
|
627
|
+
const coders = cfg?.caps?.coders;
|
|
628
|
+
if (typeof coders === 'number' && Number.isFinite(coders) && coders > 2) {
|
|
629
|
+
return [
|
|
630
|
+
{
|
|
631
|
+
rule: 'caps-coders-exceeds-cap',
|
|
632
|
+
severity: 'warning',
|
|
633
|
+
message: 'caps.coders is above the R10 cap of 2 concurrent coders — allowed, but recorded as a deviation',
|
|
634
|
+
},
|
|
635
|
+
];
|
|
636
|
+
}
|
|
637
|
+
return [];
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The 14 domain rules paired with the exact `rule` id string each one emits — the single source
|
|
642
|
+
* of truth both `DOMAIN_RULES` (below, what `validateConfig` runs) and the exported
|
|
643
|
+
* `DOMAIN_RULE_IDS` are built from. `'schema'` (the ajv layer's own issues) is deliberately not
|
|
644
|
+
* one of these 14. (B1.2/v1.3, Q16 cut: `proof-tool-absent-for-high-tier` removed, 15 -> 14.)
|
|
645
|
+
* @type {ReadonlyArray<{fn: (cfg: Record<string, any>, opts?: object) => ValidationIssue[], id: string}>}
|
|
646
|
+
*/
|
|
647
|
+
const RULE_TABLE = Object.freeze([
|
|
648
|
+
{ fn: checkLevelMissing, id: 'level-missing' },
|
|
649
|
+
{ fn: checkUnknownModelId, id: 'unknown-model-id' },
|
|
650
|
+
{ fn: checkDuplicateLevelTuple, id: 'duplicate-level-tuple' },
|
|
651
|
+
{ fn: checkStopAtNotL3, id: 'stop-at-not-l3' },
|
|
652
|
+
{ fn: checkMultimodelMissingSecondProvider, id: 'multimodel-missing-second-provider' },
|
|
653
|
+
{ fn: checkMultimodelEffectiveL2Collision, id: 'multimodel-effective-l2-collision' },
|
|
654
|
+
{ fn: checkJudgeFamilyCollision, id: 'judge-family-collision' },
|
|
655
|
+
{ fn: checkOrchestratorL3MultimodelOff, id: 'orchestrator-l3-multimodel-off' },
|
|
656
|
+
{ fn: checkGatesNotArgvArray, id: 'gates-not-argv-array' },
|
|
657
|
+
{ fn: checkSecretLookingValue, id: 'secret-looking-value' },
|
|
658
|
+
{ fn: checkFallbackUnknownOrCliAbsent, id: 'fallback-unknown-or-cli-absent' },
|
|
659
|
+
{ fn: checkEngineSubprocessNoCli, id: 'engine-subprocess-no-cli' },
|
|
660
|
+
{ fn: checkShadowRateRange, id: 'shadow-rate-range' },
|
|
661
|
+
{ fn: checkCapsCodersExceedsCap, id: 'caps-coders-exceeds-cap' },
|
|
662
|
+
]);
|
|
663
|
+
|
|
664
|
+
/** The 14 domain rules, in the order documented above. */
|
|
665
|
+
const DOMAIN_RULES = Object.freeze(RULE_TABLE.map((entry) => entry.fn));
|
|
666
|
+
|
|
667
|
+
/** The 14 rule ids `validateConfig` can emit, in `DOMAIN_RULES` order (see `RULE_TABLE`). */
|
|
668
|
+
export const DOMAIN_RULE_IDS = Object.freeze(RULE_TABLE.map((entry) => entry.id));
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* @param {Record<string, any>} cfg - a parsed (migrated) config object — need not already be schema-valid.
|
|
672
|
+
* @param {{
|
|
673
|
+
* seenInCache?: Record<string, string[]>,
|
|
674
|
+
* hasCliOnPath?: (provider: string) => boolean,
|
|
675
|
+
* }} [opts]
|
|
676
|
+
* @returns {ValidationResult}
|
|
677
|
+
*/
|
|
678
|
+
export function validateConfig(cfg, opts = {}) {
|
|
679
|
+
const schemaIssues = runSchemaValidation(cfg);
|
|
680
|
+
const domainIssues = DOMAIN_RULES.flatMap((rule) => rule(cfg, opts));
|
|
681
|
+
/** @type {Set<string>} */
|
|
682
|
+
const secretStrings = new Set();
|
|
683
|
+
collectSecretStrings(cfg, secretStrings);
|
|
684
|
+
const secretsLongestFirst = [...secretStrings].sort((a, b) => b.length - a.length);
|
|
685
|
+
const all = [...schemaIssues, ...domainIssues].map((issue) => ({
|
|
686
|
+
...issue,
|
|
687
|
+
message: finalizeMessage(issue.message, secretsLongestFirst),
|
|
688
|
+
}));
|
|
689
|
+
const errors = all.filter((issue) => issue.severity === 'error');
|
|
690
|
+
const warnings = all.filter((issue) => issue.severity === 'warning');
|
|
691
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export { schema as configSchema };
|