candor-ts 0.25.0 → 0.27.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/AGENTS.md +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/policy.mjs +91 -4
- package/query-core.mjs +29 -5
- package/query.mjs +150 -5
- package/scan-core.mjs +39 -0
- package/scan.mjs +532 -41
package/scan.mjs
CHANGED
|
@@ -28,11 +28,11 @@ import { fileURLToPath } from "node:url";
|
|
|
28
28
|
import { createRequire } from "node:module";
|
|
29
29
|
import { execFileSync } from "node:child_process";
|
|
30
30
|
import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText,
|
|
31
|
-
reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText,
|
|
31
|
+
reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, fatalPolicyErrors, refusalVerdict,
|
|
32
32
|
netClassResolver, resolveReasonClasses } from "./policy.mjs";
|
|
33
33
|
import { unverifiedHoleRule, ruleUpgrade, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
|
|
34
34
|
import { printAgents } from "./contract.mjs";
|
|
35
|
-
import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql,
|
|
35
|
+
import { isTestPath, kappa, kappaKnows, fsKind, commandHeadEffects, hostLiteral, tablesInSql,
|
|
36
36
|
modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
|
|
37
37
|
import { emitSurface } from "./surface.mjs";
|
|
38
38
|
|
|
@@ -44,7 +44,18 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
44
44
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
45
45
|
// Reused, never re-littered.
|
|
46
46
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
47
|
-
const SPEC_VERSION = "0.
|
|
47
|
+
const SPEC_VERSION = "0.27";
|
|
48
|
+
/** The `deps` / `CANDOR_DEPS` separator set — ASCII whitespace plus `:` and `,`.
|
|
49
|
+
*
|
|
50
|
+
* ONE CONSTANT BECAUSE TWO SPELLINGS WERE A SILENT GREEN. The §3.3.1 sink-over-input guard and the
|
|
51
|
+
* dep-chain loader each carried their own regex; they disagreed on `\n`, so a newline-separated
|
|
52
|
+
* `CANDOR_DEPS` was one unresolvable token to the guard and two real paths to the loader. A
|
|
53
|
+
* `--gate-json` naming one of those reports was therefore unguarded: arming overwrote it, the scan
|
|
54
|
+
* finished, and the operator's dep report ended up holding this run's `{"ok": true}` at exit 0.
|
|
55
|
+
*
|
|
56
|
+
* NOT JS `\s`, which includes U+00A0: these are PATHS, and a non-breaking space inside one is part of
|
|
57
|
+
* the path, not a separator — java, rust and swift all treat it that way. */
|
|
58
|
+
const DEP_SEPARATORS = /[ \t\n\r:,]+/;
|
|
48
59
|
|
|
49
60
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
50
61
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -119,6 +130,164 @@ See https://github.com/tombaldwin/candor`);
|
|
|
119
130
|
// value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
|
|
120
131
|
const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--workspace] [--agents] [--version] [--help]";
|
|
121
132
|
const argv = process.argv.slice(2);
|
|
133
|
+
// Declared HERE, above the sink guard, because the guard calls `loadCandorConfig` and that reads
|
|
134
|
+
// these: left below, they were in the temporal dead zone, the call threw, and the `catch` around it
|
|
135
|
+
// swallowed the throw — so the config channel the guard exists to enumerate was silently empty and a
|
|
136
|
+
// config-declared policy was destroyed at exit 0 again. A `catch` that hides a programming error is a
|
|
137
|
+
// fail-open with a reason attached.
|
|
138
|
+
const CONFIG_KEYS = new Set(["policy", "baseline", "strict", "no-ambient", "closed-world", "taint", "deps", "unknown-alias", "net-partner", "unknown-ratchet", "engine"]);
|
|
139
|
+
const CONFIG_KEYS_IMPLEMENTED = new Set(["policy", "baseline", "deps", "unknown-ratchet", "engine"]);
|
|
140
|
+
|
|
141
|
+
// ── SPEC §3.3.1 ⟨0.27⟩ ARM FIRST, AND NEVER OVER AN INPUT.
|
|
142
|
+
//
|
|
143
|
+
// This pre-pass learns the sink and this run's inputs with NO side effects, before the parse loop
|
|
144
|
+
// below, for two reasons the loop cannot serve:
|
|
145
|
+
//
|
|
146
|
+
// (1) the loop's own `unknown flag` exit(2) runs BEFORE the arming did, so `--frobnicate --gate-json G`
|
|
147
|
+
// exited leaving the PREVIOUS run's green document at G. §3.3 names an unknown flag as a
|
|
148
|
+
// broken-gate-config exit-2 cause, which MUST leave a refusal — the contract cannot depend on
|
|
149
|
+
// argv order, and it did.
|
|
150
|
+
// (2) arming WRITES, so a sink that names the policy DESTROYS it. Measured: `--policy P --gate-json P`
|
|
151
|
+
// on violating code exited 0 with `ok: true` — the armed JSON replaced P, every line of it parsed
|
|
152
|
+
// as an unknown rule, and the gate ran over zero rules. A machine-readable all-clear produced by
|
|
153
|
+
// deleting the question.
|
|
154
|
+
const preScan = (av) => {
|
|
155
|
+
let gate = null, policy = null, target = null;
|
|
156
|
+
for (let i = 0; i < av.length; i++) {
|
|
157
|
+
const a = av[i], v = av[i + 1];
|
|
158
|
+
if (a === "--gate-json" || a === "--policy" || a === "--out") {
|
|
159
|
+
if (v === undefined || (v !== "-" && v.startsWith("--"))) continue;
|
|
160
|
+
if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v;
|
|
161
|
+
i++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// The scan TARGET, needed to discover the `.candor/config` whose `policy` key may name an input
|
|
165
|
+
// this sink must not overwrite.
|
|
166
|
+
if (!a.startsWith("-") && target === null) target = a;
|
|
167
|
+
}
|
|
168
|
+
return { gate, policy, target };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Every path this run READS, whatever channel it arrived through (SPEC §3.3.1 ⟨0.27⟩).
|
|
172
|
+
//
|
|
173
|
+
// THE FIRST VERSION OF THIS GUARD KEYED ON THE FLAG. With the policy declared by `.candor/config` — the
|
|
174
|
+
// checked-in form, i.e. the one a CI job actually has — `--gate-json <that policy>` destroyed it and
|
|
175
|
+
// exited 0 with `"ok": true` in ALL FOUR ENGINES. A policy does not change what it is according to how
|
|
176
|
+
// the operator handed it over. The config is read LENIENTLY (no exit, no diagnostic): this runs before
|
|
177
|
+
// the real config load and must not pre-empt its refusal.
|
|
178
|
+
// Artifact identity, not string identity: `--policy /w/P --gate-json ./P` from /w is one file, and the
|
|
179
|
+
// engine that already had this guard compared path spellings and lost to exactly that. realpath resolves
|
|
180
|
+
// `.`, `..` and symlinks; for a sink that does not exist yet its parent is resolved instead.
|
|
181
|
+
const sameArtifact = (a, b) => {
|
|
182
|
+
if (!a || !b || a === "-" || b === "-") return false;
|
|
183
|
+
const resolve = (p) => {
|
|
184
|
+
try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
|
|
185
|
+
try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); } catch { return null; }
|
|
186
|
+
};
|
|
187
|
+
const x = resolve(a);
|
|
188
|
+
return x !== null && x === resolve(b);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const runInputs = (target, policyFlag) => {
|
|
192
|
+
const out = [];
|
|
193
|
+
if (policyFlag) out.push([policyFlag, "--policy"]);
|
|
194
|
+
for (const [v, label] of [["CANDOR_POLICY", "CANDOR_POLICY"], ["CANDOR_BASELINE", "CANDOR_BASELINE"],
|
|
195
|
+
["CANDOR_CONFIG", "CANDOR_CONFIG"]]) {
|
|
196
|
+
if (process.env[v]) out.push([process.env[v], label]);
|
|
197
|
+
}
|
|
198
|
+
// ONE DEFINITION, shared with the loader — see DEP_SEPARATORS. This comment used to claim it was
|
|
199
|
+
// "the separator set the dep loader accepts" while spelling a DIFFERENT set one screen away, and the
|
|
200
|
+
// gap between the two was a silent green: a newline-separated `CANDOR_DEPS` registered here as one
|
|
201
|
+
// unresolvable token, so the guard protected nothing, while the loader split it into real paths.
|
|
202
|
+
// `--gate-json` naming one of those reports then DESTROYED it and the run exited 0 with `ok: true`
|
|
203
|
+
// written over the operator's input — §3.3.1's own words, "a machine-readable all-clear produced by
|
|
204
|
+
// deleting the question". Measured live before this change.
|
|
205
|
+
for (const d of (process.env.CANDOR_DEPS ?? "").split(DEP_SEPARATORS).filter(Boolean)) {
|
|
206
|
+
out.push([d, "a CANDOR_DEPS report"]);
|
|
207
|
+
// A DIRECTORY DEP IS EVERY REPORT INSIDE IT. `deps` accepts a directory — `--workspace` writes
|
|
208
|
+
// `.candor/deps/` and hands that back, so it is the common spelling — and the loader then walks it
|
|
209
|
+
// and reads each `*.json`. Registering only the DIRECTORY left those files unnamed, so
|
|
210
|
+
// `--gate-json <depdir>/lib.json` was unguarded: arming destroyed the operator's dep report, the
|
|
211
|
+
// run chained the wreckage and exited 0 with `ok: true` over it. Measured in all four engines.
|
|
212
|
+
//
|
|
213
|
+
// EXPANDED HERE, not by making `sameArtifact` directory-aware. That was tried and is far too
|
|
214
|
+
// broad: the scan TARGET is an input too, and a verdict written into the tree being scanned is
|
|
215
|
+
// ordinary usage — the general rule refused it and took 33 tests with it. Only a DEP directory has
|
|
216
|
+
// its CONTENTS read, so only a dep directory expands.
|
|
217
|
+
try {
|
|
218
|
+
if (fs.statSync(d).isDirectory()) {
|
|
219
|
+
for (const f of fs.readdirSync(d)) {
|
|
220
|
+
if (f.endsWith(".json") && !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json")
|
|
221
|
+
&& !f.endsWith(".locs.json")) {
|
|
222
|
+
out.push([path.join(d, f), "a CANDOR_DEPS report"]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch { /* not a directory, or unreadable — the token itself is still registered above */ }
|
|
227
|
+
}
|
|
228
|
+
// …AND THE CONFIG'S OWN KEYS, THROUGH THE ENGINE'S OWN LOADER AND ITS OWN DISCOVERY. This used to
|
|
229
|
+
// re-derive both, and a review took it apart: the home directory was computed as parent-of-parent
|
|
230
|
+
// unconditionally where the loader only steps out of a trailing `.candor/` segment, so an out-of-tree
|
|
231
|
+
// CANDOR_CONFIG had its relative values anchored one level too high and the guard protected a path
|
|
232
|
+
// the run never reads. A second parser is a second set of holes; `loadCandorConfig` is called inside a
|
|
233
|
+
// try so it can still refuse for real a moment later.
|
|
234
|
+
const cfgFile = discoverConfigFile(target ?? ".");
|
|
235
|
+
if (cfgFile) {
|
|
236
|
+
out.push([cfgFile, "the discovered .candor/config"]);
|
|
237
|
+
try {
|
|
238
|
+
const cfg = loadCandorConfig(target ?? ".", { lenient: true });
|
|
239
|
+
for (const key of ["policy", "baseline"]) {
|
|
240
|
+
if (cfg[key]) out.push([cfg[key], `the config's \`${key}\``]);
|
|
241
|
+
}
|
|
242
|
+
for (const one of (cfg.deps ?? "").split(":").filter(Boolean)) {
|
|
243
|
+
out.push([one, "the config's `deps`"]);
|
|
244
|
+
}
|
|
245
|
+
} catch { /* lenient: the real load refuses on its own terms */ }
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
const { gate, policy, target: preTarget } = preScan(argv);
|
|
252
|
+
for (const [other, flag] of (gate ? runInputs(preTarget, policy) : [])) {
|
|
253
|
+
if (gate && sameArtifact(gate, other)) {
|
|
254
|
+
console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
|
|
255
|
+
+ `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy `
|
|
256
|
+
+ `and then gate on the wreckage. Nothing was written; give the verdict its own path.`);
|
|
257
|
+
process.exit(2);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// `.candor/config` is never a verdict sink, wherever it is. The per-input checks above can only name
|
|
261
|
+
// inputs the run was TOLD about; the config is DISCOVERED by walking up from the target, so by the
|
|
262
|
+
// time its path is known the arming has already destroyed it. A check on the SHAPE needs no
|
|
263
|
+
// discovery, so it runs before the first write and covers a config found anywhere up the tree.
|
|
264
|
+
if (gate && gate !== "-") {
|
|
265
|
+
const abs = path.resolve(gate);
|
|
266
|
+
if (path.basename(abs) === "config" && path.basename(path.dirname(abs)) === ".candor") {
|
|
267
|
+
console.error(`candor-ts: --gate-json ${gate} is a .candor/config — refusing (exit 2). The verdict `
|
|
268
|
+
+ `is armed before the config is read, so this would destroy the config that configures this `
|
|
269
|
+
+ `run. Nothing was written; give the verdict its own path.`);
|
|
270
|
+
process.exit(2);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (gate && sameArtifact(gate, process.env.CANDOR_CONFIG)) {
|
|
274
|
+
console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
|
|
275
|
+
process.exit(2);
|
|
276
|
+
}
|
|
277
|
+
if (gate && gate !== "-") armGateJsonFailClosed(gate);
|
|
278
|
+
}
|
|
279
|
+
// ⟨0.27⟩ THE STREAM SINK'S ANALOG OF ARMING — SPEC §3.1's stream-sink clause. `--gate-json -` cannot be
|
|
280
|
+
// armed (a stream has no stale previous document, and a placeholder would put TWO documents in a
|
|
281
|
+
// consumer's pipe), but the document-on-every-exit rule applies in full: an exit-2 cause that fires
|
|
282
|
+
// before the gate tail — an unknown flag, a valueless gate-adjacent flag, a missing target — must still
|
|
283
|
+
// leave the fail-closed refusal as the stream's only content. Measured: an unhonourable policy wrote the
|
|
284
|
+
// refusal to stdout while an unknown flag exited 2 leaving stdout EMPTY — the same operator mistake,
|
|
285
|
+
// answered or not according to which early exit fired, and an empty stream throws the consumer back to
|
|
286
|
+
// scraping stderr. File sinks need nothing here: the arming above already left a refusal in place.
|
|
287
|
+
const preGateSink = preScan(argv).gate;
|
|
288
|
+
const refuseEarlyToStream = (why) => {
|
|
289
|
+
if (preGateSink === "-") console.log(JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1));
|
|
290
|
+
};
|
|
122
291
|
let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, gateJsonPath = null, allowJs = false, wantAgents = false, wantJson = false, wantWorkspace = false, wantDepInits = false;
|
|
123
292
|
for (let i = 0; i < argv.length; i++) {
|
|
124
293
|
const a = argv[i];
|
|
@@ -132,7 +301,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
132
301
|
else if (a === "--dep-inits") wantDepInits = true;
|
|
133
302
|
else if (a === "--out" || a === "--policy" || a === "--gate-json") {
|
|
134
303
|
const v = argv[i + 1];
|
|
135
|
-
if (v === undefined || v.startsWith("--")) {
|
|
304
|
+
if (v === undefined || v.startsWith("--")) {
|
|
305
|
+
console.error(`candor-ts: ${a} requires a value (${usage})`);
|
|
306
|
+
refuseEarlyToStream(`${a} requires a value`);
|
|
307
|
+
process.exit(2);
|
|
308
|
+
}
|
|
136
309
|
if (a === "--out") outPrefix = v; else if (a === "--policy") policyPath = v; else gateJsonPath = v;
|
|
137
310
|
i++;
|
|
138
311
|
}
|
|
@@ -140,13 +313,27 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
140
313
|
// (SPEC §6.2/§7). `-h`/`-V`/`--help`/`--version` are print-and-exit modes consumed above, so by here
|
|
141
314
|
// a single-dash token (`-x`, the typo `-policy`) can only be a mistake; treating it as the scan
|
|
142
315
|
// target would silently scan the wrong thing.
|
|
143
|
-
else if (a.startsWith("-")) {
|
|
316
|
+
else if (a.startsWith("-")) {
|
|
317
|
+
console.error(`candor-ts: unknown flag ${a} (${usage})`);
|
|
318
|
+
// ⟨0.27⟩ §3.3 names an unknown flag as a broken-gate-config exit-2 cause; the stream sink gets the
|
|
319
|
+
// refusal document too (see refuseEarlyToStream — the file sink is already armed).
|
|
320
|
+
refuseEarlyToStream(`unknown flag ${a}`);
|
|
321
|
+
process.exit(2);
|
|
322
|
+
}
|
|
144
323
|
else if (target === null) target = a;
|
|
145
324
|
else if (outPrefix === null) outPrefix = a; // legacy positional prefix
|
|
146
|
-
else {
|
|
325
|
+
else {
|
|
326
|
+
console.error(`candor-ts: unexpected extra argument ${a} (${usage})`);
|
|
327
|
+
refuseEarlyToStream(`unexpected extra argument ${a}`);
|
|
328
|
+
process.exit(2);
|
|
329
|
+
}
|
|
147
330
|
}
|
|
148
331
|
if (wantAgents) { printAgents(); process.exit(0); }
|
|
149
|
-
if (target === null) {
|
|
332
|
+
if (target === null) {
|
|
333
|
+
console.error(usage);
|
|
334
|
+
refuseEarlyToStream("no scan target");
|
|
335
|
+
process.exit(2);
|
|
336
|
+
}
|
|
150
337
|
|
|
151
338
|
// ---- .candor/config (candor-spec §config; the checked-in alternative to the CANDOR_* env vars) -----
|
|
152
339
|
// Discovery is anchored to the SCAN TARGET (walk up from the target dir to the repo root's
|
|
@@ -156,14 +343,15 @@ if (target === null) { console.error(usage); process.exit(2); }
|
|
|
156
343
|
// never vanish silently (the §6.2 unreadable-policy posture). Only genuine absence is an empty config.
|
|
157
344
|
// Keys are the shared FAMILY vocabulary; a key OUTSIDE it warns (typo protection: a misspelt `policy`
|
|
158
345
|
// must not silently drop the gate).
|
|
159
|
-
|
|
346
|
+
// ⟨0.27⟩ `engine` (SPEC §3.4) is RECOGNIZED and IMPLEMENTED here — see enforceEnginePin. It must be in
|
|
347
|
+
// BOTH sets: missing from the vocabulary it is reported as unknown, and missing from IMPLEMENTED it is
|
|
348
|
+
// disclosed as inert — both tell an operator their pin was ignored while the engine is enforcing it.
|
|
160
349
|
// The subset this engine actually wires to a mode — `policy` (the gate), `baseline` (AS-EFF-005),
|
|
161
350
|
// `deps` (the cross-package report chain) and `unknown-ratchet` (the baseline guard's opt-in). The rest
|
|
162
351
|
// of the vocabulary is spec-inert HERE: it drives other engines' gates. But a checked-in enforcement key
|
|
163
352
|
// that silently does nothing is a DECLARED-GATE-SILENTLY-OFF — the reader believes the gate is on — so
|
|
164
353
|
// an inert recognized key DISCLOSES loudly (stderr only; verdict/report/exit code untouched) instead of
|
|
165
354
|
// staying mute. Same posture + message shape as candor-scan's CONFIG_KEYS_IMPLEMENTED.
|
|
166
|
-
const CONFIG_KEYS_IMPLEMENTED = new Set(["policy", "baseline", "deps", "unknown-ratchet"]);
|
|
167
355
|
// The ANCHOR a config file's RELATIVE path values (policy/deps) resolve against: the repo the config
|
|
168
356
|
// belongs to — the parent of its `.candor/` directory (the standard layout; candor-init scaffolds
|
|
169
357
|
// `policy arch.policy` meaning the repo root's), else the config file's own directory. NEVER the
|
|
@@ -174,28 +362,139 @@ function configAnchor(file) {
|
|
|
174
362
|
const dir = path.dirname(path.resolve(file));
|
|
175
363
|
return path.basename(dir) === ".candor" ? path.dirname(dir) : dir;
|
|
176
364
|
}
|
|
177
|
-
|
|
365
|
+
// ⟨0.27⟩ SPEC §3.4 `engine` — THE ENGINE↔BASELINE COUPLING, enforced instead of hoped for.
|
|
366
|
+
//
|
|
367
|
+
// The committed baseline is a snapshot of what ONE engine build reported, and an engine swap is
|
|
368
|
+
// baseline-invalidating. What a PIN adds over the provenance checks already in place is that it is
|
|
369
|
+
// DECLARATIVE — a build id is a hash nobody can write down, so the intended version lived in CI config,
|
|
370
|
+
// decoupled from the baseline it is married to. It also tells tooling which engine to FETCH, and it
|
|
371
|
+
// reaches a run with NO baseline configured at all.
|
|
372
|
+
//
|
|
373
|
+
// TWO OF THE FIVE VERDICTS MUST NOT CHANGE THE EXIT CODE: an ABSENT pin (the key is opt-in by
|
|
374
|
+
// construction) and an UNDETERMINED one, where §3.1's unanswerable-condition rule applies — disclosed,
|
|
375
|
+
// never scored, INCLUDING as satisfied. Exit 2 on a mismatch, never 1: unevaluable, not violating.
|
|
376
|
+
//
|
|
377
|
+
// A pin qualified for another implementation is not ours to check — one config serves the whole family,
|
|
378
|
+
// and the family versions as a LADDER, so a bare version in a polyglot repo would fail whichever engine
|
|
379
|
+
// had not yet caught up.
|
|
380
|
+
const ENGINE_IMPLS = new Set(["java", "rust", "ts", "swift", "agents"]);
|
|
381
|
+
function enginePinFor(text, implName) {
|
|
382
|
+
let wild = null, qual = null, bad = false;
|
|
383
|
+
for (const rawLine of (text ?? "").split("\n")) {
|
|
384
|
+
const line = rawLine.split("#")[0].trim();
|
|
385
|
+
if (!line) continue;
|
|
386
|
+
const parts = line.split(/\s+/);
|
|
387
|
+
if (parts[0].toLowerCase() !== "engine") continue;
|
|
388
|
+
const rest = parts.slice(1);
|
|
389
|
+
// Two lines that DISAGREE about the same key are kept BOTH, so they cannot parse as a version and
|
|
390
|
+
// surface as malformed. One silently discarding the other is the failure this key exists to stop.
|
|
391
|
+
const slot = (cur, v) => (cur !== null && cur !== v ? `${cur} / ${v}` : v);
|
|
392
|
+
// A KNOWN QUALIFIER DECIDES OWNERSHIP BEFORE ARITY. Checking the one-token case first made `engine swift` a WILDCARD pin whose version is the literal "swift" -> MALFORMED -> exit 2 in every engine, so one operator forgetting a version on a qualified line killed the whole family. SPEC 3.4 says the skip is whole-line 'whatever follows it' -- and nothing following it is a case of that too.
|
|
393
|
+
if (rest.length && ENGINE_IMPLS.has(rest[0].toLowerCase())) {
|
|
394
|
+
if (rest[0].toLowerCase() === implName) { if (rest.length === 2) qual = slot(qual, rest[1]); else bad = true; }
|
|
395
|
+
continue; // another impl's line, whatever follows it
|
|
396
|
+
}
|
|
397
|
+
if (rest.length === 0) bad = true;
|
|
398
|
+
else if (rest.length === 1) wild = slot(wild, rest[0]);
|
|
399
|
+
else bad = true;
|
|
400
|
+
}
|
|
401
|
+
if (bad) return "<unreadable>";
|
|
402
|
+
// AN UNREADABLE UNQUALIFIED LINE IS NOT HIDDEN BY A QUALIFIED PIN. `qual ?? wild` returned the qualifi
|
|
403
|
+
// ed value, so `engine garbage` beside a good qualified line passed SILENTLY here while candor-java exited
|
|
404
|
+
// 2 — the exact mirror of the bug just fixed in java, four engines the other way. Unreadability is a property of the LINE; precedence only decides which VERSION applies.
|
|
405
|
+
if (wild !== null && normalizePinVersion(wild) === null) return wild;
|
|
406
|
+
return qual ?? wild;
|
|
407
|
+
}
|
|
408
|
+
function normalizePinVersion(raw) {
|
|
409
|
+
const s = String(raw ?? "").trim().replace(/^[vV]/, "");
|
|
410
|
+
if (!/^\d+\.\d+(\.\d+)?$/.test(s)) return null;
|
|
411
|
+
return s.split(".").length === 2 ? `${s}.0` : s;
|
|
412
|
+
}
|
|
413
|
+
function enforceEnginePin(targetPath) {
|
|
414
|
+
const pin = enginePinFor(discoverConfigText(targetPath), "ts");
|
|
415
|
+
if (pin === null || pin === undefined) return; // ABSENT
|
|
416
|
+
const want = normalizePinVersion(pin);
|
|
417
|
+
if (want === null) {
|
|
418
|
+
console.error(`candor-ts: .candor/config has an \`engine\` line that is not an engine version.`);
|
|
419
|
+
console.error(` want \`engine <version>\` (e.g. \`engine v${PKG_VERSION}\`) or \`engine <impl> <version>\``);
|
|
420
|
+
console.error(` (e.g. \`engine ts v${PKG_VERSION}\`) for a repo scanned by more than one engine.`);
|
|
421
|
+
console.error(` Failing (exit 2) rather than ignoring it: a pin that cannot be read is a`);
|
|
422
|
+
console.error(` guard the operator believes is on.`);
|
|
423
|
+
process.exit(2);
|
|
424
|
+
}
|
|
425
|
+
const running = normalizePinVersion(PKG_VERSION) ?? String(PKG_VERSION ?? "").trim();
|
|
426
|
+
if (!running || running === "unknown") { // UNDETERMINED — disclose, never score
|
|
427
|
+
console.error(`candor-ts: .candor/config pins engine ${pin}, and this build does not know its own release,`);
|
|
428
|
+
console.error(` so the pin CANNOT be checked. Disclosed, not scored — neither passed nor failed.`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (want === running) return; // MATCH
|
|
432
|
+
console.error(`candor-ts: .candor/config pins engine ${pin} but this build is candor-ts ${PKG_VERSION}.`);
|
|
433
|
+
console.error(` The pin and the committed baseline move together — a newer engine resolves more`);
|
|
434
|
+
console.error(` dispatch, so its report is not comparable with a baseline the pinned engine wrote.`);
|
|
435
|
+
console.error(` Either run the pinned engine, or update the pin and regenerate the baseline in the`);
|
|
436
|
+
console.error(` same change. Exit 2 (unevaluable), not 1 — this is not a policy violation.`);
|
|
437
|
+
// ONE call, not two: an insertion script matched both pin branches to this single exit, and the
|
|
438
|
+
// duplicate put TWO documents on the stream — which parses as neither. The other branch (a build that
|
|
439
|
+
// cannot determine its own release) RETURNS rather than exiting: disclosed, not scored, so no refusal
|
|
440
|
+
// belongs there.
|
|
441
|
+
refuseEarlyToStream(`.candor/config pins engine ${pin}, which this build does not satisfy`);
|
|
442
|
+
process.exit(2);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// WHICH config file this run reads, with NO side effects (SPEC §3.4). Extracted so the §3.3.1 sink
|
|
446
|
+
// guard asks the same question the loader answers instead of re-deriving the walk — a review took the
|
|
447
|
+
// guard's own copy apart on exactly that divergence.
|
|
448
|
+
function discoverConfigFile(targetPath) {
|
|
449
|
+
const env = process.env.CANDOR_CONFIG;
|
|
450
|
+
if (env) {
|
|
451
|
+
try { return fs.statSync(env).isFile() ? env : null; } catch { return null; }
|
|
452
|
+
}
|
|
453
|
+
let dir = path.resolve(targetPath ?? ".");
|
|
454
|
+
try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
|
|
455
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
456
|
+
const cand = path.join(d, ".candor", "config");
|
|
457
|
+
if (fs.existsSync(cand)) return cand;
|
|
458
|
+
if (path.dirname(d) === d) break; // filesystem root
|
|
459
|
+
}
|
|
460
|
+
return fs.existsSync(".candor/config") ? ".candor/config" : null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/// `lenient: true` THROWS where this would otherwise `process.exit(2)`.
|
|
464
|
+
///
|
|
465
|
+
/// The collision pre-pass needs the config's DECLARED input paths before the sink is armed, and it
|
|
466
|
+
/// wrapped this call in a try under the comment "the real load refuses on its own terms" — which
|
|
467
|
+
/// assumes the failure arrives as an exception. It does not: `process.exit` is not catchable, so an
|
|
468
|
+
/// unreadable config killed the run INSIDE that try, before arming, leaving a pre-seeded green verdict
|
|
469
|
+
/// intact at the file sink. SPEC §3.3's own words for that: "a refusal that writes nothing leaves the
|
|
470
|
+
/// previous run's green document on disk." java answers the same input with `refused: true`.
|
|
471
|
+
///
|
|
472
|
+
/// Nothing is lost by leniency here. If the config cannot be read it declares no inputs anyone can
|
|
473
|
+
/// name, so the collision check over them is vacuous; arming then proceeds and the REAL load refuses a
|
|
474
|
+
/// moment later — now with the sink armed, so the refusal reaches it. A second parser was the
|
|
475
|
+
/// alternative, and a second parser is a second set of holes.
|
|
476
|
+
function loadCandorConfig(targetPath, { lenient = false } = {}) {
|
|
178
477
|
let file = process.env.CANDOR_CONFIG ?? null;
|
|
179
478
|
if (file !== null) {
|
|
180
479
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
480
|
+
if (lenient) throw new Error(`CANDOR_CONFIG set but ${file} is not a readable file`);
|
|
181
481
|
console.error(`candor-ts: CANDOR_CONFIG set but ${file} is not a readable file — failing (exit 2)`);
|
|
482
|
+
// The config is the EARLIEST exit-2 cause, and the one the stream sink is least likely to be
|
|
483
|
+
// armed for — which is exactly why it was the last one still leaving stdout empty. Found by
|
|
484
|
+
// PART 36 (b11), a row written before this line was.
|
|
485
|
+
refuseEarlyToStream(`CANDOR_CONFIG set but ${file} is not a readable file`);
|
|
182
486
|
process.exit(2);
|
|
183
487
|
}
|
|
184
488
|
} else {
|
|
185
|
-
|
|
186
|
-
try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
|
|
187
|
-
for (let d = dir; ; d = path.dirname(d)) {
|
|
188
|
-
const cand = path.join(d, ".candor", "config");
|
|
189
|
-
if (fs.existsSync(cand)) { file = cand; break; }
|
|
190
|
-
if (path.dirname(d) === d) break; // filesystem root
|
|
191
|
-
}
|
|
192
|
-
if (file === null && fs.existsSync(".candor/config")) file = ".candor/config";
|
|
489
|
+
file = discoverConfigFile(targetPath);
|
|
193
490
|
if (file === null) return {};
|
|
194
491
|
}
|
|
195
492
|
let text;
|
|
196
493
|
try { text = fs.readFileSync(file, "utf8"); }
|
|
197
494
|
catch (e) {
|
|
495
|
+
if (lenient) throw new Error(`config ${file} exists but could not be read (${e.message})`);
|
|
198
496
|
console.error(`candor-ts: config ${file} exists but could not be read (${e.message}) — failing (exit 2)`);
|
|
497
|
+
refuseEarlyToStream(`config ${file} exists but could not be read`);
|
|
199
498
|
process.exit(2);
|
|
200
499
|
}
|
|
201
500
|
const cfg = {};
|
|
@@ -230,10 +529,50 @@ function loadCandorConfig(targetPath) {
|
|
|
230
529
|
const anchor = configAnchor(file);
|
|
231
530
|
if (cfg.policy) cfg.policy = path.resolve(anchor, cfg.policy);
|
|
232
531
|
if (cfg.baseline) cfg.baseline = path.resolve(anchor, cfg.baseline);
|
|
233
|
-
|
|
532
|
+
// ASCII whitespace ONLY, like java and swift: these are PATHS, and JS `\s` includes U+00A0, so a dep
|
|
533
|
+
// path containing a non-breaking space split into two halves that were then both "skipped" — a green
|
|
534
|
+
// run with the dep silently unchained, where java and rust loaded it.
|
|
535
|
+
if (cfg.deps) cfg.deps = cfg.deps.split(/[ \t:,]+/).filter(Boolean).map((t) => path.resolve(anchor, t)).join(":");
|
|
234
536
|
return cfg;
|
|
235
537
|
}
|
|
538
|
+
// ⟨0.24⟩/⟨0.27⟩ ARM THE VERDICT FAIL-CLOSED. Every exit path then leaves a refusal behind unless the run
|
|
539
|
+
// got far enough to replace it with a real verdict. A review found the pin refusal leaving the PREVIOUS
|
|
540
|
+
// run's document on disk — a CI wrapper reading the artifact instead of the exit code then reports a pass
|
|
541
|
+
// over a run that refused. candor-java's `armGateJson` is the model; the wording is about the RUN, not
|
|
542
|
+
// about the code.
|
|
543
|
+
//
|
|
544
|
+
// A `function` declaration, not a `const`: it is CALLED from the pre-pass above the arg loop, and only a
|
|
545
|
+
// hoisted declaration can be. The write is inlined rather than calling `writeAtomic` for the same reason
|
|
546
|
+
// in reverse — that helper is a `const` declared ~4700 lines below, so calling it would be a
|
|
547
|
+
// temporal-dead-zone throw.
|
|
548
|
+
function armGateJsonFailClosed(p) {
|
|
549
|
+
try {
|
|
550
|
+
const _tmp = `${p}.${process.pid}.arm`;
|
|
551
|
+
fs.writeFileSync(_tmp, JSON.stringify({
|
|
552
|
+
spec: SPEC_VERSION, ok: false, refused: true,
|
|
553
|
+
reason: "the gate did not complete — this document was written when the run STARTED and was never "
|
|
554
|
+
+ "replaced by a verdict, so the run failed, crashed or was killed before it could decide. It is "
|
|
555
|
+
+ "NOT a verdict about the code; see the run's stderr for the cause.",
|
|
556
|
+
}, null, 1) + "\n");
|
|
557
|
+
fs.renameSync(_tmp, p);
|
|
558
|
+
} catch (e) {
|
|
559
|
+
console.error(`candor-ts: could not arm --gate-json ${p} fail-closed (${e.message}) — if this run `
|
|
560
|
+
+ `does not complete, that path may still hold a PREVIOUS run's verdict`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// MOVED ABOVE THE CONFIG LOAD. `loadCandorConfig` is ITSELF an exit-2 cause (an unusable
|
|
564
|
+
// CANDOR_CONFIG, or a committed `.candor/config` that cannot be read), and arming after it left a
|
|
565
|
+
// config refusal exiting 2 with the PREVIOUS run's green still on disk — while the comment below
|
|
566
|
+
// said "BEFORE ANYTHING THAT CAN EXIT". The rule only holds if the arming really is first.
|
|
567
|
+
// ⟨0.24⟩ ARM THE VERDICT FAIL-CLOSED BEFORE ANYTHING THAT CAN EXIT. A review found the pin refusal
|
|
568
|
+
// leaving the PREVIOUS run's `--gate-json` document on disk — a CI wrapper reading the artifact instead
|
|
569
|
+
// of the exit code then reports a pass over a run that refused. Arming at the START makes this a CLASS
|
|
570
|
+
// fix: every exit path leaves a refusal unless the run got far enough to replace it. candor-java's
|
|
571
|
+
// `armGateJson` is the model, and the wording is about the RUN, not about the code.
|
|
572
|
+
// (armed by the pre-pass above, before the arg loop — see SPEC §3.3.1 ⟨0.27⟩. Arming HERE was still
|
|
573
|
+
// after the loop's unknown-flag exit, so the contract depended on argv order.)
|
|
236
574
|
const candorConfig = loadCandorConfig(target);
|
|
575
|
+
enforceEnginePin(target); // ⟨0.27⟩ §3.4 — AFTER the arming, so its exit 2 cannot leave a stale verdict
|
|
237
576
|
// precedence: the --policy flag / CANDOR_POLICY env already populated policyPath; the config is the floor.
|
|
238
577
|
// A BARE `policy` line ("" value) means configured-with-empty → the unreadable-policy path fails loud.
|
|
239
578
|
if (policyPath === null && candorConfig.policy !== undefined) policyPath = candorConfig.policy;
|
|
@@ -241,7 +580,12 @@ if (policyPath === null && candorConfig.policy !== undefined) policyPath = cando
|
|
|
241
580
|
// (path-valued keys are already resolved against the config's anchor above). No CLI flag — matching
|
|
242
581
|
// candor-java, the reference engine (env/config only). A BARE `baseline` line ("") fails loud below.
|
|
243
582
|
let baselinePath = process.env.CANDOR_BASELINE ?? null;
|
|
244
|
-
|
|
583
|
+
// WHICH SOURCE supplied it decides what a MISSING file means: `CANDOR_BASELINE` is set unconditionally
|
|
584
|
+
// by the adopt workflow, so an absent path there is "the ratchet is not adopted yet"; a checked-in
|
|
585
|
+
// `baseline` line DECLARES this repo has one, so an absent path there was deleted or never committed —
|
|
586
|
+
// and the guard passing green over it is a gate that silently stopped gating.
|
|
587
|
+
let baselineFromConfig = false;
|
|
588
|
+
if (baselinePath === null && candorConfig.baseline !== undefined) { baselinePath = candorConfig.baseline; baselineFromConfig = true; }
|
|
245
589
|
// ⟨unknown-ratchet⟩ OPT-IN (config `unknown-ratchet` / CANDOR_UNKNOWN_RATCHET, default OFF): flip an
|
|
246
590
|
// Unknown-ONLY gain vs the baseline from advisory to an AS-EFF-005 failure (exit 1). Env-override truthy
|
|
247
591
|
// semantics mirror candor-java's Config.flag exactly — env var PRESENCE means on (env can't express off);
|
|
@@ -290,7 +634,15 @@ function fromTsconfig(cfgPath, baseDir) {
|
|
|
290
634
|
return names.filter((f) => !isTestPath(path.relative(baseDir, f)));
|
|
291
635
|
}
|
|
292
636
|
const stat = fs.existsSync(target) ? fs.statSync(target) : null;
|
|
293
|
-
if (!stat) {
|
|
637
|
+
if (!stat) {
|
|
638
|
+
console.error(`candor-ts: no such path: ${target}`);
|
|
639
|
+
// The SAME two lines as every other early exit in this file. `refuseEarlyToStream` WRITES AND
|
|
640
|
+
// RETURNS — it is not a `Never`, and calling it INSTEAD of the exit lets the run continue past its
|
|
641
|
+
// own refusal (measured, while getting this wrong: an unreadable dep wrote its refusal and then
|
|
642
|
+
// exited 0). rust's equivalent is typed `-> !`, which is why the same slip could not happen there.
|
|
643
|
+
refuseEarlyToStream(`no such path: ${target}`);
|
|
644
|
+
process.exit(2);
|
|
645
|
+
}
|
|
294
646
|
if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
295
647
|
rootDir = path.dirname(path.resolve(target));
|
|
296
648
|
fileNames = fromTsconfig(path.resolve(target), rootDir);
|
|
@@ -315,7 +667,13 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
|
315
667
|
})(rootDir);
|
|
316
668
|
}
|
|
317
669
|
}
|
|
318
|
-
if (fileNames.length === 0) {
|
|
670
|
+
if (fileNames.length === 0) {
|
|
671
|
+
console.error(`candor-ts: no TypeScript sources under ${target}`);
|
|
672
|
+
// An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
|
|
673
|
+
// get nothing. §3.1 exempts no cause, and this one is easy to hit in CI (a path that moved).
|
|
674
|
+
refuseEarlyToStream(`no TypeScript sources under ${target}`);
|
|
675
|
+
process.exit(2);
|
|
676
|
+
}
|
|
319
677
|
// Builtin typings FALLBACK: the engine ships @types/node as its own dependency, so a target that
|
|
320
678
|
// hasn't installed it still resolves node:fs/node:net/… (found by the first npx-distribution
|
|
321
679
|
// probe: a bare fixture read Unknown for fs.readFileSync because nothing supplied the builtin
|
|
@@ -798,16 +1156,69 @@ const corruptDepPkgs = new Set();
|
|
|
798
1156
|
// --workspace's auto-scanned deps dir is prepended to the explicit CANDOR_DEPS/config spec (both chain).
|
|
799
1157
|
const spec = [workspaceDepsDir, depInitsDir, process.env.CANDOR_DEPS ?? candorConfig.deps ?? ""].filter(Boolean).join(":");
|
|
800
1158
|
const files = [];
|
|
801
|
-
|
|
1159
|
+
// ASCII WHITESPACE ONLY, the same rule as the config loader above and as java, rust and swift. JS
|
|
1160
|
+
// `\s` includes U+00A0, so a dep path holding a non-breaking space split into two halves — and since
|
|
1161
|
+
// ⟨0.27⟩ made an unresolvable dep token FATAL, that turned a path the other three engines load into a
|
|
1162
|
+
// hard exit 2 naming a truncated path the operator never wrote. The config loader was fixed and this
|
|
1163
|
+
// one, which every config-declared dep is also routed through, was not: one rule, two spellings.
|
|
1164
|
+
for (const tok of spec.split(DEP_SEPARATORS).filter(Boolean)) {
|
|
802
1165
|
try {
|
|
803
1166
|
if (fs.statSync(tok).isDirectory())
|
|
804
1167
|
for (const f of fs.readdirSync(tok)) if (f.endsWith(".json") && !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.endsWith(".locs.json")) files.push(path.join(tok, f));
|
|
805
1168
|
if (fs.statSync(tok).isFile()) files.push(tok);
|
|
806
|
-
} catch {
|
|
1169
|
+
} catch {
|
|
1170
|
+
// ⟨0.27⟩ SPEC §2: A CONFIGURED DEP THAT CANNOT BE READ IS UNEVALUABLE, NOT REDUCED COVERAGE.
|
|
1171
|
+
// Skipping it continued the run, and the caller of that dep then serialised `inferred: []` — a
|
|
1172
|
+
// ⟨0.21⟩ purity claim, published in the REPORT, about a function whose dependency the operator
|
|
1173
|
+
// configured precisely so it would not be one. This engine's note said only "skipped", so the
|
|
1174
|
+
// omission was not even qualified in the channel a human reads, let alone the artifact a chained
|
|
1175
|
+
// consumer reads. java and swift already refused; this engine and rust continued.
|
|
1176
|
+
console.error(`candor-ts: CANDOR_DEPS names ${tok} but it is not a readable file or directory — `
|
|
1177
|
+
+ `failing (exit 2, unevaluable). A configured dep that is not there is not reduced coverage: `
|
|
1178
|
+
+ `its callers would serialise \`inferred: []\`, which is a purity claim about code this scan `
|
|
1179
|
+
+ `never saw. Scan that dependency, or remove it from the \`deps\` config / CANDOR_DEPS.`);
|
|
1180
|
+
// The TOKEN arm needed this too. The read and parse arms below were routed to the stream and this
|
|
1181
|
+
// one was not — three exits for one rule, two of them answered on the machine channel and one
|
|
1182
|
+
// silent, which a conformance row (PART 36 b8) caught immediately once the cause was posed at all.
|
|
1183
|
+
refuseEarlyToStream(`configured dependency ${tok} is not a readable file or directory`);
|
|
1184
|
+
process.exit(2);
|
|
1185
|
+
}
|
|
807
1186
|
}
|
|
808
1187
|
for (const f of files) {
|
|
1188
|
+
// ⟨0.27⟩ READ AND PARSE OUTSIDE THE TRY, because SPEC §2 binds them and the try was swallowing them.
|
|
1189
|
+
// The rule is one sentence — a configured dep path that "does not exist OR CANNOT BE READ MUST exit
|
|
1190
|
+
// 2, naming it" — and the 0.27 work implemented only the first half, at the token check above. A path
|
|
1191
|
+
// that resolved to a file which then failed to open, or held malformed JSON, was SKIPPED at exit 0,
|
|
1192
|
+
// and the caller of that dep serialised `inferred: []`: the ⟨0.21⟩ purity claim the token check
|
|
1193
|
+
// exists to prevent, reached by a different door.
|
|
1194
|
+
//
|
|
1195
|
+
// Found by the 0.27 go/no-go panel, which tested this engine's own changelog claim instead of
|
|
1196
|
+
// believing it. java and swift refused on both halves already; this and rust made the family 2-v-2
|
|
1197
|
+
// on a MUST. The surviving `catch` below still guards the PROCESSING of a well-formed document,
|
|
1198
|
+
// which is a different failure and stays a skip.
|
|
1199
|
+
let raw;
|
|
1200
|
+
try {
|
|
1201
|
+
raw = fs.readFileSync(f, "utf8");
|
|
1202
|
+
} catch {
|
|
1203
|
+
console.error(`candor-ts: CANDOR_DEPS report ${f} could not be read —`);
|
|
1204
|
+
console.error(` failing (exit 2, unevaluable). A configured dep this scan cannot read is not`);
|
|
1205
|
+
console.error(` reduced coverage: its callers would serialise \`inferred: []\`, a purity claim`);
|
|
1206
|
+
console.error(` about code this scan never saw.`);
|
|
1207
|
+
refuseEarlyToStream(`configured dependency report ${f} could not be read`);
|
|
1208
|
+
process.exit(2);
|
|
1209
|
+
}
|
|
1210
|
+
let parsed;
|
|
809
1211
|
try {
|
|
810
|
-
|
|
1212
|
+
parsed = JSON.parse(raw);
|
|
1213
|
+
} catch {
|
|
1214
|
+
console.error(`candor-ts: CANDOR_DEPS report ${f} is not valid JSON —`);
|
|
1215
|
+
console.error(` failing (exit 2, unevaluable). Same reason as an unreadable one: a report`);
|
|
1216
|
+
console.error(` that cannot be parsed makes no claim, and continuing would publish one.`);
|
|
1217
|
+
refuseEarlyToStream(`configured dependency report ${f} is not valid JSON`);
|
|
1218
|
+
process.exit(2);
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
const d = parsed;
|
|
811
1222
|
// A report whose version can't be VERIFIED is not trusted (§2.1) — a missing header is as
|
|
812
1223
|
// untrustworthy as a mismatched one (the Rust engine's rule; the engines split on this).
|
|
813
1224
|
const stale = d.candor?.version !== ENGINE_VERSION;
|
|
@@ -928,7 +1339,7 @@ const corruptDepPkgs = new Set();
|
|
|
928
1339
|
if (!stale && strs(e.netClass).includes("unknown-host")) cell.netIncomplete = true;
|
|
929
1340
|
crossDeps.set(e.hash, cell);
|
|
930
1341
|
}
|
|
931
|
-
} catch { console.error(`candor-ts: CANDOR_DEPS report
|
|
1342
|
+
} catch { console.error(`candor-ts: CANDOR_DEPS report could not be processed, skipped: ${f}`); }
|
|
932
1343
|
}
|
|
933
1344
|
// A package chained TWICE — once fresh, once stale — is covered by the fresh report, so it is not a
|
|
934
1345
|
// stale-only package and must not pick up the disclosure below on top of a real answer.
|
|
@@ -1729,7 +2140,7 @@ for (const sf of sources) {
|
|
|
1729
2140
|
const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
|
|
1730
2141
|
if (!fns.has(ctorQual)) {
|
|
1731
2142
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
1732
|
-
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
|
|
2143
|
+
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
|
|
1733
2144
|
hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
|
|
1734
2145
|
blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
|
|
1735
2146
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
@@ -1753,7 +2164,7 @@ for (const sf of sources) {
|
|
|
1753
2164
|
// producer's namespace nesting, so widening the hash would break report chaining.
|
|
1754
2165
|
const nsp = namespacePrefixOf(node);
|
|
1755
2166
|
const qual = isFunctionScoped(node) ? `${mod}.${nsp}${n}#${line + 1}:${character + 1}` : `${mod}.${nsp}${n}`;
|
|
1756
|
-
fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2167
|
+
fns.set(qual, { local: n, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
1757
2168
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
1758
2169
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
1759
2170
|
endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
|
|
@@ -2054,7 +2465,7 @@ function moduleUnit(sf) {
|
|
|
2054
2465
|
const qual = `${mod}.<module>`;
|
|
2055
2466
|
let rec = fns.get(qual);
|
|
2056
2467
|
if (!rec) {
|
|
2057
|
-
rec = { local: qual, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2468
|
+
rec = { local: qual, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2058
2469
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
2059
2470
|
entry: false, unitKind: "initializer",
|
|
2060
2471
|
loc: `${path.relative(rootDir, sf.fileName)}:1:1`,
|
|
@@ -2077,7 +2488,7 @@ function staticBlockUnit(node) {
|
|
|
2077
2488
|
const qual = `${mod}.${cname}.<static-init>`;
|
|
2078
2489
|
let rec = fns.get(qual);
|
|
2079
2490
|
if (!rec) {
|
|
2080
|
-
rec = { local: "<static-init>", direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2491
|
+
rec = { local: "<static-init>", direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2081
2492
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
2082
2493
|
entry: false, unitKind: "initializer",
|
|
2083
2494
|
loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1`,
|
|
@@ -3403,6 +3814,17 @@ function visitCalls(node) {
|
|
|
3403
3814
|
eff = null;
|
|
3404
3815
|
if (eff) {
|
|
3405
3816
|
rec.direct.add(eff);
|
|
3817
|
+
// SPEC §2 `fs` — refine an Fs we just PROVED with the direction its verb implies. DIRECT only
|
|
3818
|
+
// (never propagated over edges): a caller reaching one writer and one undetermined callee would
|
|
3819
|
+
// otherwise inherit ["write"] and thereby claim "writes but never reads" — the partial claim §2
|
|
3820
|
+
// forbids. An unrecognised verb adds nothing, so the field stays absent rather than half-true.
|
|
3821
|
+
if (eff === "Fs") {
|
|
3822
|
+
// A verb revealing no direction records the POISON marker "?" rather than nothing. Abstaining
|
|
3823
|
+
// would let a caller inherit a neighbour's ["write"] and claim "writes but never reads" over a
|
|
3824
|
+
// reach whose kind was never determined — the partial claim §2 forbids. Suppressed at emit.
|
|
3825
|
+
const ks = fsKind(mod, member);
|
|
3826
|
+
if (ks.length === 0) rec.fsKinds.add("?"); else for (const k of ks) rec.fsKinds.add(k);
|
|
3827
|
+
}
|
|
3406
3828
|
// a κ rule that resolves to the Unknown trust-marker (node:vm code execution) is a direct
|
|
3407
3829
|
// Unknown SOURCE — SPEC §4 requires a why on it, like eval's `reflect:eval`. (The rest of
|
|
3408
3830
|
// the κ table is concrete effects, which carry no why.)
|
|
@@ -4339,7 +4761,10 @@ const inferred = new Map([...fns.keys()].map((k) => [k, new Set(fns.get(k).direc
|
|
|
4339
4761
|
if (!queued.has(c)) { queued.add(c); queue.push(c); }
|
|
4340
4762
|
}
|
|
4341
4763
|
}
|
|
4342
|
-
|
|
4764
|
+
// `fsKinds` joins the propagated surfaces: kinds TRAVEL the call graph (a caller that transitively only
|
|
4765
|
+
// writes IS a writer), and the "?" poison travels with them so a caller of an undetermined-kind function
|
|
4766
|
+
// inherits the SUPPRESSION rather than a half-answer. Pinned by conformance PART 31.
|
|
4767
|
+
for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsKinds"]) {
|
|
4343
4768
|
const queue = [...fns.keys()];
|
|
4344
4769
|
const queued = new Set(queue);
|
|
4345
4770
|
for (let head = 0; head < queue.length; head++) {
|
|
@@ -4371,9 +4796,13 @@ for (const [name, rec] of fns) {
|
|
|
4371
4796
|
hash: `${pkgName}#${rec.local}`, // SPEC §2: the cross-package join key (package + local tail)
|
|
4372
4797
|
inferred: inf,
|
|
4373
4798
|
direct: [...rec.direct].sort(),
|
|
4374
|
-
declared
|
|
4375
|
-
|
|
4376
|
-
|
|
4799
|
+
// ⟨0.26⟩ `declared`/`undeclared`/`overdeclared` are DELIBERATELY ABSENT. They are the §5
|
|
4800
|
+
// capability-reconciliation outputs and this engine runs no such pass, so emitting `[]` would be a
|
|
4801
|
+
// positive claim — `undeclared: []` reads as "this function performs no undeclared effect", an
|
|
4802
|
+
// AS-EFF-001 all-clear from a check that never ran. SPEC §2 ⟨0.26⟩: present means the pass ran,
|
|
4803
|
+
// absent means it did not, and `[]` from an engine that computed nothing is forbidden.
|
|
4804
|
+
// They were emitted as hardcoded constants "for cross-engine schema parity" — a schema-parity check
|
|
4805
|
+
// is exactly what made that look conforming, which is why the rule now forbids requiring them.
|
|
4377
4806
|
unresolved: inf.includes("Unknown"),
|
|
4378
4807
|
};
|
|
4379
4808
|
// Inline call edges (§2 `calls`) — the SAME edges the callgraph sidecar carries, embedded per entry so a
|
|
@@ -4389,6 +4818,14 @@ for (const [name, rec] of fns) {
|
|
|
4389
4818
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
4390
4819
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
4391
4820
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
4821
|
+
// SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
|
|
4822
|
+
// (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
|
|
4823
|
+
//
|
|
4824
|
+
// Kinds TRAVEL (see the propagation loop); the "?" poison is what stops a PARTIAL answer travelling with
|
|
4825
|
+
// them. Present ⇒ some contributing Fs had no determined kind ⇒ suppress the whole field, because
|
|
4826
|
+
// ["write"] there would claim "writes but never reads" about a function that may do both.
|
|
4827
|
+
if (inf.includes("Fs") && rec.fsKinds.size && !rec.fsKinds.has("?"))
|
|
4828
|
+
entry.fs = [...rec.fsKinds].sort();
|
|
4392
4829
|
// ⟨0.6⟩ unknownWhy — REQUIRED on a DIRECT Unknown SOURCE (this fn's own body has the unresolvable call,
|
|
4393
4830
|
// so `rec.direct` carries Unknown), absent on a purely-transitive Unknown. The rich per-site reasons
|
|
4394
4831
|
// (rec.why: callback:/dispatch:/dynamic-key:) when recorded, else a generic fallback so a source is
|
|
@@ -4830,8 +5267,13 @@ function fnv1aHex(sortedQuals) {
|
|
|
4830
5267
|
|
|
4831
5268
|
// `package` names what this report COVERS — a consumer chaining it registers coverage even when
|
|
4832
5269
|
// `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
|
|
5270
|
+
// ⟨0.27⟩ SPEC §2.1 `resolves`: the OPTIONAL refinement surfaces this producer computes. Without it the
|
|
5271
|
+
// absence of such a field is overloaded between "does not compute this" and "computed and could not
|
|
5272
|
+
// determine it", and a consumer cannot read the omission at all. candor-ts resolves `fs` read/write kinds,
|
|
5273
|
+
// so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
|
|
5274
|
+
// false "undetermined", which is the inversion the field exists to prevent.
|
|
4833
5275
|
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
4834
|
-
package: pkgName, functions };
|
|
5276
|
+
resolves: ["fs"], package: pkgName, functions };
|
|
4835
5277
|
// ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
|
|
4836
5278
|
// name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
|
|
4837
5279
|
// the --gate-json advisory, so the three can never tell different stories.
|
|
@@ -4909,7 +5351,13 @@ for (const sf of sources) {
|
|
|
4909
5351
|
supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${namespacePrefixOf(d)}${d.name.getText()}` : t.expression.getText());
|
|
4910
5352
|
}
|
|
4911
5353
|
}
|
|
4912
|
-
|
|
5354
|
+
// ⟨0.26⟩ A KEY FOR EVERY TYPE INDEXED, `[]` INCLUDED — the key set IS the manifest (SPEC §2.2).
|
|
5355
|
+
// This was `if (supers.length)`, so a type with no supertypes was OMITTED and absence meant BOTH
|
|
5356
|
+
// "no supertypes" and "never indexed". Measured: removing one entry from a real sidecar silently
|
|
5357
|
+
// dropped a `callers --include-unknown` frontier row, while removing the sidecar ENTIRELY left it
|
|
5358
|
+
// correct — so LESS information was SAFER than partial information. §2.2's own example has always
|
|
5359
|
+
// shown `"app.Base": []`; this producer contradicted it.
|
|
5360
|
+
hierarchy[`${mod}.${namespacePrefixOf(node)}${node.name.getText()}`] = supers;
|
|
4913
5361
|
}
|
|
4914
5362
|
ts.forEachChild(node, walk);
|
|
4915
5363
|
})(sf);
|
|
@@ -5013,6 +5461,11 @@ if (!wantJson) {
|
|
|
5013
5461
|
// a `… | jq` / `… | candor-sarif` pipe never breaks.
|
|
5014
5462
|
const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
|
5015
5463
|
let gateViolations = [];
|
|
5464
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the raw text of every rule whose SCOPE bound no function, captured off the
|
|
5465
|
+
// gate evaluation and emitted onto the verdict document. The stderr lines alone left a machine consumer
|
|
5466
|
+
// unable to see that a rule bound nothing — the typo'd-scope silent green, one channel over. Disclosure
|
|
5467
|
+
// only: `ok` and the exit code never consult it.
|
|
5468
|
+
let gateZeroMatch = [];
|
|
5016
5469
|
// ⟨0.24⟩ the `.candor/config` that supplied POLICY VOCABULARY this verdict actually used — named on the
|
|
5017
5470
|
// document (SPEC §3.1 `99eb4e9`), null when no alias was referenced so the verdict stays byte-identical.
|
|
5018
5471
|
let policyVocabulary = null;
|
|
@@ -5065,7 +5518,20 @@ const writeRefusal = (reason, unevaluated = null) => {
|
|
|
5065
5518
|
// must not silently NARROW the guard back to report-only.
|
|
5066
5519
|
if (baselinePath !== null) {
|
|
5067
5520
|
const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
|
|
5068
|
-
if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
5521
|
+
if (baselinePath !== "" && !fs.existsSync(baselinePath) && baselineFromConfig) {
|
|
5522
|
+
// A CHECKED-IN DECLARATION IS NOT THE SAME ABSENCE — see baselineFromConfig above. Exit 2: the
|
|
5523
|
+
// gateless-green class, and an adopter review measured this as the second-likeliest first-commit
|
|
5524
|
+
// mistake (`.candor/` committed, the baseline not).
|
|
5525
|
+
console.error(`candor-ts: .candor/config declares \`baseline ${baselinePath}\` but that file is not `
|
|
5526
|
+
+ `there — failing (exit 2). A checked-in declaration says this repo HAS a baseline, so an absent `
|
|
5527
|
+
+ `one was deleted or never committed. Commit it, or record one: candor-ts <target> --out <prefix>.`);
|
|
5528
|
+
// WRITE THE REFUSAL DOCUMENT BEFORE EXITING. Without this the `--gate-json` file keeps whatever the
|
|
5529
|
+
// LAST run left there — so a CI wrapper that reads the artifact instead of the exit code sees the
|
|
5530
|
+
// previous run's `ok: true` and reports a pass, which is the stale-artifact false green this format
|
|
5531
|
+
// exists to refuse. java, rust and swift all overwrite on this branch; ts alone did not.
|
|
5532
|
+
writeRefusal(`.candor/config declares \`baseline ${baselinePath}\` but that file is not there`);
|
|
5533
|
+
process.exit(2);
|
|
5534
|
+
} else if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
5069
5535
|
console.error(`candor-ts: CANDOR_BASELINE ${baselinePath} does not exist — the regression guard is `
|
|
5070
5536
|
+ `not active (record one: candor-ts <target> --out <prefix>, then point at the report .json).`);
|
|
5071
5537
|
} else {
|
|
@@ -5075,6 +5541,10 @@ if (baselinePath !== null) {
|
|
|
5075
5541
|
if (!Array.isArray(arr)) {
|
|
5076
5542
|
console.error(`candor-ts: baseline ${shownB} exists but could not be parsed (corrupt/truncated?) — `
|
|
5077
5543
|
+ `failing (exit 2); the guard must not silently pass on an unreadable baseline. Regenerate it with this build.`);
|
|
5544
|
+
// ⟨0.27⟩ the refusal document has no exempt cause AND no exempt sink (SPEC §3.1): a file sink holds
|
|
5545
|
+
// the armed placeholder, but `--gate-json -` is not armed, so without this write the stream carried
|
|
5546
|
+
// NOTHING on this cause. Writing here also replaces the placeholder with the specific reason.
|
|
5547
|
+
writeRefusal(`baseline ${shownB} exists but could not be parsed — guard NOT evaluated`);
|
|
5078
5548
|
process.exit(2);
|
|
5079
5549
|
}
|
|
5080
5550
|
const baseVersion = !Array.isArray(root) && root.candor && typeof root.candor === "object"
|
|
@@ -5082,12 +5552,14 @@ if (baselinePath !== null) {
|
|
|
5082
5552
|
if (baseVersion === null) {
|
|
5083
5553
|
console.error(`candor-ts: the baseline ${shownB} has no provenance header (a legacy/bare-array report) — `
|
|
5084
5554
|
+ `a baseline is comparable only to its producing build (§2.1). Failing (exit 2); regenerate it with this build.`);
|
|
5555
|
+
writeRefusal(`baseline ${shownB} has no provenance header — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5085
5556
|
process.exit(2);
|
|
5086
5557
|
}
|
|
5087
5558
|
if (baseVersion !== ENGINE_VERSION) {
|
|
5088
5559
|
console.error(`candor-ts: the baseline ${shownB} was produced by engine build ${baseVersion} but this is `
|
|
5089
5560
|
+ `build ${ENGINE_VERSION} — an engine swap is baseline-invalidating and the gate cannot evaluate `
|
|
5090
5561
|
+ `(exit 2; never a silent skip, never a bogus AS-EFF-005 wave). Regenerate deliberately with this build.`);
|
|
5562
|
+
writeRefusal(`baseline ${shownB} was produced by engine build ${baseVersion}, not this build — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5091
5563
|
process.exit(2);
|
|
5092
5564
|
}
|
|
5093
5565
|
const base = new Map();
|
|
@@ -5114,6 +5586,7 @@ if (baselinePath !== null) {
|
|
|
5114
5586
|
console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
|
|
5115
5587
|
+ `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
|
|
5116
5588
|
+ `report-only. Regenerate the baseline with this build.`);
|
|
5589
|
+
writeRefusal(`baseline callgraph ${sidecarPath} could not be parsed — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5117
5590
|
process.exit(2);
|
|
5118
5591
|
}
|
|
5119
5592
|
// The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
|
|
@@ -5221,9 +5694,11 @@ if (policyPath !== null) {
|
|
|
5221
5694
|
if (policyErrs.length) {
|
|
5222
5695
|
const why = policyErrorText(policyPath, policyErrs);
|
|
5223
5696
|
console.error(why);
|
|
5224
|
-
// ⟨0.
|
|
5225
|
-
//
|
|
5226
|
-
|
|
5697
|
+
// ⟨0.27⟩ ONE `unevaluated` ENTRY PER RULE OF THE POLICY — not only the unhonourable lines (SPEC
|
|
5698
|
+
// §3.1's composed-document clause). Measured: listing only the bad token's line let a consumer read
|
|
5699
|
+
// `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
|
|
5700
|
+
// so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
|
|
5701
|
+
policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
|
|
5227
5702
|
} else {
|
|
5228
5703
|
// ⟨0.24⟩ the config file that supplied vocabulary the verdict USED, so an ambient `.candor/config` — the
|
|
5229
5704
|
// walk goes up through every parent, and CANDOR_CONFIG overrides it outright — cannot move a verdict while
|
|
@@ -5232,7 +5707,20 @@ if (policyPath !== null) {
|
|
|
5232
5707
|
const p = discoverConfigPath(policyVocabularyAnchor(policyPath, target));
|
|
5233
5708
|
if (p) policyVocabulary = { config: p, aliases: gatePolicy.aliasesUsed };
|
|
5234
5709
|
}
|
|
5235
|
-
|
|
5710
|
+
const gateOut = evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners);
|
|
5711
|
+
// ⟨0.27⟩ SPEC §4 — a rule that bound NO function is disclosed, never scored as satisfied. The exit
|
|
5712
|
+
// code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
|
|
5713
|
+
// repositories and a layer exists in only some of them, so refusal would make a shared policy
|
|
5714
|
+
// unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
|
|
5715
|
+
for (const raw of gateOut.zeroMatch ?? []) {
|
|
5716
|
+
console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
|
|
5717
|
+
+ `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
|
|
5718
|
+
+ `repos; a typo'd layer name otherwise.`);
|
|
5719
|
+
}
|
|
5720
|
+
// ⟨0.27⟩ captured BEFORE the concat below — `concat` returns a plain array, so the `zeroMatch`
|
|
5721
|
+
// property riding `gateOut` would be silently lost with it (see the gateZeroMatch declaration).
|
|
5722
|
+
gateZeroMatch = gateOut.zeroMatch ?? [];
|
|
5723
|
+
gateViolations = gateViolations.concat(gateOut);
|
|
5236
5724
|
// Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
|
|
5237
5725
|
// in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
|
|
5238
5726
|
// fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
|
|
@@ -5307,6 +5795,9 @@ if (gateJsonPath) {
|
|
|
5307
5795
|
// consumer reading exit 1 must be able to see that the POLICY half of the gate never ran — the same
|
|
5308
5796
|
// `unevaluated` key, in the same position, that `gate --report` uses for its answerability refusals.
|
|
5309
5797
|
if (policyRefusal) verdictObj.unevaluated = policyRefusal.unevaluated;
|
|
5798
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines carry, in the machine channel. Omitted
|
|
5799
|
+
// when empty so a fully-binding verdict is byte-identical; never consulted for `ok` or the exit code.
|
|
5800
|
+
if (gateZeroMatch.length) verdictObj.zeroMatch = gateZeroMatch;
|
|
5310
5801
|
// ⟨0.21⟩ (Gap 2) the machine-legible incompleteness: the units candor couldn't analyze, so a CI/agent
|
|
5311
5802
|
// reading the JSON learns WHY the gate can't certify (the stderr warning alone used to hide this from a
|
|
5312
5803
|
// machine). `incomplete:true` + the list; the run exits 2 (could-not-fully-evaluate) below. ok:false +
|