candor-ts 0.26.0 → 0.28.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 +49 -2
- package/README.md +2 -2
- package/contract.mjs +16 -2
- package/lsp.mjs +51 -3
- package/mcp.mjs +181 -21
- package/package.json +2 -2
- package/policy.mjs +134 -6
- package/query-core.mjs +335 -12
- package/query.mjs +879 -57
- package/scan-core.mjs +39 -0
- package/scan.mjs +1330 -39
- package/surface.mjs +15 -2
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, policyZeroRules, 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.28";
|
|
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,765 @@ 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, out = null, refused = false;
|
|
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
|
+
// ⟨0.28⟩ A MISSING/FLAG-SHAPED VALUE IS WHERE THE PARSE LOOP EXITS 2 — and this pre-pass used to
|
|
160
|
+
// disagree about what happens NEXT. The loop refuses `--policy --out X` at `--policy` and never
|
|
161
|
+
// parses another token; this pre-pass skipped on and read `--out X` as a fresh flag, so X was
|
|
162
|
+
// ARMED on an argv the loop never accepts — SPEC §3.3.1 (1)'s precondition ("`--out` has been
|
|
163
|
+
// parsed and accepted") was false, and X's previous reports became permanent placeholders.
|
|
164
|
+
// So report-set arming honours only an `--out` consumed BEFORE the first token the loop refuses
|
|
165
|
+
// at (`--out p --zzz` still arms p: the loop accepted that pair before it died). `gate`, `policy`
|
|
166
|
+
// and `target` keep collecting past the breakage, each for its own reason: the gate sink must be
|
|
167
|
+
// armed with the refusal whatever the argv order (⟨0.27⟩ (1)) and arming it writes a refusal, not
|
|
168
|
+
// a placeholder over a report set; policy/target only feed the input GUARDS, where over-collection
|
|
169
|
+
// can only protect a file more — and the run is exiting 2 regardless.
|
|
170
|
+
if (v === undefined || (v !== "-" && v.startsWith("--"))) { refused = true; continue; }
|
|
171
|
+
// ⟨0.28⟩ THE LAST `--out` WINS, BECAUSE THAT IS WHAT THE PARSE LOOP HONOURS — every assignment here
|
|
172
|
+
// overwrites, deliberately. candor-swift's arm of this rung caught the reference engine returning
|
|
173
|
+
// the FIRST: measured on `--out p1 --out p2 --zzz-not-a-flag`, p1 was armed and p2 — the prefix the
|
|
174
|
+
// run would actually have written — stayed STALE, so the rung did nothing for that argv while
|
|
175
|
+
// neutralising a set nobody was going to replace. A pre-pass that disagrees with the loop it exists
|
|
176
|
+
// to run ahead of arms the wrong thing.
|
|
177
|
+
if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v; else if (!refused) out = v;
|
|
178
|
+
i++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// The scan TARGET, needed to discover the `.candor/config` whose `policy` key may name an input
|
|
182
|
+
// this sink must not overwrite.
|
|
183
|
+
if (!a.startsWith("-") && target === null) target = a;
|
|
184
|
+
}
|
|
185
|
+
return { gate, policy, target, out };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// SPEC §3.3.1 ⟨0.28⟩ — every `--gate-json` this argv names. `preScan` keeps only the last, which is what
|
|
189
|
+
// the parse loop honours and exactly the behaviour this rung refuses: measured, three engines wrote the
|
|
190
|
+
// verdict to the LAST path and left the first holding a previous run's `{"ok": true}` while the gate
|
|
191
|
+
// fired — the ⟨0.27⟩ stale green, reached by a spelling nobody had considered.
|
|
192
|
+
const allGateSinks = (av) => {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (let i = 0; i < av.length; i++) {
|
|
195
|
+
if (av[i] !== "--gate-json") continue;
|
|
196
|
+
const v = av[i + 1];
|
|
197
|
+
if (v === undefined || (v !== "-" && v.startsWith("--"))) continue;
|
|
198
|
+
out.push(v); i++;
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Every path this run READS, whatever channel it arrived through (SPEC §3.3.1 ⟨0.27⟩).
|
|
204
|
+
//
|
|
205
|
+
// THE FIRST VERSION OF THIS GUARD KEYED ON THE FLAG. With the policy declared by `.candor/config` — the
|
|
206
|
+
// checked-in form, i.e. the one a CI job actually has — `--gate-json <that policy>` destroyed it and
|
|
207
|
+
// exited 0 with `"ok": true` in ALL FOUR ENGINES. A policy does not change what it is according to how
|
|
208
|
+
// the operator handed it over. The config is read LENIENTLY (no exit, no diagnostic): this runs before
|
|
209
|
+
// the real config load and must not pre-empt its refusal.
|
|
210
|
+
// Artifact identity, not string identity: `--policy /w/P --gate-json ./P` from /w is one file, and the
|
|
211
|
+
// engine that already had this guard compared path spellings and lost to exactly that. realpath resolves
|
|
212
|
+
// `.`, `..` and symlinks; for a sink that does not exist yet its parent is resolved instead.
|
|
213
|
+
const sameArtifact = (a, b) => {
|
|
214
|
+
if (!a || !b || a === "-" || b === "-") return false;
|
|
215
|
+
// ⟨0.28⟩ DEVICE+INODE FIRST. Path equality alone called two HARDLINKS to one inode two sinks and
|
|
216
|
+
// refused a legal command — the mirror of the stale green. And a symlink whose target does not exist
|
|
217
|
+
// YET still names that target, which `realpathSync` cannot resolve, so resolve it explicitly.
|
|
218
|
+
try {
|
|
219
|
+
const sa = fs.statSync(a), sb = fs.statSync(b);
|
|
220
|
+
if (sa.dev === sb.dev && sa.ino === sb.ino) return true;
|
|
221
|
+
} catch { /* one of them is not there yet — fall through */ }
|
|
222
|
+
try {
|
|
223
|
+
const ra = resolveSinkArtifact(a), rb = resolveSinkArtifact(b);
|
|
224
|
+
if (ra !== a || rb !== b) {
|
|
225
|
+
if (path.resolve(ra) === path.resolve(rb)) return true;
|
|
226
|
+
}
|
|
227
|
+
} catch { /* fall through to the path forms below */ }
|
|
228
|
+
const resolve = (p) => {
|
|
229
|
+
try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
|
|
230
|
+
try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); } catch { return null; }
|
|
231
|
+
};
|
|
232
|
+
const x = resolve(a);
|
|
233
|
+
return x !== null && x === resolve(b);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// ⟨0.28⟩ SPEC §3.3.1 — EVERY `--out` THIS ARGV NAMES, in order, duplicates kept. `preScan` keeps only the
|
|
237
|
+
// LAST (what the parse loop honours); this is the same walk, and it exists because **a repeated `--out`
|
|
238
|
+
// is the same rule as a repeated `--gate-json`** — the question the rung that settled the verdict sink
|
|
239
|
+
// filed as "deferred" for the report sink, on no stated ground except which sink was in front of the
|
|
240
|
+
// author.
|
|
241
|
+
//
|
|
242
|
+
// ONE WALK, not a second parser. The value rules are `preScan`'s exactly, including the `refused` latch:
|
|
243
|
+
// a `--out` named after the first token the parse loop will refuse at was never accepted, so it is not a
|
|
244
|
+
// sink. Two walks with two value rules is how the repeated form and the single form come to disagree
|
|
245
|
+
// about which tokens are prefixes at all — and candor-swift's arm of this rung caught the reference
|
|
246
|
+
// engine arming the FIRST prefix while the run wrote the LAST.
|
|
247
|
+
const allOutPrefixes = (av) => {
|
|
248
|
+
const out = [];
|
|
249
|
+
let refused = false;
|
|
250
|
+
for (let i = 0; i < av.length; i++) {
|
|
251
|
+
const a = av[i], v = av[i + 1];
|
|
252
|
+
if (a !== "--gate-json" && a !== "--policy" && a !== "--out") continue;
|
|
253
|
+
if (v === undefined || (v !== "-" && v.startsWith("--"))) { refused = true; continue; }
|
|
254
|
+
if (a === "--out" && !refused) out.push(v);
|
|
255
|
+
i++;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
};
|
|
259
|
+
// Two spellings of ONE prefix are ONE report set — the §3.3.1 artifact rule applied to the prefix. A
|
|
260
|
+
// prefix is not itself a file, but `sameArtifact` resolves the PARENT of a path that does not exist, so
|
|
261
|
+
// `p` and `./p` from p's own directory collapse. Order-preserving, first spelling kept; `--out` never
|
|
262
|
+
// accepts `-`, so the stream arm of that helper is inert here.
|
|
263
|
+
const distinctOutPrefixes = (all) => {
|
|
264
|
+
const out = [];
|
|
265
|
+
for (const p of all) if (!out.some((k) => k === p || sameArtifact(k, p))) out.push(p);
|
|
266
|
+
return out;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// ⟨0.28⟩ SPEC §3.3.1 — **A SINK THAT LIES UNDER THE SCAN TARGET AND BEARS AN EXTENSION THIS ENGINE
|
|
270
|
+
// PARSES IS REFUSED.** This is the residual the exact-artifact ruling deliberately left: registering
|
|
271
|
+
// the target in `runInputs` catches `--gate-json <the target>` and cannot catch `--gate-json
|
|
272
|
+
// src/main.ts` while scanning `tsconfig.json` or `.`, because the file set the run will parse is not
|
|
273
|
+
// known at the moment arming happens (arming precedes the file walk, and deferring it would uncover
|
|
274
|
+
// the argv-error exits the arming rule exists for).
|
|
275
|
+
//
|
|
276
|
+
// MEASURED HERE BEFORE THIS FIX, and this engine had the worst artifact of the four:
|
|
277
|
+
//
|
|
278
|
+
// $ node scan.mjs tsconfig.json --gate-json src/main.ts
|
|
279
|
+
// candor-ts: 1 source file(s) failed to parse — NOT analyzed …
|
|
280
|
+
// candor-ts: wrote 0 effectful functions (1 analyzed, 1 files) to .candor/report.json exit 0
|
|
281
|
+
// $ cat src/main.ts
|
|
282
|
+
// { "spec": "0.28", "ok": false, … } ← the operator's SOURCE, unrecoverably replaced
|
|
283
|
+
//
|
|
284
|
+
// Unrecoverable loss of the operator's own code, reported as SUCCESS — the run destroyed the file,
|
|
285
|
+
// then dutifully disclosed the parse failure it had itself caused.
|
|
286
|
+
//
|
|
287
|
+
// NOT CONTAINMENT IN GENERAL, and that control is the whole difference between this and the fix the
|
|
288
|
+
// ruling explicitly rejects. `<dir>/.candor/report.json` is under the target and is not a source
|
|
289
|
+
// file, so the recommended layout stays permitted; a general containment rule was tried in this repo
|
|
290
|
+
// for the dep-directory case and "took 33 tests with it" (see `runInputs`). Extension is the whole of
|
|
291
|
+
// the predicate, and an engine knows its own source extensions before it knows its file list — which
|
|
292
|
+
// is exactly what makes this checkable at the instant arming happens.
|
|
293
|
+
//
|
|
294
|
+
// THE JS FAMILY IS IN THE SET UNCONDITIONALLY, not gated on `--allow-js`. `--allow-js` is only one of
|
|
295
|
+
// the two ways a `.js` reaches the parse set: a tsconfig carrying `allowJs` puts them in
|
|
296
|
+
// `parsed.fileNames`, and the tsconfig is not read at parse time either. Erring here costs a sink
|
|
297
|
+
// spelled `verdict.mjs`; erring the other way costs the operator's source.
|
|
298
|
+
// "UNDER THE TARGET" IS UNDER THE TARGET'S ROOT DIR, AND THAT IS NOT THE SAME STRING. The measured
|
|
299
|
+
// reproduction is `--gate-json src/main.ts` while scanning **`tsconfig.json`** — a FILE target, whose
|
|
300
|
+
// parsed set is everything the config names and therefore lives under its DIRECTORY, not under the
|
|
301
|
+
// token. A first version of this predicate compared against the target path itself and the defect
|
|
302
|
+
// reproduced unchanged (measured, same fixture, source still destroyed at exit 0). So the containment
|
|
303
|
+
// root is computed by the SAME three-way test the run below roots itself with (search `usedTsconfig`):
|
|
304
|
+
// a tsconfig-shaped file roots at its directory, any other single file is exactly itself (and is
|
|
305
|
+
// already covered by the exact-artifact registration in `runInputs`), a directory is itself.
|
|
306
|
+
//
|
|
307
|
+
// Over-approximate on the tsconfig arm, deliberately: the config's `include` may not name every `.ts`
|
|
308
|
+
// under that directory, so a sink spelled `<dir>/excluded/verdict.ts` is refused though the run would
|
|
309
|
+
// not have parsed it. The cost is a verdict path that has to be renamed; the cost of the other
|
|
310
|
+
// direction is the operator's source.
|
|
311
|
+
const PARSED_SOURCE_EXT = /\.[mc]?[tj]sx?$/i;
|
|
312
|
+
const targetContainmentRoot = (target) => {
|
|
313
|
+
let t;
|
|
314
|
+
try { t = fs.realpathSync(target); } catch { return null; } // a missing target refuses on its own terms
|
|
315
|
+
try {
|
|
316
|
+
if (fs.statSync(t).isFile()) return /tsconfig.*\.json$/i.test(path.basename(t)) ? path.dirname(t) : t;
|
|
317
|
+
} catch { return null; }
|
|
318
|
+
return t;
|
|
319
|
+
};
|
|
320
|
+
const sinkIsParsedSourceUnderTarget = (sink, target) => {
|
|
321
|
+
if (!sink || sink === "-" || !target) return false;
|
|
322
|
+
if (!PARSED_SOURCE_EXT.test(path.basename(sink))) return false;
|
|
323
|
+
const t = targetContainmentRoot(target);
|
|
324
|
+
if (t === null) return false;
|
|
325
|
+
// The sink may not exist yet — resolve its parent and re-append the name, the shape `sameArtifact` uses.
|
|
326
|
+
let s;
|
|
327
|
+
try { s = fs.realpathSync(sink); }
|
|
328
|
+
catch {
|
|
329
|
+
try { s = path.join(fs.realpathSync(path.dirname(path.resolve(sink))), path.basename(sink)); }
|
|
330
|
+
catch { return false; }
|
|
331
|
+
}
|
|
332
|
+
return s === t || s.startsWith(t.endsWith(path.sep) ? t : t + path.sep);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const runInputs = (target, policyFlag) => {
|
|
336
|
+
const out = [];
|
|
337
|
+
// ⟨0.28⟩ THE SCAN TARGET ITSELF — SPEC §3.3.1 (3) lists "the target's own source tree" among the
|
|
338
|
+
// inputs arming must not touch, and this list did not: `--gate-json app.ts` on the file being scanned
|
|
339
|
+
// armed the refusal verdict OVER the operator's source, the run then parsed the wreckage it had just
|
|
340
|
+
// made ("1 source file(s) failed to parse"), and exited 0. Silent destruction of the one file the run
|
|
341
|
+
// exists to describe, reported as success. Measured live, and the same gap was in all four engines.
|
|
342
|
+
//
|
|
343
|
+
// EXACT ARTIFACT, NEVER CONTAINMENT. Registering the target as one more `[path, label]` pair means
|
|
344
|
+
// `sameArtifact` refuses only a sink that IS the target — a report or verdict written into `.candor/`
|
|
345
|
+
// INSIDE a directory target is ordinary usage and still permitted. Making `sameArtifact`
|
|
346
|
+
// directory-aware was tried for the dep-directory case below and rejected (it refused the ordinary
|
|
347
|
+
// in-tree layout and took 33 tests with it); the same reasoning holds here.
|
|
348
|
+
if (target) out.push([target, "the scan target"]);
|
|
349
|
+
if (policyFlag) out.push([policyFlag, "--policy"]);
|
|
350
|
+
for (const [v, label] of [["CANDOR_POLICY", "CANDOR_POLICY"], ["CANDOR_BASELINE", "CANDOR_BASELINE"],
|
|
351
|
+
["CANDOR_CONFIG", "CANDOR_CONFIG"]]) {
|
|
352
|
+
if (process.env[v]) out.push([process.env[v], label]);
|
|
353
|
+
}
|
|
354
|
+
// ONE DEFINITION, shared with the loader — see DEP_SEPARATORS. This comment used to claim it was
|
|
355
|
+
// "the separator set the dep loader accepts" while spelling a DIFFERENT set one screen away, and the
|
|
356
|
+
// gap between the two was a silent green: a newline-separated `CANDOR_DEPS` registered here as one
|
|
357
|
+
// unresolvable token, so the guard protected nothing, while the loader split it into real paths.
|
|
358
|
+
// `--gate-json` naming one of those reports then DESTROYED it and the run exited 0 with `ok: true`
|
|
359
|
+
// written over the operator's input — §3.3.1's own words, "a machine-readable all-clear produced by
|
|
360
|
+
// deleting the question". Measured live before this change.
|
|
361
|
+
for (const d of (process.env.CANDOR_DEPS ?? "").split(DEP_SEPARATORS).filter(Boolean)) {
|
|
362
|
+
out.push([d, "a CANDOR_DEPS report"]);
|
|
363
|
+
// A DIRECTORY DEP IS EVERY REPORT INSIDE IT. `deps` accepts a directory — `--workspace` writes
|
|
364
|
+
// `.candor/deps/` and hands that back, so it is the common spelling — and the loader then walks it
|
|
365
|
+
// and reads each `*.json`. Registering only the DIRECTORY left those files unnamed, so
|
|
366
|
+
// `--gate-json <depdir>/lib.json` was unguarded: arming destroyed the operator's dep report, the
|
|
367
|
+
// run chained the wreckage and exited 0 with `ok: true` over it. Measured in all four engines.
|
|
368
|
+
//
|
|
369
|
+
// EXPANDED HERE, not by making `sameArtifact` directory-aware. That was tried and is far too
|
|
370
|
+
// broad: the scan TARGET is an input too, and a verdict written into the tree being scanned is
|
|
371
|
+
// ordinary usage — the general rule refused it and took 33 tests with it. Only a DEP directory has
|
|
372
|
+
// its CONTENTS read, so only a dep directory expands.
|
|
373
|
+
try {
|
|
374
|
+
if (fs.statSync(d).isDirectory()) {
|
|
375
|
+
for (const f of fs.readdirSync(d)) {
|
|
376
|
+
if (f.endsWith(".json") && !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json")
|
|
377
|
+
&& !f.endsWith(".locs.json")) {
|
|
378
|
+
out.push([path.join(d, f), "a CANDOR_DEPS report"]);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
} catch { /* not a directory, or unreadable — the token itself is still registered above */ }
|
|
383
|
+
}
|
|
384
|
+
// …AND THE CONFIG'S OWN KEYS, THROUGH THE ENGINE'S OWN LOADER AND ITS OWN DISCOVERY. This used to
|
|
385
|
+
// re-derive both, and a review took it apart: the home directory was computed as parent-of-parent
|
|
386
|
+
// unconditionally where the loader only steps out of a trailing `.candor/` segment, so an out-of-tree
|
|
387
|
+
// CANDOR_CONFIG had its relative values anchored one level too high and the guard protected a path
|
|
388
|
+
// the run never reads. A second parser is a second set of holes; `loadCandorConfig` is called inside a
|
|
389
|
+
// try so it can still refuse for real a moment later.
|
|
390
|
+
const cfgFile = discoverConfigFile(target ?? ".");
|
|
391
|
+
if (cfgFile) {
|
|
392
|
+
out.push([cfgFile, "the discovered .candor/config"]);
|
|
393
|
+
try {
|
|
394
|
+
const cfg = loadCandorConfig(target ?? ".", { lenient: true });
|
|
395
|
+
for (const key of ["policy", "baseline"]) {
|
|
396
|
+
if (cfg[key]) out.push([cfg[key], `the config's \`${key}\``]);
|
|
397
|
+
}
|
|
398
|
+
for (const one of (cfg.deps ?? "").split(":").filter(Boolean)) {
|
|
399
|
+
out.push([one, "the config's `deps`"]);
|
|
400
|
+
}
|
|
401
|
+
} catch { /* lenient: the real load refuses on its own terms */ }
|
|
402
|
+
}
|
|
403
|
+
return out;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// ⟨0.27⟩ THE STREAM SINK'S ANALOG OF ARMING — SPEC §3.1's stream-sink clause. `--gate-json -` cannot be
|
|
407
|
+
// armed (a stream has no stale previous document, and a placeholder would put TWO documents in a
|
|
408
|
+
// consumer's pipe), but the document-on-every-exit rule applies in full: an exit-2 cause that fires
|
|
409
|
+
// before the gate tail — an unknown flag, a valueless gate-adjacent flag, a missing target — must still
|
|
410
|
+
// leave the fail-closed refusal as the stream's only content. Measured: an unhonourable policy wrote the
|
|
411
|
+
// refusal to stdout while an unknown flag exited 2 leaving stdout EMPTY — the same operator mistake,
|
|
412
|
+
// answered or not according to which early exit fired, and an empty stream throws the consumer back to
|
|
413
|
+
// scraping stderr. File sinks need nothing here: the arming above already left a refusal in place.
|
|
414
|
+
// DECLARED HERE, above the pre-arg-loop `{ … }` block: those guards can themselves exit 2, and the
|
|
415
|
+
// helper has to exist BEFORE its first caller — a `const` doesn't hoist, so left below where it used to
|
|
416
|
+
// live (before the arg loop but after this block) it was in the temporal dead zone at every gate-input
|
|
417
|
+
// refusal, and stdout stayed empty on every one of them. Measured while writing this move.
|
|
418
|
+
const preGateSink = preScan(argv).gate;
|
|
419
|
+
// ⟨0.28⟩ SPEC §3.3.1 (4) — THE SAME RULE ONE HOP UPSTREAM, FOR THE REPORT STREAM. `--json` is the
|
|
420
|
+
// stdout REPORT sink; on any exit-2 the fail-closed ⟨0.21⟩ Row-1 report is written to stdout as the
|
|
421
|
+
// stream's only content. Measured 2026-08-10 across all four engines: `--json --zzz-not-a-flag` exited
|
|
422
|
+
// 2 with stdout ZERO BYTES, so a downstream JSON consumer parsing stdout throws and is thrown back to
|
|
423
|
+
// scraping stderr — the same defect ⟨0.27⟩ closed on the verdict stream, arriving through the report
|
|
424
|
+
// sink because that rule was written for the verdict sink and no engine extended it. Detected here
|
|
425
|
+
// pre-arg-loop by `argv.includes("--json")`: this flag is stdout-only, and the value-consuming flags
|
|
426
|
+
// exit 2 rather than swallow it as their value, so a `--json` token in argv IS the request.
|
|
427
|
+
const preWantJson = argv.includes("--json");
|
|
428
|
+
// `reportStreamWritten` latch: mirrors the rust reference's `REPORT_STREAM_WRITTEN` OnceLock. Set
|
|
429
|
+
// once, at the successful `wantJson` print further down the file (search for this comment tag), so a
|
|
430
|
+
// later exit-2 site does not put a SECOND JSON document on the stream — two documents on one pipe
|
|
431
|
+
// parses as neither.
|
|
432
|
+
let reportStreamWritten = false;
|
|
433
|
+
// The ⟨0.21⟩ Row-1 manifest-carrying empty: `functions: []` + `analyzed.count: 0` + `unanalyzed`
|
|
434
|
+
// naming the cause. A ⟨0.24⟩ consumer already reads this as *nothing was judged, no purity licence*
|
|
435
|
+
// and a `gate --report` records the file `invisible` — no new reader logic. The version/toolchain
|
|
436
|
+
// match the ordinary envelope so a consumer's provenance check reads the same.
|
|
437
|
+
// ONE BUILDER FOR BOTH SINKS: the stream form below and the ⟨0.28⟩ `--out` file armer emit the SAME
|
|
438
|
+
// bytes for the same reason string. Two spellings of one document is how a consumer ends up with two
|
|
439
|
+
// shapes to recognise, and the file form is the one a `gate --report` parses.
|
|
440
|
+
const failClosedReportDoc = (reason) => {
|
|
441
|
+
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\n\r]/g, " ");
|
|
442
|
+
return `{\n "candor": {\n "version": "candor-ts-${PKG_VERSION}",\n "toolchain": "node-${process.versions.node}",\n "spec": "${SPEC_VERSION}"\n },\n "functions": [],\n "analyzed": { "count": 0 },\n "unanalyzed": [\n { "path": "<run>", "reason": "${esc(reason)}" }\n ]\n}`;
|
|
443
|
+
};
|
|
444
|
+
const refuseEarlyToStream = (why) => {
|
|
445
|
+
if (preGateSink === "-") console.log(JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1));
|
|
446
|
+
else if (preWantJson && !reportStreamWritten) {
|
|
447
|
+
// Skipped when stdout is already claimed by `--gate-json -` (the two-stream case is refused
|
|
448
|
+
// earlier with a verdict on stdout).
|
|
449
|
+
console.log(failClosedReportDoc(`refused: ${why}`));
|
|
450
|
+
reportStreamWritten = true;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// ── SPEC §3.3.1 ⟨0.28⟩ — ARM THE `--out <prefix>` REPORT SET, AND HAND BACK WHAT THE RUN DID NOT OWN.
|
|
455
|
+
//
|
|
456
|
+
// The verdict sink arms by writing to a path the run is about to own. A report PREFIX cannot: at parse
|
|
457
|
+
// time the run does not know which files it will write (this engine writes `<prefix>.json`, a workspace
|
|
458
|
+
// engine fans out to one per member), so the set it DOES know is the one the PREVIOUS run left on disk —
|
|
459
|
+
// and that is exactly the set at risk of being read as current after this run fails. Measured on this
|
|
460
|
+
// engine: `node scan.mjs <target> --out p --zzz-not-a-flag` exited 2 with `p.json` byte-identical to the
|
|
461
|
+
// previous good run, and a downstream `gate --report p.json` then reads a green report the failed run
|
|
462
|
+
// never produced.
|
|
463
|
+
//
|
|
464
|
+
// Armed from the pre-pass, before the arg loop's own unknown-flag exit — the exit this rung is most
|
|
465
|
+
// often reached through. Each report the run does write overwrites its placeholder a moment later.
|
|
466
|
+
//
|
|
467
|
+
// ⟨0.28⟩ AND THE §2.2 SIDECARS GO WITH THE REPORT — DELETED, NOT EMPTIED. That question is no longer
|
|
468
|
+
// open: an armed report beside a LIVE sidecar is a pair that contradicts itself, and §2.2 gives the
|
|
469
|
+
// sidecar no provenance of its own to arbitrate with. See `removeArmedReportSidecars` below.
|
|
470
|
+
const OUT_ARM_DOC = failClosedReportDoc(
|
|
471
|
+
"armed: this report was written when the run STARTED and was never replaced, so the run failed, "
|
|
472
|
+
+ "crashed or was killed before it could describe this package. It is NOT a claim about any code; "
|
|
473
|
+
+ "see the run's stderr for the cause.") + "\n";
|
|
474
|
+
/** `[path, bytes-before-arming]` for every report armed under the out prefix. */
|
|
475
|
+
const outArmed = [];
|
|
476
|
+
/** `[reportPath, sidecarPath, bytes-before-deletion]` for every §2.2 sidecar deleted while arming. */
|
|
477
|
+
const outArmedSidecars = [];
|
|
478
|
+
|
|
479
|
+
// ⟨0.28⟩ THIS REPORT'S §2.2 SIDECARS, DELETED WITH IT.
|
|
480
|
+
//
|
|
481
|
+
// `callers`/`whatif`/`rewire` are answered FROM THE SIDECAR, not from the report: a currently-pure
|
|
482
|
+
// function is absent from the report by §2 rule 3, so only `<stem>.callgraph.json` records it. Leaving
|
|
483
|
+
// the sidecar live beside an armed report is therefore not an untidy pair, it is the cardinal sin one
|
|
484
|
+
// file over. MEASURED ON THIS ENGINE — baseline `f` pure, reached by `g`; the new version gives `f` an
|
|
485
|
+
// `fs.readFileSync` and adds a second caller `h`; the run exits 2 on an unknown flag with the report
|
|
486
|
+
// armed to the ⟨0.21⟩ Row-1 empty:
|
|
487
|
+
//
|
|
488
|
+
// callers f -> exit 0, direct: ["src.app.g"], transitive: ["src.app.g", "src.app.main"]
|
|
489
|
+
//
|
|
490
|
+
// Confident, exit 0, and WRONG: `h` calls `f` too, and an agent reads that as safe-to-edit. After this
|
|
491
|
+
// change the same query has no graph to answer from, and a recovering run answers `g` AND `h`.
|
|
492
|
+
//
|
|
493
|
+
// THE CONSUMER HALF IS NOW IMPLEMENTED TOO, and this paragraph used to record that it was not — which is
|
|
494
|
+
// exactly how a limitation written as a comment reads as handled and stops being measured. Deleting the
|
|
495
|
+
// sidecar removes the confidently WRONG answer; it does not by itself produce an honest one. The absence
|
|
496
|
+
// arm here was an EMPTY caller set at exit 0 — human-fine, machine-silent, the split that makes a defect
|
|
497
|
+
// a cardinal sin. `callers` (query.mjs) now emits an `unanswerable` key AND exits 2 over a pair with no
|
|
498
|
+
// §2.2 sidecar; conformance PART 37 row (e) pins it. `impact` and `path` FOLLOWED — the sibling-route
|
|
499
|
+
// habit caught early for once, and the line that separates them from the list below is not "graph verb"
|
|
500
|
+
// but WHERE THE TARGET RESOLVES: both resolve theirs over the §2.2 call graph, so with no sidecar every
|
|
501
|
+
// answer is vacuous, and each vacuum spells itself as the reassurance the verb exists to give
|
|
502
|
+
// (`affectedCount: 0` = safe to change; `path: []` = it does not reach that effect). rust and java exit 2
|
|
503
|
+
// on both. STILL OPEN, named so it stays measured: the report-only DESCRIPTIVE verbs
|
|
504
|
+
// (`map`/`show`/`blindspots`/`where`/`reachable`/`containment`/`tour`) answer flat at exit 0 over an
|
|
505
|
+
// armed report. That is a rung of its own — it wants the ⟨0.21⟩ manifest forwarded into descriptive-verb
|
|
506
|
+
// JSON — not a patch to this one.
|
|
507
|
+
//
|
|
508
|
+
// DELETED RATHER THAN `{}`, and NOT by reading the report's own anti-deletion rule (§3.3.1) across. That
|
|
509
|
+
// rule exists because a consumer treating a missing REPORT as "nothing to report" fails open. No sidecar
|
|
510
|
+
// consumer has that failure mode: §2.2 makes the sidecar OPTIONAL, so every consumer was forced to define
|
|
511
|
+
// an absence arm from the start and every specified arm is safe (over-listing §3.1, `origin: "unknown"`,
|
|
512
|
+
// refusal on a corrupt one). And ⟨0.24⟩ has already RULED empty ≡ absent ≡ unparseable for the hierarchy.
|
|
513
|
+
// `{}` is a file this family has declared meaningless.
|
|
514
|
+
//
|
|
515
|
+
// THE GUESS RUNS OPPOSITE TO THE ARMER'S, deliberately. The armer above identifies its own report
|
|
516
|
+
// POSITIVELY by content, because there a miss leaves a stale report and an over-reach destroys a file.
|
|
517
|
+
// Here a miss merely leaves a sidecar behind — the pre-rung state, and the ⟨0.28⟩ pairing rule catches it
|
|
518
|
+
// consumer-side — while an over-reach deletes something that is not ours. So this one goes by the §2.2
|
|
519
|
+
// reserved segment NAMES scoped to the report's own stem. Both directions chosen so the WRONG guess costs
|
|
520
|
+
// least; neither is a template for the other.
|
|
521
|
+
//
|
|
522
|
+
// `gate` AND `encountered-*` ARE NOT TAKEN, though §2.2 reserves them in the same family-wide list. A
|
|
523
|
+
// gate verdict is the VERDICT sink's document — separately armed, separately named by the operator — and
|
|
524
|
+
// destroying it from the report sink is precisely the cross-sink harm §3.3.1 measures, failing OPEN in
|
|
525
|
+
// the way `armGateJsonFailClosed` refuses to. `encountered-*` is a prefix family rather than a segment
|
|
526
|
+
// and belongs to no report's pair. Exclusion by argument, not by a shorter list.
|
|
527
|
+
const REPORT_SIDECAR_SEGMENTS = ["callgraph", "hierarchy", "locs", "calibrated", "layerreach"];
|
|
528
|
+
const removeArmedReportSidecars = (report, prefix, inputs) => {
|
|
529
|
+
const stem = report.replace(/\.json$/i, "");
|
|
530
|
+
for (const seg of REPORT_SIDECAR_SEGMENTS) {
|
|
531
|
+
const side = `${stem}.${seg}.json`;
|
|
532
|
+
// lstat, not existsSync: existsSync FOLLOWS a symlink, so a DANGLING link read as "absent" and was
|
|
533
|
+
// skipped without the symlink disclosure below — but it is still the operator's link, and the
|
|
534
|
+
// symlink arm must see it. A path with nothing there at all falls out through the catch.
|
|
535
|
+
let lst;
|
|
536
|
+
try { lst = fs.lstatSync(side); } catch { continue; }
|
|
537
|
+
// THE INPUT EXEMPTION COVERS THE SIDECARS, asked of the same `runInputs`/`sameArtifact` the report
|
|
538
|
+
// and verdict sink guards use — "do not touch what this run READS" does not stop at the report half.
|
|
539
|
+
// A `--policy`, a chained `CANDOR_DEPS` report or the discovered config can perfectly well be named
|
|
540
|
+
// `<stem>.locs.json`, and deleting it silently would be worse than the pair it repairs.
|
|
541
|
+
const hit = inputs.find(([other]) => sameArtifact(side, other));
|
|
542
|
+
if (hit) {
|
|
543
|
+
console.error(`candor-ts: --out ${prefix} armed the report ${report}, but its \`${seg}\` sidecar `
|
|
544
|
+
+ `${side} is what this run READS as ${hit[1]} — leaving it in place. That report and that `
|
|
545
|
+
+ `sidecar are now a MISMATCHED PAIR: read the sidecar as unanswerable until a run completes.`);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
// A SYMLINKED SIDECAR IS LEFT ALONE, AND SAID SO — the ruling the rust reference pinned in 8094169,
|
|
549
|
+
// raised by candor-swift's arm of this rung. This engine used to resolve the link and delete its
|
|
550
|
+
// TARGET, arguing the stale bytes live there; measured, a failing run (`--out out --zzz`) deleted
|
|
551
|
+
// `../shared/callgraph.json` — a file OUTSIDE this prefix, the canonical copy in a shared-artifact
|
|
552
|
+
// CI layout — unrecoverably, because a failed run never restores and every other link to it now
|
|
553
|
+
// dangles. And even the SUCCESS path cannot make this honest: the disarm hands bytes back with
|
|
554
|
+
// `writeSinkAtomic`, so a delete-then-restore cycle converts the operator's LINK into a regular
|
|
555
|
+
// file — a third state neither the pre-run tree nor the armed tree ever had. The link is not ours
|
|
556
|
+
// to consume. Leaving it is the cheap side of the trade: the ⟨0.28⟩ pairing rule already makes a
|
|
557
|
+
// sidecar beside an armed report unanswerable consumer-side, whereas a severed link (or a destroyed
|
|
558
|
+
// shared target) is not recoverable at all.
|
|
559
|
+
if (lst.isSymbolicLink()) {
|
|
560
|
+
console.error(`candor-ts: ${side} is a §2.2 sidecar of the armed report ${report} but is a SYMLINK `
|
|
561
|
+
+ `— leaving it, because removing it would sever the operator's layout (and a restore would hand `
|
|
562
|
+
+ `back a regular file where a link was). Its report is armed, so treat the pair as unanswerable `
|
|
563
|
+
+ `until a run completes (SPEC §3.3.1 ⟨0.28⟩).`);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
// Remember the bytes first, so a report the run turns out not to own can hand its sidecars back too.
|
|
567
|
+
let prev;
|
|
568
|
+
try { prev = fs.readFileSync(side); } catch { prev = null; }
|
|
569
|
+
try { fs.rmSync(side, { force: true }); if (prev !== null) outArmedSidecars.push([report, side, prev]); }
|
|
570
|
+
catch (e) {
|
|
571
|
+
console.error(`candor-ts: could not remove ${side} beside the armed report ${report} (${e.message}) `
|
|
572
|
+
+ `— it is a STALE half-pair: any callers/whatif answer computed from it describes the last run `
|
|
573
|
+
+ `that completed, not this tree.`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const armOutPrefixFailClosed = (prefix, inputs) => {
|
|
579
|
+
if (!prefix) return;
|
|
580
|
+
const abs = path.resolve(prefix);
|
|
581
|
+
const dir = path.dirname(abs), stem = path.basename(abs);
|
|
582
|
+
if (!stem) return;
|
|
583
|
+
let names;
|
|
584
|
+
try { names = fs.readdirSync(dir); } catch { return; } // no previous run under this prefix: nothing to arm
|
|
585
|
+
for (const name of names.sort()) {
|
|
586
|
+
if (!name.startsWith(`${stem}.`) || !name.endsWith(".json")) continue;
|
|
587
|
+
const full = path.join(dir, name);
|
|
588
|
+
try { if (!fs.statSync(full).isFile()) continue; } catch { continue; }
|
|
589
|
+
// THE ⟨0.27⟩ (2) INPUT EXEMPTION APPLIES TO THIS WRITER TOO, AND IT IS ASKED FIRST. Arming happens
|
|
590
|
+
// before the run knows its answer, so a prefix whose expansion collides with something this run READS
|
|
591
|
+
// would destroy it — the same hazard that made `--policy P --gate-json P` a machine-readable
|
|
592
|
+
// all-clear. A policy or a chained dep report can perfectly well be named `<prefix>.something.json`.
|
|
593
|
+
// Same resolver as every other sink guard (`sameArtifact`: device+inode, then the resolved path).
|
|
594
|
+
//
|
|
595
|
+
// ORDER MATTERS, and identification-first had it backwards (candor-swift's arm of this rung caught
|
|
596
|
+
// it). A `--policy <prefix>.policy.json` holding ordinary rule lines is not JSON, so it fails
|
|
597
|
+
// identification and gets skipped SILENTLY — the operator never learns their policy sat in the arming
|
|
598
|
+
// path, and losing a disclosure is the thing this project does not do. The exemption also has to
|
|
599
|
+
// OUTRANK identification for the case where the colliding input IS a valid report (a chained
|
|
600
|
+
// `CANDOR_DEPS` dep report under the same prefix): "do not touch what this run reads" is the stronger
|
|
601
|
+
// claim, whatever the file turns out to be.
|
|
602
|
+
const hit = inputs.find(([other]) => sameArtifact(full, other));
|
|
603
|
+
if (hit) {
|
|
604
|
+
console.error(`candor-ts: --out ${prefix} would arm over ${full}, which this run READS as ${hit[1]} `
|
|
605
|
+
+ `— leaving it untouched. Give the report set its own prefix.`);
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
// …AND THEN ONLY FILES POSITIVELY IDENTIFIED AS THIS ENGINE'S OWN §2 REPORT — never a name denylist.
|
|
609
|
+
//
|
|
610
|
+
// The first version excluded `.callgraph`/`.hierarchy`/`.locs` by suffix and armed everything else
|
|
611
|
+
// under the prefix. SPEC §2.2 ⟨0.24⟩ (the "reserved set, family-wide" paragraph) lists SEVEN reserved
|
|
612
|
+
// trailing segments — `callgraph`, `hierarchy`, `calibrated`, `layerreach`, `locs`, `gate` and the
|
|
613
|
+
// `encountered-*` family — and records that the engines were already drifting on it, one carving out
|
|
614
|
+
// six and another two. This carved out three. Measured on the rust reference: the armer overwrote
|
|
615
|
+
// `<prefix>.calibrated.json`, `.layerreach.json`, `.encountered-hosts.json` and — worst —
|
|
616
|
+
// `<prefix>.gate.json`, a GATE VERDICT, each replaced by a report-shaped placeholder. A run whose
|
|
617
|
+
// report sink is armed was silently destroying the verdict sink's document beside it.
|
|
618
|
+
//
|
|
619
|
+
// THE MECHANISM WAS WRONG, NOT JUST THE LIST. This project's denylist-over-allowlist rule is about
|
|
620
|
+
// CLASSIFYING, where over-approximating is the safe direction. For a WRITER it inverts:
|
|
621
|
+
// over-approximating destroys a file. §2.2 can call an incomplete denylist "loud" because an
|
|
622
|
+
// unregistered suffix there merely falls back into a candidate set and gets disclosed; in an armer it
|
|
623
|
+
// is silent and destructive. So this writes only what it recognises as its own report — a JSON object
|
|
624
|
+
// carrying both a `candor` envelope and `functions` — which needs no list and cannot drift as the
|
|
625
|
+
// reserved family grows. (The placeholder itself carries both, so an already-armed file re-arms.)
|
|
626
|
+
let doc;
|
|
627
|
+
try { doc = JSON.parse(fs.readFileSync(full, "utf8")); } catch { continue; }
|
|
628
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.candor === undefined || doc.functions === undefined) continue;
|
|
629
|
+
// Remember the bytes BEFORE overwriting, so a run that completes can hand back anything it turned
|
|
630
|
+
// out not to own (see disarmUnwrittenOutReports).
|
|
631
|
+
let prev;
|
|
632
|
+
try { prev = fs.readFileSync(full); } catch { continue; }
|
|
633
|
+
// THE SIDECARS FOLLOW ONLY IF THE REPORT ACTUALLY ARMED (raised on candor-java's arm of this rung;
|
|
634
|
+
// the rust reference deleted them unconditionally in f23a993 and was corrected by ff8cc09). A write that
|
|
635
|
+
// FAILS leaves the PREVIOUS run's report on disk; removing its sidecars there would produce a
|
|
636
|
+
// stale-report/no-callgraph pair no run has ever written — strictly worse than the pre-rung state,
|
|
637
|
+
// because the half that survives is the one a gate reads while the half that made `callers`
|
|
638
|
+
// answerable is gone. A pair degrades together or not at all.
|
|
639
|
+
let armed = false;
|
|
640
|
+
try { writeSinkAtomic(full, OUT_ARM_DOC); outArmed.push([full, prev]); armed = true; }
|
|
641
|
+
catch (e) {
|
|
642
|
+
console.error(`candor-ts: could not arm the report ${full} fail-closed (${e.message}) — leaving it `
|
|
643
|
+
+ `AND its §2.2 sidecars exactly as they are; if this run does not complete, that path may still `
|
|
644
|
+
+ `hold a PREVIOUS run's report`);
|
|
645
|
+
}
|
|
646
|
+
if (armed) removeArmedReportSidecars(full, prefix, inputs);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// HAND BACK WHAT THIS RUN TURNED OUT NOT TO OWN. Arming cannot know at parse time which files the run
|
|
651
|
+
// will write, so it arms the whole previous set. Once the run has finished writing, a file STILL holding
|
|
652
|
+
// the placeholder is one the run never claimed — a leftover from a package that is no longer in the scan.
|
|
653
|
+
//
|
|
654
|
+
// THAT IS NOT AN INCOMPLETE ANALYSIS, AND LEAVING THE PLACEHOLDER THERE ASSERTS ONE. The rust reference's
|
|
655
|
+
// first version kept them and described it as closing the orphaned-report defect for free; it did not. A
|
|
656
|
+
// placeholder's non-empty `unanalyzed` is the ⟨0.21⟩ incomplete-analysis trigger, so a COMPLETE scan
|
|
657
|
+
// began refusing with exit 2 and went on refusing until someone deleted the leftover by hand. The run did
|
|
658
|
+
// not fail to analyze that package; the package is not there. Claiming an incompleteness the run never
|
|
659
|
+
// experienced is the mirror of the staleness this rung exists to close.
|
|
660
|
+
//
|
|
661
|
+
// So the previous bytes go back and THE ORPHAN IS LEFT EXACTLY AS FOUND — still an open defect (a report
|
|
662
|
+
// for code that is gone still reaches a gate over the prefix), deliberately: it is pre-existing, it has
|
|
663
|
+
// its own wire question (delete it? mark it not-in-scan? a prefix can legitimately be shared), and
|
|
664
|
+
// resolving it inside a staleness fix would be deciding it by accident. Deleting the placeholder instead
|
|
665
|
+
// of restoring is rejected for §3.3.1's own reason: a consumer treating a missing file as "nothing to
|
|
666
|
+
// report" fails open by another route.
|
|
667
|
+
const disarmUnwrittenOutReports = () => {
|
|
668
|
+
const armed = Buffer.from(OUT_ARM_DOC);
|
|
669
|
+
for (const [file, prev] of outArmed) {
|
|
670
|
+
let now;
|
|
671
|
+
try { now = fs.readFileSync(file); } catch { continue; }
|
|
672
|
+
if (!now.equals(armed)) continue; // this run rewrote it — a real report
|
|
673
|
+
try { writeSinkAtomic(file, prev); }
|
|
674
|
+
catch (e) {
|
|
675
|
+
console.error(`candor-ts: could not restore ${file}, which this run armed but did not write `
|
|
676
|
+
+ `(${e.message}) — it still holds the fail-closed placeholder`);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
// ⟨0.28⟩ …AND THIS REPORT'S SIDECARS COME BACK WITH IT. A report the run turned out not to own is an
|
|
680
|
+
// ORPHAN, left exactly as found — and "as found" included its sidecars. Handing back the report while
|
|
681
|
+
// leaving them deleted is a THIRD state neither the pre-run tree nor the armed tree ever had: it
|
|
682
|
+
// degrades every `callers`/`whatif` answer over that package to the absence arm with nothing on disk
|
|
683
|
+
// saying why. This engine really does reach here (unlike candor-java, which arms exactly the one path
|
|
684
|
+
// the operator named): the armer arms the whole PREVIOUS set under the prefix, and this run writes
|
|
685
|
+
// only `<prefix>.json`, so any other report the previous run left there is armed and handed back.
|
|
686
|
+
for (const [owner, side, sprev] of outArmedSidecars) {
|
|
687
|
+
if (owner !== file || fs.existsSync(side)) continue; // not this report's, or the run rewrote it
|
|
688
|
+
try { writeSinkAtomic(side, sprev); }
|
|
689
|
+
catch (e) {
|
|
690
|
+
console.error(`candor-ts: could not restore ${side}, the §2.2 sidecar of ${file}, which this run `
|
|
691
|
+
+ `armed but did not write (${e.message}) — that report is now missing a sidecar it had before `
|
|
692
|
+
+ `this run started`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
{
|
|
699
|
+
const { gate, policy, target: preTarget, out: preOut } = preScan(argv);
|
|
700
|
+
// ⟨0.28⟩ The DUPLICATE case is decided below, and the single-sink guards here must not pre-empt it:
|
|
701
|
+
// they act on `gate` alone — the LAST sink — so `--gate-json - --gate-json <the policy>` exited on the
|
|
702
|
+
// policy before the STREAM could be told anything (measured: exit 2, stdout zero bytes).
|
|
703
|
+
const _named0 = allGateSinks(argv);
|
|
704
|
+
const _distinct0 = [];
|
|
705
|
+
for (const g of _named0) {
|
|
706
|
+
if (!_distinct0.some((k) => k === g || (k !== "-" && g !== "-" && sameArtifact(k, g)))) _distinct0.push(g);
|
|
707
|
+
}
|
|
708
|
+
const singleSink = _distinct0.length < 2;
|
|
709
|
+
// ⟨0.28⟩ `--json` BESIDE `--gate-json -`: a report and a verdict cannot share one stream. Decided here,
|
|
710
|
+
// in the pre-pass, so the refusal is stdout's ONLY content — refusing after the report has gone out is
|
|
711
|
+
// the defect, not the fix. On this engine `--json` is stdout-only, so the sink alone decides it.
|
|
712
|
+
if (gate === "-" && argv.includes("--json")) {
|
|
713
|
+
console.error("candor-ts: --json and --gate-json - both name STDOUT — refusing (exit 2). `--json` "
|
|
714
|
+
+ "writes the REPORT there and `--gate-json -` the VERDICT, so this would put two JSON documents on "
|
|
715
|
+
+ "one stream and a consumer parsing it gets neither. Send one to a file, or run the scan twice.");
|
|
716
|
+
console.log(JSON.stringify(refusalVerdict(SPEC_VERSION,
|
|
717
|
+
"--json and --gate-json - both name stdout — a report and a verdict cannot share one stream", null), null, 1));
|
|
718
|
+
process.exit(2);
|
|
719
|
+
}
|
|
720
|
+
for (const [other, flag] of (gate && singleSink ? runInputs(preTarget, policy) : [])) {
|
|
721
|
+
if (gate && sameArtifact(gate, other)) {
|
|
722
|
+
console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
|
|
723
|
+
+ `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy `
|
|
724
|
+
+ `and then gate on the wreckage. Nothing was written; give the verdict its own path.`);
|
|
725
|
+
refuseEarlyToStream(`--gate-json ${gate} names the same file as ${flag} ${other}`);
|
|
726
|
+
process.exit(2);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
// ⟨0.28⟩ …AND THE TARGET EXPANDS TO THE FILES THE RUN WILL PARSE (see `sinkIsParsedSourceUnderTarget`).
|
|
730
|
+
if (gate && singleSink && sinkIsParsedSourceUnderTarget(gate, preTarget)) {
|
|
731
|
+
console.error(`candor-ts: --gate-json ${gate} lies UNDER the scan target ${preTarget} and bears an `
|
|
732
|
+
+ `extension this engine parses — refusing (exit 2). Nothing was written there. The verdict is `
|
|
733
|
+
+ `armed at parse time, BEFORE the file walk, so this would replace a source file this run is `
|
|
734
|
+
+ `about to read and then scan the wreckage. A non-source sink under the target `
|
|
735
|
+
+ `(${path.join(targetContainmentRoot(preTarget) ?? ".", ".candor", "verdict.json")}, say) is the `
|
|
736
|
+
+ `recommended layout and stays permitted.`);
|
|
737
|
+
refuseEarlyToStream(`--gate-json ${gate} is a source file under the scan target ${preTarget}`);
|
|
738
|
+
process.exit(2);
|
|
739
|
+
}
|
|
740
|
+
// `.candor/config` is never a verdict sink, wherever it is. The per-input checks above can only name
|
|
741
|
+
// inputs the run was TOLD about; the config is DISCOVERED by walking up from the target, so by the
|
|
742
|
+
// time its path is known the arming has already destroyed it. A check on the SHAPE needs no
|
|
743
|
+
// discovery, so it runs before the first write and covers a config found anywhere up the tree.
|
|
744
|
+
if (gate && singleSink && gate !== "-") {
|
|
745
|
+
const abs = path.resolve(gate);
|
|
746
|
+
if (path.basename(abs) === "config" && path.basename(path.dirname(abs)) === ".candor") {
|
|
747
|
+
console.error(`candor-ts: --gate-json ${gate} is a .candor/config — refusing (exit 2). The verdict `
|
|
748
|
+
+ `is armed before the config is read, so this would destroy the config that configures this `
|
|
749
|
+
+ `run. Nothing was written; give the verdict its own path.`);
|
|
750
|
+
refuseEarlyToStream(`--gate-json ${gate} is a .candor/config`); // ⟨0.28⟩ report stream too
|
|
751
|
+
process.exit(2);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (gate && singleSink && sameArtifact(gate, process.env.CANDOR_CONFIG)) {
|
|
755
|
+
console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
|
|
756
|
+
refuseEarlyToStream(`--gate-json ${gate} names the same file as CANDOR_CONFIG`); // ⟨0.28⟩
|
|
757
|
+
process.exit(2);
|
|
758
|
+
}
|
|
759
|
+
// ⟨0.28⟩ A REPEATED `--gate-json` IS REFUSED, AND EVERY PATH NAMED GETS THE REFUSAL. Placed after the
|
|
760
|
+
// input-collision guards above and before arming: a sink that is an INPUT is refused having written
|
|
761
|
+
// nothing, and that exemption outranks this one — this write must not be the thing that destroys a
|
|
762
|
+
// policy. Two spellings of one path are ONE sink (the same artifact rule), so `--gate-json P
|
|
763
|
+
// --gate-json ./P` from P's own directory is not a duplicate.
|
|
764
|
+
const named = allGateSinks(argv);
|
|
765
|
+
const distinct = [];
|
|
766
|
+
for (const g of named) {
|
|
767
|
+
if (!distinct.some((k) => k === g || (k !== "-" && g !== "-" && sameArtifact(k, g)))) distinct.push(g);
|
|
768
|
+
}
|
|
769
|
+
// EVERY named sink gets the input checks, not just the one the parse honours. The first draft checked
|
|
770
|
+
// only `gate` (the last), so with `--policy P --gate-json P --gate-json B` the guard never saw P and
|
|
771
|
+
// this refusal DESTROYED the policy — measured against the other three engines, which kept it. The
|
|
772
|
+
// input exemption outranks this refusal: a sink that is an input is refused having written nothing.
|
|
773
|
+
// ⟨0.28⟩ THE INPUT EXEMPTION COVERS THE PATH, NOT THE RUN. Refusing the whole run on the first
|
|
774
|
+
// offending sink left the OTHER named sink holding whatever it held — measured: exit 2, the policy
|
|
775
|
+
// correctly intact, and an innocent sink still publishing a previous run's `{"ok": true}` to its
|
|
776
|
+
// reader. The offending path gets nothing; every other one gets the refusal.
|
|
777
|
+
const offending = new Set();
|
|
778
|
+
if (distinct.length > 1) {
|
|
779
|
+
for (const g of distinct) {
|
|
780
|
+
let bad = false;
|
|
781
|
+
for (const [other, flag] of runInputs(preTarget, policy)) {
|
|
782
|
+
if (sameArtifact(g, other)) {
|
|
783
|
+
console.error(`candor-ts: --gate-json ${g} names the SAME FILE as ${flag} ${other} — refusing `
|
|
784
|
+
+ `(exit 2). Nothing was written there.`);
|
|
785
|
+
bad = true;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (g !== "-") {
|
|
789
|
+
const abs = path.resolve(g);
|
|
790
|
+
if (path.basename(abs) === "config" && path.basename(path.dirname(abs)) === ".candor") {
|
|
791
|
+
console.error(`candor-ts: --gate-json ${g} is a .candor/config — refusing (exit 2). Nothing was written there.`);
|
|
792
|
+
bad = true;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (sameArtifact(g, process.env.CANDOR_CONFIG)) {
|
|
796
|
+
console.error(`candor-ts: --gate-json ${g} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
|
|
797
|
+
bad = true;
|
|
798
|
+
}
|
|
799
|
+
// ⟨0.28⟩ THE SAME PREDICATE ON THE DUPLICATE ROUTE. Asked here as well as above so a SECOND
|
|
800
|
+
// `--gate-json` cannot smuggle the duplicate-refusal document over source the single-sink route
|
|
801
|
+
// refuses to touch — this rung has paid six times for one rule with two spellings. The path is
|
|
802
|
+
// exempt (nothing written there); every innocent sibling sink still gets the refusal.
|
|
803
|
+
if (sinkIsParsedSourceUnderTarget(g, preTarget)) {
|
|
804
|
+
console.error(`candor-ts: --gate-json ${g} lies UNDER the scan target ${preTarget} and bears an `
|
|
805
|
+
+ `extension this engine parses — refusing (exit 2). Nothing was written there.`);
|
|
806
|
+
bad = true;
|
|
807
|
+
}
|
|
808
|
+
if (bad) offending.add(g);
|
|
809
|
+
}
|
|
810
|
+
if (offending.size === distinct.length) {
|
|
811
|
+
refuseEarlyToStream(`every named --gate-json path collides with an input`); // ⟨0.28⟩ report stream
|
|
812
|
+
process.exit(2);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (distinct.length > 1) {
|
|
816
|
+
const list = distinct.join(", ");
|
|
817
|
+
console.error(`candor-ts: --gate-json given more than once (${list}) — refusing (exit 2). A gate `
|
|
818
|
+
+ `publishes ONE verdict. Naming two sinks says where it goes twice, and the reader of the path `
|
|
819
|
+
+ `that loses cannot tell it lost. Name one, or run the gate twice.`);
|
|
820
|
+
const doc = JSON.stringify(refusalVerdict(SPEC_VERSION,
|
|
821
|
+
`--gate-json was given more than once (${list}) — a run publishes one verdict to one sink`, null), null, 1);
|
|
822
|
+
for (const g of distinct) {
|
|
823
|
+
if (offending.has(g)) continue; // the exemption is scoped to this path
|
|
824
|
+
if (g === "-") console.log(doc);
|
|
825
|
+
else try { writeSinkAtomic(g, doc + "\n"); }
|
|
826
|
+
catch (e) { console.error(`candor-ts: could not write the refusal to --gate-json ${g} (${e.message})`); }
|
|
827
|
+
}
|
|
828
|
+
// ⟨0.28⟩ report stream: only fire the helper when stdout wasn't ALREADY claimed by a `--gate-json -`
|
|
829
|
+
// verdict written in the loop above — the helper would otherwise write a second document to stdout
|
|
830
|
+
// (its verdict arm keys on `preGateSink === "-"` and doesn't know one was just printed). When `-`
|
|
831
|
+
// isn't in the list, refuseEarlyToStream's report arm handles the `--json` case; when it is, the
|
|
832
|
+
// verdict is already there and `--json` was refused earlier (line 333), so nothing more is owed.
|
|
833
|
+
if (!distinct.includes("-")) refuseEarlyToStream(`--gate-json was given more than once (${list})`);
|
|
834
|
+
process.exit(2);
|
|
835
|
+
}
|
|
836
|
+
if (gate && gate !== "-") armGateJsonFailClosed(gate);
|
|
837
|
+
// ⟨0.28⟩ …AND ARM THE REPORT SET, still before the arg loop below can exit on an unknown flag.
|
|
838
|
+
//
|
|
839
|
+
// **ONLY AN EXPLICITLY NAMED `--out`, NEVER THE DEFAULT PREFIX.** The first version of this armed the
|
|
840
|
+
// default `<target>/.candor/report` too, reasoning that an operator who passes no `--out` still has
|
|
841
|
+
// yesterday's report there to go stale. That is right about staleness and wrong about OWNERSHIP, and
|
|
842
|
+
// the difference DESTROYS DATA: measured, `scan.mjs <repo> --zzz-not-a-flag` overwrote a COMMITTED
|
|
843
|
+
// `.candor/report.json` with the placeholder — and committed reports and baselines are the pattern
|
|
844
|
+
// this project recommends and ships in CI. A run that dies in argv parsing was never going to write
|
|
845
|
+
// there, and it had not been told it owned that path. (It bit for real: this engine's first version
|
|
846
|
+
// left candor-rust's committed report dirty while running a conformance probe.)
|
|
847
|
+
//
|
|
848
|
+
// ⟨0.27⟩'s arming rule never had to face this because `--gate-json` has no default — every verdict
|
|
849
|
+
// sink is NAMED. So "arm at the instant the sink is known" presumes a sink the operator named, and
|
|
850
|
+
// that presumption is explicit here: with `--out p` the operator has declared p is this run's output
|
|
851
|
+
// and arming is right even when p is checked in; with no flag there is no such declaration. The legacy
|
|
852
|
+
// positional prefix (`scan.mjs f.ts out`) is left alone for the same reason it was never armed here.
|
|
853
|
+
//
|
|
854
|
+
// `--json` publishes the report to STDOUT and writes no files at all, so there is no file sink to arm
|
|
855
|
+
// in that form — it is the stream rule above, and arming under it would rewrite files this run is
|
|
856
|
+
// never going to touch.
|
|
857
|
+
//
|
|
858
|
+
// ⟨0.28⟩ **AND A REPEATED `--out` IS THE SAME RULE — refused at exit 2, with the fail-closed report
|
|
859
|
+
// written to EVERY prefix named.** `--out A --out B` says where the reports go, twice; the two
|
|
860
|
+
// statements cannot both be honoured. MEASURED here 2026-08-12 before this: last-wins at exit 0, with
|
|
861
|
+
// `A.json` byte-identical to the previous good run — a stale green whose reader has no way to learn it
|
|
862
|
+
// lost, and a `gate --report A` over it answers from a scan that never ran.
|
|
863
|
+
//
|
|
864
|
+
// "The fail-closed report written to every prefix named" means, under THIS sink's own arming rules,
|
|
865
|
+
// ARMING each prefix: the set at risk is the one the PREVIOUS run left there, which is what
|
|
866
|
+
// `armOutPrefixFailClosed` rewrites to the ⟨0.21⟩ Row-1 no-claim shape (asking the input exemption
|
|
867
|
+
// first, per file, and taking the §2.2 sidecars with each report). The run exits before scanning, so
|
|
868
|
+
// `disarmUnwrittenOutReports` never runs and the placeholders STAND — which is the fail-closed reading
|
|
869
|
+
// a run that scanned nothing is entitled to.
|
|
870
|
+
//
|
|
871
|
+
// AFTER the gate-sink arming above, so a `--gate-json` file sink already holds its armed document and
|
|
872
|
+
// `refuseEarlyToStream` covers the `-` and `--json` streams. And skipped under `--json` for the same
|
|
873
|
+
// reason the single-prefix arm below is: that form writes no files at all, so arming would rewrite
|
|
874
|
+
// files this run was never going to touch — the refusal itself still fires.
|
|
875
|
+
const namedOuts = distinctOutPrefixes(allOutPrefixes(argv));
|
|
876
|
+
if (namedOuts.length > 1) {
|
|
877
|
+
const list = namedOuts.join(", ");
|
|
878
|
+
console.error(`candor-ts: --out given more than once (${list}) — refusing (exit 2). A run writes ONE `
|
|
879
|
+
+ `report set to ONE prefix. Naming two says where the reports go twice, and the reader of the `
|
|
880
|
+
+ `prefix that loses cannot tell it lost — it goes on holding a previous run's reports as if they `
|
|
881
|
+
+ `were current${preWantJson ? "" : ", so every prefix named now holds the fail-closed empty in "
|
|
882
|
+
+ "place of them"}. Name one, or run the scan twice.`);
|
|
883
|
+
if (!preWantJson) {
|
|
884
|
+
const inputs = runInputs(preTarget, policy);
|
|
885
|
+
for (const p of namedOuts) armOutPrefixFailClosed(p, inputs);
|
|
886
|
+
}
|
|
887
|
+
refuseEarlyToStream(`--out was given more than once (${list}) — a run writes one report set to one prefix`);
|
|
888
|
+
process.exit(2);
|
|
889
|
+
}
|
|
890
|
+
if (preOut && !preWantJson) armOutPrefixFailClosed(preOut, runInputs(preTarget, policy));
|
|
891
|
+
}
|
|
122
892
|
let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, gateJsonPath = null, allowJs = false, wantAgents = false, wantJson = false, wantWorkspace = false, wantDepInits = false;
|
|
123
893
|
for (let i = 0; i < argv.length; i++) {
|
|
124
894
|
const a = argv[i];
|
|
@@ -132,7 +902,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
132
902
|
else if (a === "--dep-inits") wantDepInits = true;
|
|
133
903
|
else if (a === "--out" || a === "--policy" || a === "--gate-json") {
|
|
134
904
|
const v = argv[i + 1];
|
|
135
|
-
if (v === undefined || v.startsWith("--")) {
|
|
905
|
+
if (v === undefined || v.startsWith("--")) {
|
|
906
|
+
console.error(`candor-ts: ${a} requires a value (${usage})`);
|
|
907
|
+
refuseEarlyToStream(`${a} requires a value`);
|
|
908
|
+
process.exit(2);
|
|
909
|
+
}
|
|
136
910
|
if (a === "--out") outPrefix = v; else if (a === "--policy") policyPath = v; else gateJsonPath = v;
|
|
137
911
|
i++;
|
|
138
912
|
}
|
|
@@ -140,13 +914,27 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
140
914
|
// (SPEC §6.2/§7). `-h`/`-V`/`--help`/`--version` are print-and-exit modes consumed above, so by here
|
|
141
915
|
// a single-dash token (`-x`, the typo `-policy`) can only be a mistake; treating it as the scan
|
|
142
916
|
// target would silently scan the wrong thing.
|
|
143
|
-
else if (a.startsWith("-")) {
|
|
917
|
+
else if (a.startsWith("-")) {
|
|
918
|
+
console.error(`candor-ts: unknown flag ${a} (${usage})`);
|
|
919
|
+
// ⟨0.27⟩ §3.3 names an unknown flag as a broken-gate-config exit-2 cause; the stream sink gets the
|
|
920
|
+
// refusal document too (see refuseEarlyToStream — the file sink is already armed).
|
|
921
|
+
refuseEarlyToStream(`unknown flag ${a}`);
|
|
922
|
+
process.exit(2);
|
|
923
|
+
}
|
|
144
924
|
else if (target === null) target = a;
|
|
145
925
|
else if (outPrefix === null) outPrefix = a; // legacy positional prefix
|
|
146
|
-
else {
|
|
926
|
+
else {
|
|
927
|
+
console.error(`candor-ts: unexpected extra argument ${a} (${usage})`);
|
|
928
|
+
refuseEarlyToStream(`unexpected extra argument ${a}`);
|
|
929
|
+
process.exit(2);
|
|
930
|
+
}
|
|
147
931
|
}
|
|
148
932
|
if (wantAgents) { printAgents(); process.exit(0); }
|
|
149
|
-
if (target === null) {
|
|
933
|
+
if (target === null) {
|
|
934
|
+
console.error(usage);
|
|
935
|
+
refuseEarlyToStream("no scan target");
|
|
936
|
+
process.exit(2);
|
|
937
|
+
}
|
|
150
938
|
|
|
151
939
|
// ---- .candor/config (candor-spec §config; the checked-in alternative to the CANDOR_* env vars) -----
|
|
152
940
|
// Discovery is anchored to the SCAN TARGET (walk up from the target dir to the repo root's
|
|
@@ -156,14 +944,15 @@ if (target === null) { console.error(usage); process.exit(2); }
|
|
|
156
944
|
// never vanish silently (the §6.2 unreadable-policy posture). Only genuine absence is an empty config.
|
|
157
945
|
// Keys are the shared FAMILY vocabulary; a key OUTSIDE it warns (typo protection: a misspelt `policy`
|
|
158
946
|
// must not silently drop the gate).
|
|
159
|
-
|
|
947
|
+
// ⟨0.27⟩ `engine` (SPEC §3.4) is RECOGNIZED and IMPLEMENTED here — see enforceEnginePin. It must be in
|
|
948
|
+
// BOTH sets: missing from the vocabulary it is reported as unknown, and missing from IMPLEMENTED it is
|
|
949
|
+
// disclosed as inert — both tell an operator their pin was ignored while the engine is enforcing it.
|
|
160
950
|
// The subset this engine actually wires to a mode — `policy` (the gate), `baseline` (AS-EFF-005),
|
|
161
951
|
// `deps` (the cross-package report chain) and `unknown-ratchet` (the baseline guard's opt-in). The rest
|
|
162
952
|
// of the vocabulary is spec-inert HERE: it drives other engines' gates. But a checked-in enforcement key
|
|
163
953
|
// that silently does nothing is a DECLARED-GATE-SILENTLY-OFF — the reader believes the gate is on — so
|
|
164
954
|
// an inert recognized key DISCLOSES loudly (stderr only; verdict/report/exit code untouched) instead of
|
|
165
955
|
// 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
956
|
// The ANCHOR a config file's RELATIVE path values (policy/deps) resolve against: the repo the config
|
|
168
957
|
// belongs to — the parent of its `.candor/` directory (the standard layout; candor-init scaffolds
|
|
169
958
|
// `policy arch.policy` meaning the repo root's), else the config file's own directory. NEVER the
|
|
@@ -174,28 +963,139 @@ function configAnchor(file) {
|
|
|
174
963
|
const dir = path.dirname(path.resolve(file));
|
|
175
964
|
return path.basename(dir) === ".candor" ? path.dirname(dir) : dir;
|
|
176
965
|
}
|
|
177
|
-
|
|
966
|
+
// ⟨0.27⟩ SPEC §3.4 `engine` — THE ENGINE↔BASELINE COUPLING, enforced instead of hoped for.
|
|
967
|
+
//
|
|
968
|
+
// The committed baseline is a snapshot of what ONE engine build reported, and an engine swap is
|
|
969
|
+
// baseline-invalidating. What a PIN adds over the provenance checks already in place is that it is
|
|
970
|
+
// DECLARATIVE — a build id is a hash nobody can write down, so the intended version lived in CI config,
|
|
971
|
+
// decoupled from the baseline it is married to. It also tells tooling which engine to FETCH, and it
|
|
972
|
+
// reaches a run with NO baseline configured at all.
|
|
973
|
+
//
|
|
974
|
+
// TWO OF THE FIVE VERDICTS MUST NOT CHANGE THE EXIT CODE: an ABSENT pin (the key is opt-in by
|
|
975
|
+
// construction) and an UNDETERMINED one, where §3.1's unanswerable-condition rule applies — disclosed,
|
|
976
|
+
// never scored, INCLUDING as satisfied. Exit 2 on a mismatch, never 1: unevaluable, not violating.
|
|
977
|
+
//
|
|
978
|
+
// A pin qualified for another implementation is not ours to check — one config serves the whole family,
|
|
979
|
+
// and the family versions as a LADDER, so a bare version in a polyglot repo would fail whichever engine
|
|
980
|
+
// had not yet caught up.
|
|
981
|
+
const ENGINE_IMPLS = new Set(["java", "rust", "ts", "swift", "agents"]);
|
|
982
|
+
function enginePinFor(text, implName) {
|
|
983
|
+
let wild = null, qual = null, bad = false;
|
|
984
|
+
for (const rawLine of (text ?? "").split("\n")) {
|
|
985
|
+
const line = rawLine.split("#")[0].trim();
|
|
986
|
+
if (!line) continue;
|
|
987
|
+
const parts = line.split(/\s+/);
|
|
988
|
+
if (parts[0].toLowerCase() !== "engine") continue;
|
|
989
|
+
const rest = parts.slice(1);
|
|
990
|
+
// Two lines that DISAGREE about the same key are kept BOTH, so they cannot parse as a version and
|
|
991
|
+
// surface as malformed. One silently discarding the other is the failure this key exists to stop.
|
|
992
|
+
const slot = (cur, v) => (cur !== null && cur !== v ? `${cur} / ${v}` : v);
|
|
993
|
+
// 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.
|
|
994
|
+
if (rest.length && ENGINE_IMPLS.has(rest[0].toLowerCase())) {
|
|
995
|
+
if (rest[0].toLowerCase() === implName) { if (rest.length === 2) qual = slot(qual, rest[1]); else bad = true; }
|
|
996
|
+
continue; // another impl's line, whatever follows it
|
|
997
|
+
}
|
|
998
|
+
if (rest.length === 0) bad = true;
|
|
999
|
+
else if (rest.length === 1) wild = slot(wild, rest[0]);
|
|
1000
|
+
else bad = true;
|
|
1001
|
+
}
|
|
1002
|
+
if (bad) return "<unreadable>";
|
|
1003
|
+
// AN UNREADABLE UNQUALIFIED LINE IS NOT HIDDEN BY A QUALIFIED PIN. `qual ?? wild` returned the qualifi
|
|
1004
|
+
// ed value, so `engine garbage` beside a good qualified line passed SILENTLY here while candor-java exited
|
|
1005
|
+
// 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.
|
|
1006
|
+
if (wild !== null && normalizePinVersion(wild) === null) return wild;
|
|
1007
|
+
return qual ?? wild;
|
|
1008
|
+
}
|
|
1009
|
+
function normalizePinVersion(raw) {
|
|
1010
|
+
const s = String(raw ?? "").trim().replace(/^[vV]/, "");
|
|
1011
|
+
if (!/^\d+\.\d+(\.\d+)?$/.test(s)) return null;
|
|
1012
|
+
return s.split(".").length === 2 ? `${s}.0` : s;
|
|
1013
|
+
}
|
|
1014
|
+
function enforceEnginePin(targetPath) {
|
|
1015
|
+
const pin = enginePinFor(discoverConfigText(targetPath), "ts");
|
|
1016
|
+
if (pin === null || pin === undefined) return; // ABSENT
|
|
1017
|
+
const want = normalizePinVersion(pin);
|
|
1018
|
+
if (want === null) {
|
|
1019
|
+
console.error(`candor-ts: .candor/config has an \`engine\` line that is not an engine version.`);
|
|
1020
|
+
console.error(` want \`engine <version>\` (e.g. \`engine v${PKG_VERSION}\`) or \`engine <impl> <version>\``);
|
|
1021
|
+
console.error(` (e.g. \`engine ts v${PKG_VERSION}\`) for a repo scanned by more than one engine.`);
|
|
1022
|
+
console.error(` Failing (exit 2) rather than ignoring it: a pin that cannot be read is a`);
|
|
1023
|
+
console.error(` guard the operator believes is on.`);
|
|
1024
|
+
process.exit(2);
|
|
1025
|
+
}
|
|
1026
|
+
const running = normalizePinVersion(PKG_VERSION) ?? String(PKG_VERSION ?? "").trim();
|
|
1027
|
+
if (!running || running === "unknown") { // UNDETERMINED — disclose, never score
|
|
1028
|
+
console.error(`candor-ts: .candor/config pins engine ${pin}, and this build does not know its own release,`);
|
|
1029
|
+
console.error(` so the pin CANNOT be checked. Disclosed, not scored — neither passed nor failed.`);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (want === running) return; // MATCH
|
|
1033
|
+
console.error(`candor-ts: .candor/config pins engine ${pin} but this build is candor-ts ${PKG_VERSION}.`);
|
|
1034
|
+
console.error(` The pin and the committed baseline move together — a newer engine resolves more`);
|
|
1035
|
+
console.error(` dispatch, so its report is not comparable with a baseline the pinned engine wrote.`);
|
|
1036
|
+
console.error(` Either run the pinned engine, or update the pin and regenerate the baseline in the`);
|
|
1037
|
+
console.error(` same change. Exit 2 (unevaluable), not 1 — this is not a policy violation.`);
|
|
1038
|
+
// ONE call, not two: an insertion script matched both pin branches to this single exit, and the
|
|
1039
|
+
// duplicate put TWO documents on the stream — which parses as neither. The other branch (a build that
|
|
1040
|
+
// cannot determine its own release) RETURNS rather than exiting: disclosed, not scored, so no refusal
|
|
1041
|
+
// belongs there.
|
|
1042
|
+
refuseEarlyToStream(`.candor/config pins engine ${pin}, which this build does not satisfy`);
|
|
1043
|
+
process.exit(2);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// WHICH config file this run reads, with NO side effects (SPEC §3.4). Extracted so the §3.3.1 sink
|
|
1047
|
+
// guard asks the same question the loader answers instead of re-deriving the walk — a review took the
|
|
1048
|
+
// guard's own copy apart on exactly that divergence.
|
|
1049
|
+
function discoverConfigFile(targetPath) {
|
|
1050
|
+
const env = process.env.CANDOR_CONFIG;
|
|
1051
|
+
if (env) {
|
|
1052
|
+
try { return fs.statSync(env).isFile() ? env : null; } catch { return null; }
|
|
1053
|
+
}
|
|
1054
|
+
let dir = path.resolve(targetPath ?? ".");
|
|
1055
|
+
try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
|
|
1056
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
1057
|
+
const cand = path.join(d, ".candor", "config");
|
|
1058
|
+
if (fs.existsSync(cand)) return cand;
|
|
1059
|
+
if (path.dirname(d) === d) break; // filesystem root
|
|
1060
|
+
}
|
|
1061
|
+
return fs.existsSync(".candor/config") ? ".candor/config" : null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/// `lenient: true` THROWS where this would otherwise `process.exit(2)`.
|
|
1065
|
+
///
|
|
1066
|
+
/// The collision pre-pass needs the config's DECLARED input paths before the sink is armed, and it
|
|
1067
|
+
/// wrapped this call in a try under the comment "the real load refuses on its own terms" — which
|
|
1068
|
+
/// assumes the failure arrives as an exception. It does not: `process.exit` is not catchable, so an
|
|
1069
|
+
/// unreadable config killed the run INSIDE that try, before arming, leaving a pre-seeded green verdict
|
|
1070
|
+
/// intact at the file sink. SPEC §3.3's own words for that: "a refusal that writes nothing leaves the
|
|
1071
|
+
/// previous run's green document on disk." java answers the same input with `refused: true`.
|
|
1072
|
+
///
|
|
1073
|
+
/// Nothing is lost by leniency here. If the config cannot be read it declares no inputs anyone can
|
|
1074
|
+
/// name, so the collision check over them is vacuous; arming then proceeds and the REAL load refuses a
|
|
1075
|
+
/// moment later — now with the sink armed, so the refusal reaches it. A second parser was the
|
|
1076
|
+
/// alternative, and a second parser is a second set of holes.
|
|
1077
|
+
function loadCandorConfig(targetPath, { lenient = false } = {}) {
|
|
178
1078
|
let file = process.env.CANDOR_CONFIG ?? null;
|
|
179
1079
|
if (file !== null) {
|
|
180
1080
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
1081
|
+
if (lenient) throw new Error(`CANDOR_CONFIG set but ${file} is not a readable file`);
|
|
181
1082
|
console.error(`candor-ts: CANDOR_CONFIG set but ${file} is not a readable file — failing (exit 2)`);
|
|
1083
|
+
// The config is the EARLIEST exit-2 cause, and the one the stream sink is least likely to be
|
|
1084
|
+
// armed for — which is exactly why it was the last one still leaving stdout empty. Found by
|
|
1085
|
+
// PART 36 (b11), a row written before this line was.
|
|
1086
|
+
refuseEarlyToStream(`CANDOR_CONFIG set but ${file} is not a readable file`);
|
|
182
1087
|
process.exit(2);
|
|
183
1088
|
}
|
|
184
1089
|
} 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";
|
|
1090
|
+
file = discoverConfigFile(targetPath);
|
|
193
1091
|
if (file === null) return {};
|
|
194
1092
|
}
|
|
195
1093
|
let text;
|
|
196
1094
|
try { text = fs.readFileSync(file, "utf8"); }
|
|
197
1095
|
catch (e) {
|
|
1096
|
+
if (lenient) throw new Error(`config ${file} exists but could not be read (${e.message})`);
|
|
198
1097
|
console.error(`candor-ts: config ${file} exists but could not be read (${e.message}) — failing (exit 2)`);
|
|
1098
|
+
refuseEarlyToStream(`config ${file} exists but could not be read`);
|
|
199
1099
|
process.exit(2);
|
|
200
1100
|
}
|
|
201
1101
|
const cfg = {};
|
|
@@ -230,10 +1130,78 @@ function loadCandorConfig(targetPath) {
|
|
|
230
1130
|
const anchor = configAnchor(file);
|
|
231
1131
|
if (cfg.policy) cfg.policy = path.resolve(anchor, cfg.policy);
|
|
232
1132
|
if (cfg.baseline) cfg.baseline = path.resolve(anchor, cfg.baseline);
|
|
233
|
-
|
|
1133
|
+
// ASCII whitespace ONLY, like java and swift: these are PATHS, and JS `\s` includes U+00A0, so a dep
|
|
1134
|
+
// path containing a non-breaking space split into two halves that were then both "skipped" — a green
|
|
1135
|
+
// run with the dep silently unchained, where java and rust loaded it.
|
|
1136
|
+
if (cfg.deps) cfg.deps = cfg.deps.split(/[ \t:,]+/).filter(Boolean).map((t) => path.resolve(anchor, t)).join(":");
|
|
234
1137
|
return cfg;
|
|
235
1138
|
}
|
|
1139
|
+
// ⟨0.24⟩/⟨0.27⟩ ARM THE VERDICT FAIL-CLOSED. Every exit path then leaves a refusal behind unless the run
|
|
1140
|
+
// got far enough to replace it with a real verdict. A review found the pin refusal leaving the PREVIOUS
|
|
1141
|
+
// run's document on disk — a CI wrapper reading the artifact instead of the exit code then reports a pass
|
|
1142
|
+
// over a run that refused. candor-java's `armGateJson` is the model; the wording is about the RUN, not
|
|
1143
|
+
// about the code.
|
|
1144
|
+
//
|
|
1145
|
+
// A `function` declaration, not a `const`: it is CALLED from the pre-pass above the arg loop, and only a
|
|
1146
|
+
// hoisted declaration can be. The write is inlined rather than calling `writeAtomic` for the same reason
|
|
1147
|
+
// in reverse — that helper is a `const` declared ~4700 lines below, so calling it would be a
|
|
1148
|
+
// temporal-dead-zone throw.
|
|
1149
|
+
// ⟨0.28⟩ RESOLVE THE SINK TO ITS FINAL ARTIFACT BEFORE WRITING, and preserve the operator's layout.
|
|
1150
|
+
// `renameSync` REPLACES a symlink rather than following it, so an `artifacts/verdict.json` linked into a
|
|
1151
|
+
// shared directory kept a previous run's `{"ok": true}` while this run's document landed on the link — a
|
|
1152
|
+
// stale green with a single `--gate-json` and no operator mistake. And rename gives the destination a NEW
|
|
1153
|
+
// inode, so a multiply-linked target strands its other name with the previous document; there the write
|
|
1154
|
+
// goes in place, trading the atomicity window for not publishing a stale verdict at a name the operator
|
|
1155
|
+
// wired up. SPEC §3.3.1 states identity about ARTIFACTS; this family had it in the comparison only.
|
|
1156
|
+
function resolveSinkArtifact(p) {
|
|
1157
|
+
let cur = p;
|
|
1158
|
+
for (let i = 0; i < 32; i++) {
|
|
1159
|
+
let st;
|
|
1160
|
+
try { st = fs.lstatSync(cur); } catch { return cur; }
|
|
1161
|
+
if (!st.isSymbolicLink()) return cur;
|
|
1162
|
+
let t;
|
|
1163
|
+
try { t = fs.readlinkSync(cur); } catch { return cur; }
|
|
1164
|
+
cur = path.isAbsolute(t) ? t : path.join(path.dirname(cur), t);
|
|
1165
|
+
}
|
|
1166
|
+
return cur;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function writeSinkAtomic(p, text) {
|
|
1170
|
+
const target = resolveSinkArtifact(p);
|
|
1171
|
+
try {
|
|
1172
|
+
if (fs.statSync(target).nlink > 1) { fs.writeFileSync(target, text); return; }
|
|
1173
|
+
} catch { /* not there yet — the ordinary temp+rename path is right */ }
|
|
1174
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
1175
|
+
fs.writeFileSync(tmp, text);
|
|
1176
|
+
fs.renameSync(tmp, target);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function armGateJsonFailClosed(p) {
|
|
1180
|
+
try {
|
|
1181
|
+
writeSinkAtomic(p, JSON.stringify({
|
|
1182
|
+
spec: SPEC_VERSION, ok: false, refused: true,
|
|
1183
|
+
reason: "the gate did not complete — this document was written when the run STARTED and was never "
|
|
1184
|
+
+ "replaced by a verdict, so the run failed, crashed or was killed before it could decide. It is "
|
|
1185
|
+
+ "NOT a verdict about the code; see the run's stderr for the cause.",
|
|
1186
|
+
}, null, 1) + "\n");
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
console.error(`candor-ts: could not arm --gate-json ${p} fail-closed (${e.message}) — if this run `
|
|
1189
|
+
+ `does not complete, that path may still hold a PREVIOUS run's verdict`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// MOVED ABOVE THE CONFIG LOAD. `loadCandorConfig` is ITSELF an exit-2 cause (an unusable
|
|
1193
|
+
// CANDOR_CONFIG, or a committed `.candor/config` that cannot be read), and arming after it left a
|
|
1194
|
+
// config refusal exiting 2 with the PREVIOUS run's green still on disk — while the comment below
|
|
1195
|
+
// said "BEFORE ANYTHING THAT CAN EXIT". The rule only holds if the arming really is first.
|
|
1196
|
+
// ⟨0.24⟩ ARM THE VERDICT FAIL-CLOSED BEFORE ANYTHING THAT CAN EXIT. A review found the pin refusal
|
|
1197
|
+
// leaving the PREVIOUS run's `--gate-json` document on disk — a CI wrapper reading the artifact instead
|
|
1198
|
+
// of the exit code then reports a pass over a run that refused. Arming at the START makes this a CLASS
|
|
1199
|
+
// fix: every exit path leaves a refusal unless the run got far enough to replace it. candor-java's
|
|
1200
|
+
// `armGateJson` is the model, and the wording is about the RUN, not about the code.
|
|
1201
|
+
// (armed by the pre-pass above, before the arg loop — see SPEC §3.3.1 ⟨0.27⟩. Arming HERE was still
|
|
1202
|
+
// after the loop's unknown-flag exit, so the contract depended on argv order.)
|
|
236
1203
|
const candorConfig = loadCandorConfig(target);
|
|
1204
|
+
enforceEnginePin(target); // ⟨0.27⟩ §3.4 — AFTER the arming, so its exit 2 cannot leave a stale verdict
|
|
237
1205
|
// precedence: the --policy flag / CANDOR_POLICY env already populated policyPath; the config is the floor.
|
|
238
1206
|
// A BARE `policy` line ("" value) means configured-with-empty → the unreadable-policy path fails loud.
|
|
239
1207
|
if (policyPath === null && candorConfig.policy !== undefined) policyPath = candorConfig.policy;
|
|
@@ -241,7 +1209,12 @@ if (policyPath === null && candorConfig.policy !== undefined) policyPath = cando
|
|
|
241
1209
|
// (path-valued keys are already resolved against the config's anchor above). No CLI flag — matching
|
|
242
1210
|
// candor-java, the reference engine (env/config only). A BARE `baseline` line ("") fails loud below.
|
|
243
1211
|
let baselinePath = process.env.CANDOR_BASELINE ?? null;
|
|
244
|
-
|
|
1212
|
+
// WHICH SOURCE supplied it decides what a MISSING file means: `CANDOR_BASELINE` is set unconditionally
|
|
1213
|
+
// by the adopt workflow, so an absent path there is "the ratchet is not adopted yet"; a checked-in
|
|
1214
|
+
// `baseline` line DECLARES this repo has one, so an absent path there was deleted or never committed —
|
|
1215
|
+
// and the guard passing green over it is a gate that silently stopped gating.
|
|
1216
|
+
let baselineFromConfig = false;
|
|
1217
|
+
if (baselinePath === null && candorConfig.baseline !== undefined) { baselinePath = candorConfig.baseline; baselineFromConfig = true; }
|
|
245
1218
|
// ⟨unknown-ratchet⟩ OPT-IN (config `unknown-ratchet` / CANDOR_UNKNOWN_RATCHET, default OFF): flip an
|
|
246
1219
|
// Unknown-ONLY gain vs the baseline from advisory to an AS-EFF-005 failure (exit 1). Env-override truthy
|
|
247
1220
|
// semantics mirror candor-java's Config.flag exactly — env var PRESENCE means on (env can't express off);
|
|
@@ -290,9 +1263,20 @@ function fromTsconfig(cfgPath, baseDir) {
|
|
|
290
1263
|
return names.filter((f) => !isTestPath(path.relative(baseDir, f)));
|
|
291
1264
|
}
|
|
292
1265
|
const stat = fs.existsSync(target) ? fs.statSync(target) : null;
|
|
293
|
-
if (!stat) {
|
|
1266
|
+
if (!stat) {
|
|
1267
|
+
console.error(`candor-ts: no such path: ${target}`);
|
|
1268
|
+
// The SAME two lines as every other early exit in this file. `refuseEarlyToStream` WRITES AND
|
|
1269
|
+
// RETURNS — it is not a `Never`, and calling it INSTEAD of the exit lets the run continue past its
|
|
1270
|
+
// own refusal (measured, while getting this wrong: an unreadable dep wrote its refusal and then
|
|
1271
|
+
// exited 0). rust's equivalent is typed `-> !`, which is why the same slip could not happen there.
|
|
1272
|
+
refuseEarlyToStream(`no such path: ${target}`);
|
|
1273
|
+
process.exit(2);
|
|
1274
|
+
}
|
|
1275
|
+
let usedTsconfig = null; // the tsconfig this run actually read, so a later disclosure can name the
|
|
1276
|
+
// cause that APPLIES rather than the one that usually does.
|
|
294
1277
|
if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
295
1278
|
rootDir = path.dirname(path.resolve(target));
|
|
1279
|
+
usedTsconfig = path.resolve(target);
|
|
296
1280
|
fileNames = fromTsconfig(path.resolve(target), rootDir);
|
|
297
1281
|
} else if (stat.isFile()) {
|
|
298
1282
|
rootDir = path.dirname(path.resolve(target));
|
|
@@ -301,6 +1285,7 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
|
301
1285
|
rootDir = path.resolve(target);
|
|
302
1286
|
const tsconfig = path.join(rootDir, "tsconfig.json");
|
|
303
1287
|
if (fs.existsSync(tsconfig) && !allowJs) {
|
|
1288
|
+
usedTsconfig = tsconfig;
|
|
304
1289
|
fileNames = fromTsconfig(tsconfig, rootDir);
|
|
305
1290
|
} else {
|
|
306
1291
|
fileNames = [];
|
|
@@ -315,7 +1300,13 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
|
315
1300
|
})(rootDir);
|
|
316
1301
|
}
|
|
317
1302
|
}
|
|
318
|
-
if (fileNames.length === 0) {
|
|
1303
|
+
if (fileNames.length === 0) {
|
|
1304
|
+
console.error(`candor-ts: no TypeScript sources under ${target}`);
|
|
1305
|
+
// An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
|
|
1306
|
+
// get nothing. §3.1 exempts no cause, and this one is easy to hit in CI (a path that moved).
|
|
1307
|
+
refuseEarlyToStream(`no TypeScript sources under ${target}`);
|
|
1308
|
+
process.exit(2);
|
|
1309
|
+
}
|
|
319
1310
|
// Builtin typings FALLBACK: the engine ships @types/node as its own dependency, so a target that
|
|
320
1311
|
// hasn't installed it still resolves node:fs/node:net/… (found by the first npx-distribution
|
|
321
1312
|
// probe: a bare fixture read Unknown for fs.readFileSync because nothing supplied the builtin
|
|
@@ -331,6 +1322,29 @@ if (!compilerOptions.typeRoots) {
|
|
|
331
1322
|
compilerOptions.typeRoots = roots;
|
|
332
1323
|
}
|
|
333
1324
|
if (!outPrefix) outPrefix = path.join(rootDir, ".candor", "report");
|
|
1325
|
+
// ⟨0.28⟩ THE REPORT SET REACHES ITS SINK BY A SECOND ROUTE, AND THE INPUT RULE MUST COVER BOTH. The armer
|
|
1326
|
+
// asks the input exemption of every file it arms, so `--out tsconfig` over a `tsconfig.json` TARGET was
|
|
1327
|
+
// correctly left unarmed — and then the FINAL `writeAtomic(`${outPrefix}.json`)` destroyed it anyway, at
|
|
1328
|
+
// exit 0, on a run that had already scanned it: "wrote 0 effectful functions … to tsconfig.json" over the
|
|
1329
|
+
// operator's own tsconfig. Measured while verifying the target registration covered both sinks; it covered
|
|
1330
|
+
// the arm and not the write. Asked HERE, where every route to the report set converges (`--out`, the
|
|
1331
|
+
// legacy positional prefix, and the default), of the same `runInputs`/`sameArtifact` every other sink
|
|
1332
|
+
// guard uses, over the exact file set this engine writes. Refused, not skipped: silently withholding one
|
|
1333
|
+
// file of a four-file report set would be a pair no run has ever written.
|
|
1334
|
+
if (!wantJson) {
|
|
1335
|
+
for (const suffix of ["", ".callgraph", ".locs", ".hierarchy"]) {
|
|
1336
|
+
const w = `${outPrefix}${suffix}.json`;
|
|
1337
|
+
for (const [other, label] of runInputs(target, policyPath)) {
|
|
1338
|
+
if (sameArtifact(w, other)) {
|
|
1339
|
+
console.error(`candor-ts: the report set at --out ${outPrefix} includes ${w}, which is the SAME `
|
|
1340
|
+
+ `FILE as ${label} ${other} — refusing (exit 2). Writing the report there would destroy an `
|
|
1341
|
+
+ `input of this run. Nothing was scanned; give the report set its own prefix.`);
|
|
1342
|
+
refuseEarlyToStream(`the report set at ${outPrefix} would overwrite ${label} ${other}`);
|
|
1343
|
+
process.exit(2);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
334
1348
|
// --json prints the report to stdout and writes NOTHING, so skip creating the (otherwise default) .candor/ dir.
|
|
335
1349
|
// The scanned package's name — the first half of the cross-package join key (SPEC §2 `hash`).
|
|
336
1350
|
let pkgName = path.basename(rootDir);
|
|
@@ -798,16 +1812,69 @@ const corruptDepPkgs = new Set();
|
|
|
798
1812
|
// --workspace's auto-scanned deps dir is prepended to the explicit CANDOR_DEPS/config spec (both chain).
|
|
799
1813
|
const spec = [workspaceDepsDir, depInitsDir, process.env.CANDOR_DEPS ?? candorConfig.deps ?? ""].filter(Boolean).join(":");
|
|
800
1814
|
const files = [];
|
|
801
|
-
|
|
1815
|
+
// ASCII WHITESPACE ONLY, the same rule as the config loader above and as java, rust and swift. JS
|
|
1816
|
+
// `\s` includes U+00A0, so a dep path holding a non-breaking space split into two halves — and since
|
|
1817
|
+
// ⟨0.27⟩ made an unresolvable dep token FATAL, that turned a path the other three engines load into a
|
|
1818
|
+
// hard exit 2 naming a truncated path the operator never wrote. The config loader was fixed and this
|
|
1819
|
+
// one, which every config-declared dep is also routed through, was not: one rule, two spellings.
|
|
1820
|
+
for (const tok of spec.split(DEP_SEPARATORS).filter(Boolean)) {
|
|
802
1821
|
try {
|
|
803
1822
|
if (fs.statSync(tok).isDirectory())
|
|
804
1823
|
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
1824
|
if (fs.statSync(tok).isFile()) files.push(tok);
|
|
806
|
-
} catch {
|
|
1825
|
+
} catch {
|
|
1826
|
+
// ⟨0.27⟩ SPEC §2: A CONFIGURED DEP THAT CANNOT BE READ IS UNEVALUABLE, NOT REDUCED COVERAGE.
|
|
1827
|
+
// Skipping it continued the run, and the caller of that dep then serialised `inferred: []` — a
|
|
1828
|
+
// ⟨0.21⟩ purity claim, published in the REPORT, about a function whose dependency the operator
|
|
1829
|
+
// configured precisely so it would not be one. This engine's note said only "skipped", so the
|
|
1830
|
+
// omission was not even qualified in the channel a human reads, let alone the artifact a chained
|
|
1831
|
+
// consumer reads. java and swift already refused; this engine and rust continued.
|
|
1832
|
+
console.error(`candor-ts: CANDOR_DEPS names ${tok} but it is not a readable file or directory — `
|
|
1833
|
+
+ `failing (exit 2, unevaluable). A configured dep that is not there is not reduced coverage: `
|
|
1834
|
+
+ `its callers would serialise \`inferred: []\`, which is a purity claim about code this scan `
|
|
1835
|
+
+ `never saw. Scan that dependency, or remove it from the \`deps\` config / CANDOR_DEPS.`);
|
|
1836
|
+
// The TOKEN arm needed this too. The read and parse arms below were routed to the stream and this
|
|
1837
|
+
// one was not — three exits for one rule, two of them answered on the machine channel and one
|
|
1838
|
+
// silent, which a conformance row (PART 36 b8) caught immediately once the cause was posed at all.
|
|
1839
|
+
refuseEarlyToStream(`configured dependency ${tok} is not a readable file or directory`);
|
|
1840
|
+
process.exit(2);
|
|
1841
|
+
}
|
|
807
1842
|
}
|
|
808
1843
|
for (const f of files) {
|
|
1844
|
+
// ⟨0.27⟩ READ AND PARSE OUTSIDE THE TRY, because SPEC §2 binds them and the try was swallowing them.
|
|
1845
|
+
// The rule is one sentence — a configured dep path that "does not exist OR CANNOT BE READ MUST exit
|
|
1846
|
+
// 2, naming it" — and the 0.27 work implemented only the first half, at the token check above. A path
|
|
1847
|
+
// that resolved to a file which then failed to open, or held malformed JSON, was SKIPPED at exit 0,
|
|
1848
|
+
// and the caller of that dep serialised `inferred: []`: the ⟨0.21⟩ purity claim the token check
|
|
1849
|
+
// exists to prevent, reached by a different door.
|
|
1850
|
+
//
|
|
1851
|
+
// Found by the 0.27 go/no-go panel, which tested this engine's own changelog claim instead of
|
|
1852
|
+
// believing it. java and swift refused on both halves already; this and rust made the family 2-v-2
|
|
1853
|
+
// on a MUST. The surviving `catch` below still guards the PROCESSING of a well-formed document,
|
|
1854
|
+
// which is a different failure and stays a skip.
|
|
1855
|
+
let raw;
|
|
809
1856
|
try {
|
|
810
|
-
|
|
1857
|
+
raw = fs.readFileSync(f, "utf8");
|
|
1858
|
+
} catch {
|
|
1859
|
+
console.error(`candor-ts: CANDOR_DEPS report ${f} could not be read —`);
|
|
1860
|
+
console.error(` failing (exit 2, unevaluable). A configured dep this scan cannot read is not`);
|
|
1861
|
+
console.error(` reduced coverage: its callers would serialise \`inferred: []\`, a purity claim`);
|
|
1862
|
+
console.error(` about code this scan never saw.`);
|
|
1863
|
+
refuseEarlyToStream(`configured dependency report ${f} could not be read`);
|
|
1864
|
+
process.exit(2);
|
|
1865
|
+
}
|
|
1866
|
+
let parsed;
|
|
1867
|
+
try {
|
|
1868
|
+
parsed = JSON.parse(raw);
|
|
1869
|
+
} catch {
|
|
1870
|
+
console.error(`candor-ts: CANDOR_DEPS report ${f} is not valid JSON —`);
|
|
1871
|
+
console.error(` failing (exit 2, unevaluable). Same reason as an unreadable one: a report`);
|
|
1872
|
+
console.error(` that cannot be parsed makes no claim, and continuing would publish one.`);
|
|
1873
|
+
refuseEarlyToStream(`configured dependency report ${f} is not valid JSON`);
|
|
1874
|
+
process.exit(2);
|
|
1875
|
+
}
|
|
1876
|
+
try {
|
|
1877
|
+
const d = parsed;
|
|
811
1878
|
// A report whose version can't be VERIFIED is not trusted (§2.1) — a missing header is as
|
|
812
1879
|
// untrustworthy as a mismatched one (the Rust engine's rule; the engines split on this).
|
|
813
1880
|
const stale = d.candor?.version !== ENGINE_VERSION;
|
|
@@ -928,7 +1995,7 @@ const corruptDepPkgs = new Set();
|
|
|
928
1995
|
if (!stale && strs(e.netClass).includes("unknown-host")) cell.netIncomplete = true;
|
|
929
1996
|
crossDeps.set(e.hash, cell);
|
|
930
1997
|
}
|
|
931
|
-
} catch { console.error(`candor-ts: CANDOR_DEPS report
|
|
1998
|
+
} catch { console.error(`candor-ts: CANDOR_DEPS report could not be processed, skipped: ${f}`); }
|
|
932
1999
|
}
|
|
933
2000
|
// A package chained TWICE — once fresh, once stale — is covered by the fresh report, so it is not a
|
|
934
2001
|
// stale-only package and must not pick up the disclosure below on top of a real answer.
|
|
@@ -1361,6 +2428,9 @@ function ollamaFromUrlArg(urlLit) {
|
|
|
1361
2428
|
}
|
|
1362
2429
|
// qualifies by the file's basename (`Cases.union_a`).
|
|
1363
2430
|
const fns = new Map(); // qualified name -> { direct, edges, hosts, tables, cmds, paths, loc }
|
|
2431
|
+
// Units minted for a declaration with NO BODY (ambient `declare`, `.d.ts` member, `abstract` member).
|
|
2432
|
+
// Drained by the §3 body-less-declaration disclosure pass, after the class-CHA index is built.
|
|
2433
|
+
const bodylessDecls = new Map(); // qual -> {node, mod, abstract, owner, member}
|
|
1364
2434
|
const unlistedSeen = new Map(); // the κ-coverage ledger: unlisted npm package -> call-site count
|
|
1365
2435
|
const nodeName = new WeakMap(); // declaration node -> qualified name
|
|
1366
2436
|
// ORM table declarations: `@Entity("user")` on a class maps that class to its table — the JVM's
|
|
@@ -1729,7 +2799,7 @@ for (const sf of sources) {
|
|
|
1729
2799
|
const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
|
|
1730
2800
|
if (!fns.has(ctorQual)) {
|
|
1731
2801
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
1732
|
-
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
|
|
2802
|
+
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
|
|
1733
2803
|
hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
|
|
1734
2804
|
blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
|
|
1735
2805
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
@@ -1753,11 +2823,30 @@ for (const sf of sources) {
|
|
|
1753
2823
|
// producer's namespace nesting, so widening the hash would break report chaining.
|
|
1754
2824
|
const nsp = namespacePrefixOf(node);
|
|
1755
2825
|
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(),
|
|
2826
|
+
fns.set(qual, { local: n, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
1757
2827
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
1758
2828
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
1759
2829
|
endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
|
|
1760
2830
|
nodeName.set(node, qual);
|
|
2831
|
+
// Record a unit minted for a declaration with NO BODY, for the §3 disclosure pass below (see it
|
|
2832
|
+
// for the argument). MIRROR `fns.set`'s last-write-wins EXACTLY — `.delete` on the bodied write is
|
|
2833
|
+
// load-bearing, not tidiness: an overload set is N body-less signatures followed by the
|
|
2834
|
+
// implementation under the SAME qual, so a plain `.add` would mark every real overloaded function
|
|
2835
|
+
// in the project unanalysable and charge its callers Unknown. That is the over-charge half of the
|
|
2836
|
+
// fix, and it is pinned by a fixture (`over`/`callsOver`, still `Fs` after this pass).
|
|
2837
|
+
{
|
|
2838
|
+
const hasBodySlot = ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)
|
|
2839
|
+
|| ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node)
|
|
2840
|
+
|| ts.isSetAccessorDeclaration(node);
|
|
2841
|
+
if (hasBodySlot && !node.body)
|
|
2842
|
+
bodylessDecls.set(qual, {
|
|
2843
|
+
node, mod,
|
|
2844
|
+
abstract: !!(ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Abstract),
|
|
2845
|
+
owner: node.parent?.name?.getText?.(),
|
|
2846
|
+
member: node.name?.getText?.(),
|
|
2847
|
+
});
|
|
2848
|
+
else bodylessDecls.delete(qual);
|
|
2849
|
+
}
|
|
1761
2850
|
if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
|
|
1762
2851
|
nodeName.set(node.initializer, qual);
|
|
1763
2852
|
// Index a `Object.defineProperty` descriptor accessor by its target SYMBOL + key, so a forcing
|
|
@@ -2054,7 +3143,7 @@ function moduleUnit(sf) {
|
|
|
2054
3143
|
const qual = `${mod}.<module>`;
|
|
2055
3144
|
let rec = fns.get(qual);
|
|
2056
3145
|
if (!rec) {
|
|
2057
|
-
rec = { local: qual, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
3146
|
+
rec = { local: qual, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2058
3147
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
2059
3148
|
entry: false, unitKind: "initializer",
|
|
2060
3149
|
loc: `${path.relative(rootDir, sf.fileName)}:1:1`,
|
|
@@ -2077,7 +3166,7 @@ function staticBlockUnit(node) {
|
|
|
2077
3166
|
const qual = `${mod}.${cname}.<static-init>`;
|
|
2078
3167
|
let rec = fns.get(qual);
|
|
2079
3168
|
if (!rec) {
|
|
2080
|
-
rec = { local: "<static-init>", direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
3169
|
+
rec = { local: "<static-init>", direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2081
3170
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
2082
3171
|
entry: false, unitKind: "initializer",
|
|
2083
3172
|
loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1`,
|
|
@@ -3403,6 +4492,17 @@ function visitCalls(node) {
|
|
|
3403
4492
|
eff = null;
|
|
3404
4493
|
if (eff) {
|
|
3405
4494
|
rec.direct.add(eff);
|
|
4495
|
+
// SPEC §2 `fs` — refine an Fs we just PROVED with the direction its verb implies. DIRECT only
|
|
4496
|
+
// (never propagated over edges): a caller reaching one writer and one undetermined callee would
|
|
4497
|
+
// otherwise inherit ["write"] and thereby claim "writes but never reads" — the partial claim §2
|
|
4498
|
+
// forbids. An unrecognised verb adds nothing, so the field stays absent rather than half-true.
|
|
4499
|
+
if (eff === "Fs") {
|
|
4500
|
+
// A verb revealing no direction records the POISON marker "?" rather than nothing. Abstaining
|
|
4501
|
+
// would let a caller inherit a neighbour's ["write"] and claim "writes but never reads" over a
|
|
4502
|
+
// reach whose kind was never determined — the partial claim §2 forbids. Suppressed at emit.
|
|
4503
|
+
const ks = fsKind(mod, member);
|
|
4504
|
+
if (ks.length === 0) rec.fsKinds.add("?"); else for (const k of ks) rec.fsKinds.add(k);
|
|
4505
|
+
}
|
|
3406
4506
|
// a κ rule that resolves to the Unknown trust-marker (node:vm code execution) is a direct
|
|
3407
4507
|
// Unknown SOURCE — SPEC §4 requires a why on it, like eval's `reflect:eval`. (The rest of
|
|
3408
4508
|
// the κ table is concrete effects, which carry no why.)
|
|
@@ -4309,6 +5409,53 @@ function resolveDepEntryKey(pkg, subpath) {
|
|
|
4309
5409
|
}
|
|
4310
5410
|
}
|
|
4311
5411
|
|
|
5412
|
+
// ---- THE BODY-LESS LOCAL DECLARATION (SPEC §3 honesty invariant; §4 `native:`/`dispatch:`) ---------
|
|
5413
|
+
// A declaration the scan can SEE but whose BODY is not in the analyzed set — an ambient `declare
|
|
5414
|
+
// function`, any member of a `.d.ts`, an `abstract` member no local subclass overrides. `localName`
|
|
5415
|
+
// mints a unit for each (it keys on the declaration, not on the presence of a body), the call site
|
|
5416
|
+
// edges the caller to it, and the unit is EMPTY — so the caller unioned nothing and read PURE. That is
|
|
5417
|
+
// the cardinal sin: a purity claim over code the engine never saw.
|
|
5418
|
+
//
|
|
5419
|
+
// FOUND ON REAL CODE, and the corpus is why: candor-ts's whole report for `axios` is 54 `index.d.ts`
|
|
5420
|
+
// declarations while its 61 `.js` implementation files are never analyzed, so `deny Unknown` — the gate
|
|
5421
|
+
// whose entire purpose is "fail if candor cannot see what this reaches" — exited **0** where rust, java
|
|
5422
|
+
// and swift all exit 1 on the same shape. candor-ts was the four-way outlier:
|
|
5423
|
+
//
|
|
5424
|
+
// rust `extern "C" { fn ambient(…) }` caller -> Unknown[native:extern fn]
|
|
5425
|
+
// java `public static native int ambient` DECL -> Unknown[native:ambient], caller inherits
|
|
5426
|
+
// swift protocol req., no local conformer caller -> Unknown[dispatch:Ambient.ping]
|
|
5427
|
+
// ts `declare function ambient(…)` caller -> PURE <-- this
|
|
5428
|
+
//
|
|
5429
|
+
// We take java's shape (charge the DECLARATION, let the existing fixpoint carry it caller-ward) rather
|
|
5430
|
+
// than rust/swift's (charge at the edge), for one reason: ts already mints the unit and already forms
|
|
5431
|
+
// the edge, so mint-side is ONE place. Charging at the edge would mean touching every call and desugar
|
|
5432
|
+
// site — and that is the exact drift this file has been bitten by twice (`discloseUnanswerableKey` is
|
|
5433
|
+
// one function today because it was two, and the ⟨0.19⟩ reason class was added to neither).
|
|
5434
|
+
//
|
|
5435
|
+
// PRECISION HALF, and it is not optional — a fix that over-charges is how a fabrication gets introduced
|
|
5436
|
+
// while closing an under-report. A base member with a local bodied override is ALREADY resolved: the
|
|
5437
|
+
// class-CHA at the dispatch site edges the caller to the overrides and runs its own `allResolved` gate.
|
|
5438
|
+
// Charging the empty base too would manufacture uncertainty over code the engine can in fact see.
|
|
5439
|
+
// MEASURED on the corpus: hono's `EventProcessor` has six abstracts with three local subclasses and
|
|
5440
|
+
// zod's `ZodType._parse` one — excluding them takes zod's delta to +0 and hono's from +18 to +9, and
|
|
5441
|
+
// every one of the nine that remain is a true positive (`Deno.mkdir`/`writeFile` are Fs, `Deno.
|
|
5442
|
+
// upgradeWebSocket` and `FetcherLike.fetch` are Net, all declared in local `.d.ts` shims with no body).
|
|
5443
|
+
const answeredByLocalBody = (node) => (classOverrides.get(node) ?? []).some((om) => !!om.body);
|
|
5444
|
+
for (const [qual, meta] of bodylessDecls) {
|
|
5445
|
+
const rec = fns.get(qual);
|
|
5446
|
+
if (!rec || answeredByLocalBody(meta.node)) continue;
|
|
5447
|
+
rec.direct.add("Unknown");
|
|
5448
|
+
// §4's dividing line, same as everywhere else in this file. An `abstract` member IS an unresolved
|
|
5449
|
+
// DISPATCH — owner type and member are both nameable, which is what `dispatch:` reserves itself for,
|
|
5450
|
+
// and it is the spelling swift gives the same shape. Everything else (ambient/`declare`/`.d.ts`) is a
|
|
5451
|
+
// boundary to code we cannot analyse, which is §4's definition of `native:` verbatim; java spells it
|
|
5452
|
+
// `native:<method>` and rust `native:extern fn`. §4 makes the CLASS per-language and best-effort, so
|
|
5453
|
+
// ts emitting `native:` where its language model produces one is exactly the licensed use.
|
|
5454
|
+
rec.why.add(meta.abstract
|
|
5455
|
+
? dispatchWhy(meta.owner ? `${meta.mod}.${meta.owner}` : null, meta.member)
|
|
5456
|
+
: `native:${rec.local}`);
|
|
5457
|
+
}
|
|
5458
|
+
|
|
4312
5459
|
// ---- pass 3: the least fixpoint (SEMANTICS §5a), effects + the literal surfaces -------------------
|
|
4313
5460
|
// WORKLIST least-fixpoint. The old `while (changed) { for [,rec] of fns }` swept every function on every
|
|
4314
5461
|
// pass, so its pass count equalled the longest back-to-front call chain — O(V²) on deep whole-project
|
|
@@ -4339,7 +5486,10 @@ const inferred = new Map([...fns.keys()].map((k) => [k, new Set(fns.get(k).direc
|
|
|
4339
5486
|
if (!queued.has(c)) { queued.add(c); queue.push(c); }
|
|
4340
5487
|
}
|
|
4341
5488
|
}
|
|
4342
|
-
|
|
5489
|
+
// `fsKinds` joins the propagated surfaces: kinds TRAVEL the call graph (a caller that transitively only
|
|
5490
|
+
// writes IS a writer), and the "?" poison travels with them so a caller of an undetermined-kind function
|
|
5491
|
+
// inherits the SUPPRESSION rather than a half-answer. Pinned by conformance PART 31.
|
|
5492
|
+
for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsKinds"]) {
|
|
4343
5493
|
const queue = [...fns.keys()];
|
|
4344
5494
|
const queued = new Set(queue);
|
|
4345
5495
|
for (let head = 0; head < queue.length; head++) {
|
|
@@ -4393,6 +5543,14 @@ for (const [name, rec] of fns) {
|
|
|
4393
5543
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
4394
5544
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
4395
5545
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
5546
|
+
// SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
|
|
5547
|
+
// (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
|
|
5548
|
+
//
|
|
5549
|
+
// Kinds TRAVEL (see the propagation loop); the "?" poison is what stops a PARTIAL answer travelling with
|
|
5550
|
+
// them. Present ⇒ some contributing Fs had no determined kind ⇒ suppress the whole field, because
|
|
5551
|
+
// ["write"] there would claim "writes but never reads" about a function that may do both.
|
|
5552
|
+
if (inf.includes("Fs") && rec.fsKinds.size && !rec.fsKinds.has("?"))
|
|
5553
|
+
entry.fs = [...rec.fsKinds].sort();
|
|
4396
5554
|
// ⟨0.6⟩ unknownWhy — REQUIRED on a DIRECT Unknown SOURCE (this fn's own body has the unresolvable call,
|
|
4397
5555
|
// so `rec.direct` carries Unknown), absent on a purely-transitive Unknown. The rich per-site reasons
|
|
4398
5556
|
// (rec.why: callback:/dispatch:/dynamic-key:) when recorded, else a generic fallback so a source is
|
|
@@ -4808,6 +5966,10 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
4808
5966
|
+ `NO report written (a consumer cannot detect this from the outside):`);
|
|
4809
5967
|
for (const c of contradictions.slice(0, 20)) console.error(` ${c}`);
|
|
4810
5968
|
if (contradictions.length > 20) console.error(` … and ${contradictions.length - 20} more`);
|
|
5969
|
+
// ⟨0.28⟩ report stream: this exit-2 fires BEFORE the envelope is printed, so a `--json` run would
|
|
5970
|
+
// otherwise leave stdout empty for the whole class of trust-marker contradictions. The latch is
|
|
5971
|
+
// still unset at this point; the helper writes the fail-closed doc as stdout's only content.
|
|
5972
|
+
refuseEarlyToStream(`${contradictions.length} report entr${contradictions.length === 1 ? "y contradicts its" : "ies contradict their"} own trust markers`);
|
|
4811
5973
|
process.exit(2);
|
|
4812
5974
|
}
|
|
4813
5975
|
}
|
|
@@ -4834,8 +5996,13 @@ function fnv1aHex(sortedQuals) {
|
|
|
4834
5996
|
|
|
4835
5997
|
// `package` names what this report COVERS — a consumer chaining it registers coverage even when
|
|
4836
5998
|
// `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
|
|
5999
|
+
// ⟨0.27⟩ SPEC §2.1 `resolves`: the OPTIONAL refinement surfaces this producer computes. Without it the
|
|
6000
|
+
// absence of such a field is overloaded between "does not compute this" and "computed and could not
|
|
6001
|
+
// determine it", and a consumer cannot read the omission at all. candor-ts resolves `fs` read/write kinds,
|
|
6002
|
+
// so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
|
|
6003
|
+
// false "undetermined", which is the inversion the field exists to prevent.
|
|
4837
6004
|
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
4838
|
-
package: pkgName, functions };
|
|
6005
|
+
resolves: ["fs"], package: pkgName, functions };
|
|
4839
6006
|
// ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
|
|
4840
6007
|
// name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
|
|
4841
6008
|
// the --gate-json advisory, so the three can never tell different stories.
|
|
@@ -4874,10 +6041,18 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
|
4874
6041
|
// `candor-ts-watch` re-scans (the recommended agent setup runs both) — must never observe a
|
|
4875
6042
|
// half-written report. An in-place writeFileSync leaves a truncation window where JSON.parse throws;
|
|
4876
6043
|
// rename(2) is atomic within a filesystem, so a reader sees either the old report or the new one whole.
|
|
4877
|
-
|
|
6044
|
+
// ⟨0.28⟩ …and through `writeSinkAtomic`, so a symlinked or multiply-linked destination is written where
|
|
6045
|
+
// the operator points rather than replaced. Reports have the same layout exposure as verdicts.
|
|
6046
|
+
const writeAtomic = (file, text) => writeSinkAtomic(file, text);
|
|
4878
6047
|
// --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
|
|
4879
6048
|
if (wantJson) {
|
|
4880
6049
|
console.log(JSON.stringify(envelope, null, 1));
|
|
6050
|
+
// ⟨0.28⟩ REPORT STREAM LATCH — a successful envelope went to stdout, so a later exit-2 site (baseline
|
|
6051
|
+
// corrupt, policy refusal, gate NOT certified over unanalyzed) MUST NOT also write a fail-closed
|
|
6052
|
+
// placeholder there. Two documents on one stream parses as neither — the same shape the two-stream
|
|
6053
|
+
// refusal exists to prevent, arriving through a different door. The rust reference sets the mirror
|
|
6054
|
+
// `REPORT_STREAM_WRITTEN` OnceLock at the analog site (crates/candor-scan/src/scan.rs).
|
|
6055
|
+
reportStreamWritten = true;
|
|
4881
6056
|
} else {
|
|
4882
6057
|
writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
|
|
4883
6058
|
writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
|
|
@@ -4928,6 +6103,12 @@ if (!wantJson) {
|
|
|
4928
6103
|
writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
|
|
4929
6104
|
console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
|
|
4930
6105
|
}
|
|
6106
|
+
// ⟨0.28⟩ The run has finished writing its report set: hand back any file it armed and turned out not to
|
|
6107
|
+
// own (see disarmUnwrittenOutReports). Placed HERE rather than at the exit sites because everything
|
|
6108
|
+
// below — the policy gate, the baseline ratchet, the unanalyzed certification — can exit 1 or 2 having
|
|
6109
|
+
// already published a complete report, and a leftover placeholder past this point is a claim of
|
|
6110
|
+
// incompleteness this run did not experience.
|
|
6111
|
+
disarmUnwrittenOutReports();
|
|
4931
6112
|
{
|
|
4932
6113
|
// Effect breakdown — make the result visible at a glance, not just a count + a file path.
|
|
4933
6114
|
const counts = {};
|
|
@@ -5014,7 +6195,17 @@ if (!wantJson) {
|
|
|
5014
6195
|
// A qual is test code iff its recorded loc (file:line[:col]) lies on a test path — the same predicate
|
|
5015
6196
|
// the scan already uses to keep test files out of the report.
|
|
5016
6197
|
const isTestQual = (q) => { const l = locMap.get(q); return l ? isTestPath(l) : false; };
|
|
5017
|
-
|
|
6198
|
+
// The cause this run can actually stand behind, in the order the evidence supports: packages the
|
|
6199
|
+
// classifier does not cover (already enumerated above) beats a tsconfig guess, and a tsconfig this run
|
|
6200
|
+
// READ rules the tsconfig guess out entirely.
|
|
6201
|
+
const unresolvedCause = unlistedSeen.size > 0
|
|
6202
|
+
? `the ${uncoveredLedger.length} package${uncoveredLedger.length === 1 ? "" : "s"} named above are not `
|
|
6203
|
+
+ `covered by the classifier, so calls into them resolve to Unknown`
|
|
6204
|
+
: (usedTsconfig
|
|
6205
|
+
? `this scan read ${path.relative(rootDir, usedTsconfig) || path.basename(usedTsconfig)}, so the `
|
|
6206
|
+
+ `cause is unresolvable imports rather than a missing tsconfig`
|
|
6207
|
+
: `no tsconfig.json was found for this target, so imports resolve poorly — point candor at one`);
|
|
6208
|
+
emitSurface(inferred, directMap, callsMap, locMap, isTestQual, console.error, unresolvedCause);
|
|
5018
6209
|
}
|
|
5019
6210
|
|
|
5020
6211
|
// ---- the gate surfaces: the AS-EFF-005 baseline guard + the standing §6.2 policy gate --------------
|
|
@@ -5023,6 +6214,14 @@ if (!wantJson) {
|
|
|
5023
6214
|
// a `… | jq` / `… | candor-sarif` pipe never breaks.
|
|
5024
6215
|
const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
|
5025
6216
|
let gateViolations = [];
|
|
6217
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the raw text of every rule whose SCOPE bound no function, captured off the
|
|
6218
|
+
// gate evaluation and emitted onto the verdict document. The stderr lines alone left a machine consumer
|
|
6219
|
+
// unable to see that a rule bound nothing — the typo'd-scope silent green, one channel over. Disclosure
|
|
6220
|
+
// only: `ok` and the exit code never consult it.
|
|
6221
|
+
let gateZeroMatch = [];
|
|
6222
|
+
// ⟨0.28⟩ SPEC §6.2 `ignored` — the policy lines the parse DROPPED, for the verdict document. Declared
|
|
6223
|
+
// beside `gateZeroMatch` and for the same reason: the verdict is assembled long after the policy block.
|
|
6224
|
+
let gateIgnored = [];
|
|
5026
6225
|
// ⟨0.24⟩ the `.candor/config` that supplied POLICY VOCABULARY this verdict actually used — named on the
|
|
5027
6226
|
// document (SPEC §3.1 `99eb4e9`), null when no alias was referenced so the verdict stays byte-identical.
|
|
5028
6227
|
let policyVocabulary = null;
|
|
@@ -5075,7 +6274,20 @@ const writeRefusal = (reason, unevaluated = null) => {
|
|
|
5075
6274
|
// must not silently NARROW the guard back to report-only.
|
|
5076
6275
|
if (baselinePath !== null) {
|
|
5077
6276
|
const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
|
|
5078
|
-
if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
6277
|
+
if (baselinePath !== "" && !fs.existsSync(baselinePath) && baselineFromConfig) {
|
|
6278
|
+
// A CHECKED-IN DECLARATION IS NOT THE SAME ABSENCE — see baselineFromConfig above. Exit 2: the
|
|
6279
|
+
// gateless-green class, and an adopter review measured this as the second-likeliest first-commit
|
|
6280
|
+
// mistake (`.candor/` committed, the baseline not).
|
|
6281
|
+
console.error(`candor-ts: .candor/config declares \`baseline ${baselinePath}\` but that file is not `
|
|
6282
|
+
+ `there — failing (exit 2). A checked-in declaration says this repo HAS a baseline, so an absent `
|
|
6283
|
+
+ `one was deleted or never committed. Commit it, or record one: candor-ts <target> --out <prefix>.`);
|
|
6284
|
+
// WRITE THE REFUSAL DOCUMENT BEFORE EXITING. Without this the `--gate-json` file keeps whatever the
|
|
6285
|
+
// LAST run left there — so a CI wrapper that reads the artifact instead of the exit code sees the
|
|
6286
|
+
// previous run's `ok: true` and reports a pass, which is the stale-artifact false green this format
|
|
6287
|
+
// exists to refuse. java, rust and swift all overwrite on this branch; ts alone did not.
|
|
6288
|
+
writeRefusal(`.candor/config declares \`baseline ${baselinePath}\` but that file is not there`);
|
|
6289
|
+
process.exit(2);
|
|
6290
|
+
} else if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
5079
6291
|
console.error(`candor-ts: CANDOR_BASELINE ${baselinePath} does not exist — the regression guard is `
|
|
5080
6292
|
+ `not active (record one: candor-ts <target> --out <prefix>, then point at the report .json).`);
|
|
5081
6293
|
} else {
|
|
@@ -5085,6 +6297,10 @@ if (baselinePath !== null) {
|
|
|
5085
6297
|
if (!Array.isArray(arr)) {
|
|
5086
6298
|
console.error(`candor-ts: baseline ${shownB} exists but could not be parsed (corrupt/truncated?) — `
|
|
5087
6299
|
+ `failing (exit 2); the guard must not silently pass on an unreadable baseline. Regenerate it with this build.`);
|
|
6300
|
+
// ⟨0.27⟩ the refusal document has no exempt cause AND no exempt sink (SPEC §3.1): a file sink holds
|
|
6301
|
+
// the armed placeholder, but `--gate-json -` is not armed, so without this write the stream carried
|
|
6302
|
+
// NOTHING on this cause. Writing here also replaces the placeholder with the specific reason.
|
|
6303
|
+
writeRefusal(`baseline ${shownB} exists but could not be parsed — guard NOT evaluated`);
|
|
5088
6304
|
process.exit(2);
|
|
5089
6305
|
}
|
|
5090
6306
|
const baseVersion = !Array.isArray(root) && root.candor && typeof root.candor === "object"
|
|
@@ -5092,12 +6308,14 @@ if (baselinePath !== null) {
|
|
|
5092
6308
|
if (baseVersion === null) {
|
|
5093
6309
|
console.error(`candor-ts: the baseline ${shownB} has no provenance header (a legacy/bare-array report) — `
|
|
5094
6310
|
+ `a baseline is comparable only to its producing build (§2.1). Failing (exit 2); regenerate it with this build.`);
|
|
6311
|
+
writeRefusal(`baseline ${shownB} has no provenance header — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5095
6312
|
process.exit(2);
|
|
5096
6313
|
}
|
|
5097
6314
|
if (baseVersion !== ENGINE_VERSION) {
|
|
5098
6315
|
console.error(`candor-ts: the baseline ${shownB} was produced by engine build ${baseVersion} but this is `
|
|
5099
6316
|
+ `build ${ENGINE_VERSION} — an engine swap is baseline-invalidating and the gate cannot evaluate `
|
|
5100
6317
|
+ `(exit 2; never a silent skip, never a bogus AS-EFF-005 wave). Regenerate deliberately with this build.`);
|
|
6318
|
+
writeRefusal(`baseline ${shownB} was produced by engine build ${baseVersion}, not this build — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5101
6319
|
process.exit(2);
|
|
5102
6320
|
}
|
|
5103
6321
|
const base = new Map();
|
|
@@ -5124,6 +6342,7 @@ if (baselinePath !== null) {
|
|
|
5124
6342
|
console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
|
|
5125
6343
|
+ `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
|
|
5126
6344
|
+ `report-only. Regenerate the baseline with this build.`);
|
|
6345
|
+
writeRefusal(`baseline callgraph ${sidecarPath} could not be parsed — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5127
6346
|
process.exit(2);
|
|
5128
6347
|
}
|
|
5129
6348
|
// The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
|
|
@@ -5220,6 +6439,11 @@ if (policyPath !== null) {
|
|
|
5220
6439
|
const unknownAliases = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(policyPath, target)), parseErrs);
|
|
5221
6440
|
const gatePolicy = parsePolicy(text, unknownAliases);
|
|
5222
6441
|
parseErrs.push(...gatePolicy.errors);
|
|
6442
|
+
// ⟨0.28⟩ SPEC §6.2 — the LINES THE PARSE DROPPED, for the verdict document below. Non-fatal by
|
|
6443
|
+
// construction (`parsePolicy` excludes the fatal kinds: a policy ERROR refuses the whole run and a
|
|
6444
|
+
// refused run has no verdict for a dropped line to have shrunk). Captured here because the verdict
|
|
6445
|
+
// is assembled far below and `gatePolicy` is scoped to this block.
|
|
6446
|
+
gateIgnored = gatePolicy.ignored ?? [];
|
|
5223
6447
|
// ⟨0.24⟩ only the UNRECOGNISED VALUE TOKENS refuse. `errors` now also carries every LINE this parser
|
|
5224
6448
|
// dropped whole (SPEC §3.1 `195d45a`), which is additive to the `parsepolicy` witness and deliberately
|
|
5225
6449
|
// silent about the gate — refusing there would be a grammar change, not a token change.
|
|
@@ -5231,9 +6455,49 @@ if (policyPath !== null) {
|
|
|
5231
6455
|
if (policyErrs.length) {
|
|
5232
6456
|
const why = policyErrorText(policyPath, policyErrs);
|
|
5233
6457
|
console.error(why);
|
|
5234
|
-
// ⟨0.
|
|
5235
|
-
//
|
|
5236
|
-
|
|
6458
|
+
// ⟨0.27⟩ ONE `unevaluated` ENTRY PER RULE OF THE POLICY — not only the unhonourable lines (SPEC
|
|
6459
|
+
// §3.1's composed-document clause). Measured: listing only the bad token's line let a consumer read
|
|
6460
|
+
// `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
|
|
6461
|
+
// so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
|
|
6462
|
+
policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
|
|
6463
|
+
} else if (!gatePolicy.deny.length && !gatePolicy.allow.length && !gatePolicy.forbid.length) {
|
|
6464
|
+
// ⟨0.28⟩ A CONFIGURED POLICY THAT YIELDED ZERO RULES IS A BROKEN GATE CONFIG (SPEC §6.2) — the same
|
|
6465
|
+
// refusal posture as the two branches above, and for the reason §6.2 already gives for an unreadable
|
|
6466
|
+
// FILE: "a typo'd policy path that runs green is a gate that silently passes everything". MEASURED
|
|
6467
|
+
// four-way 2026-08-10: `--policy <a README>` wrote `{"ok":true,"violations":[]}` and exited 0 on
|
|
6468
|
+
// every engine — byte-identical to a gate that ran and found nothing, AND byte-identical to the
|
|
6469
|
+
// no-gate-configured verdict (§3.3), so the one consumer this format exists for cannot tell "your
|
|
6470
|
+
// code is clean" from "your gate had no rules". The per-line `ignoring policy rule` warnings above
|
|
6471
|
+
// go to stderr, which is not the machine channel.
|
|
6472
|
+
//
|
|
6473
|
+
// The line-level ignore-with-a-warning leniency is UNTOUCHED and still right: silent reinterpretation
|
|
6474
|
+
// is the one thing a security gate must not do, and an engine meeting a rule kind from a newer rung
|
|
6475
|
+
// must not refuse the whole file over it. This rung is about what that leniency COMPOSES TO — a file
|
|
6476
|
+
// in which EVERY line was ignored is a gate that asked nothing.
|
|
6477
|
+
//
|
|
6478
|
+
// THE CONTROL, which is what makes this a rule and not a blanket: reaching here at all means a policy
|
|
6479
|
+
// was CONFIGURED (`--policy`, CANDOR_POLICY, or the `.candor/config` `policy` key — all three land in
|
|
6480
|
+
// `policyPath`). A run that configured no gate never enters this block and stays exit 0; that is the
|
|
6481
|
+
// honest way to say "I am not gating", and it is exactly why a configured zero-rule policy is never a
|
|
6482
|
+
// legitimate expression of that intent.
|
|
6483
|
+
//
|
|
6484
|
+
// EVERY RULE VECTOR, and rust's first draft of this check read only its `rules`. `parsePolicy` splits
|
|
6485
|
+
// the four kinds across THREE arrays — `deny` (both `deny` and `pure`), `allow` and `forbid` — so
|
|
6486
|
+
// keying on one made an allow-only policy (`allow Net api.stripe.com`, an ordinary allowlist gate) or
|
|
6487
|
+
// a forbid-only layer policy refuse as if it had no rules at all. A zero-rule test that inspects a
|
|
6488
|
+
// subset of the rule kinds is the same false-answer shape this rung exists to close, pointed the
|
|
6489
|
+
// other way.
|
|
6490
|
+
const { why, unevaluated } = policyZeroRules(policyPath);
|
|
6491
|
+
console.error(`candor-ts: ${why} — refusing (exit 2, gate NOT enforced). Every line was ignored (see `
|
|
6492
|
+
+ `the \`ignoring policy rule\` warnings above), the file is empty, or it holds only comments. A `
|
|
6493
|
+
+ `gate with no rules cannot have caught anything, and reporting \`ok: true\` here would be `
|
|
6494
|
+
+ `indistinguishable from a gate that ran and found nothing. If you did not mean to gate this run, `
|
|
6495
|
+
+ `remove the \`policy\` setting rather than pointing it at a file with no rules in it.`);
|
|
6496
|
+
// …RECORDED, NOT EXECUTED, so it takes the SAME precedence as both branches above (SPEC §3.1
|
|
6497
|
+
// `4c79958`): a certain violation dominates a refusal. No POLICY violation can exist with zero rules,
|
|
6498
|
+
// but an AS-EFF-005 baseline regression is a finding from evidence this run already carries, and it
|
|
6499
|
+
// rides exit 1 with this refusal disclosed beside it under `unevaluated`.
|
|
6500
|
+
policyRefusal = { why, unevaluated };
|
|
5237
6501
|
} else {
|
|
5238
6502
|
// ⟨0.24⟩ the config file that supplied vocabulary the verdict USED, so an ambient `.candor/config` — the
|
|
5239
6503
|
// walk goes up through every parent, and CANDOR_CONFIG overrides it outright — cannot move a verdict while
|
|
@@ -5242,7 +6506,20 @@ if (policyPath !== null) {
|
|
|
5242
6506
|
const p = discoverConfigPath(policyVocabularyAnchor(policyPath, target));
|
|
5243
6507
|
if (p) policyVocabulary = { config: p, aliases: gatePolicy.aliasesUsed };
|
|
5244
6508
|
}
|
|
5245
|
-
|
|
6509
|
+
const gateOut = evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners);
|
|
6510
|
+
// ⟨0.27⟩ SPEC §4 — a rule that bound NO function is disclosed, never scored as satisfied. The exit
|
|
6511
|
+
// code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
|
|
6512
|
+
// repositories and a layer exists in only some of them, so refusal would make a shared policy
|
|
6513
|
+
// unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
|
|
6514
|
+
for (const raw of gateOut.zeroMatch ?? []) {
|
|
6515
|
+
console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
|
|
6516
|
+
+ `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
|
|
6517
|
+
+ `repos; a typo'd layer name otherwise.`);
|
|
6518
|
+
}
|
|
6519
|
+
// ⟨0.27⟩ captured BEFORE the concat below — `concat` returns a plain array, so the `zeroMatch`
|
|
6520
|
+
// property riding `gateOut` would be silently lost with it (see the gateZeroMatch declaration).
|
|
6521
|
+
gateZeroMatch = gateOut.zeroMatch ?? [];
|
|
6522
|
+
gateViolations = gateViolations.concat(gateOut);
|
|
5246
6523
|
// Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
|
|
5247
6524
|
// in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
|
|
5248
6525
|
// fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
|
|
@@ -5317,6 +6594,20 @@ if (gateJsonPath) {
|
|
|
5317
6594
|
// consumer reading exit 1 must be able to see that the POLICY half of the gate never ran — the same
|
|
5318
6595
|
// `unevaluated` key, in the same position, that `gate --report` uses for its answerability refusals.
|
|
5319
6596
|
if (policyRefusal) verdictObj.unevaluated = policyRefusal.unevaluated;
|
|
6597
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines carry, in the machine channel. Omitted
|
|
6598
|
+
// when empty so a fully-binding verdict is byte-identical; never consulted for `ok` or the exit code.
|
|
6599
|
+
if (gateZeroMatch.length) verdictObj.zeroMatch = gateZeroMatch;
|
|
6600
|
+
// ⟨0.28⟩ SPEC §6.2 `ignored: [{line, text, reason}]` — the policy lines the parse DROPPED, so a machine
|
|
6601
|
+
// consumer can see that the gate it is reading is SMALLER than the gate that was written. MEASURED
|
|
6602
|
+
// four-way: all four engines warn per ignored line on stderr while the verdict document stays silent,
|
|
6603
|
+
// and the zero-rule refusal fires only at ZERO survivors — so 1-of-10 rules parsing wrote
|
|
6604
|
+
// `{"ok": true, "violations": []}` at exit 0 with nothing said about the nine gates never asked.
|
|
6605
|
+
//
|
|
6606
|
+
// DISTINCT FROM `unevaluated`, and the distinction is load-bearing: `unevaluated` carries rules that
|
|
6607
|
+
// PARSED and could not be answered, `ignored` carries text that never became a rule at all. Omitted
|
|
6608
|
+
// when nothing was dropped, so a clean policy's verdict stays byte-identical. Never consulted for `ok`
|
|
6609
|
+
// or the exit code — the line-level leniency is UNCHANGED, only disclosed.
|
|
6610
|
+
if (gateIgnored.length) verdictObj.ignored = gateIgnored;
|
|
5320
6611
|
// ⟨0.21⟩ (Gap 2) the machine-legible incompleteness: the units candor couldn't analyze, so a CI/agent
|
|
5321
6612
|
// reading the JSON learns WHY the gate can't certify (the stderr warning alone used to hide this from a
|
|
5322
6613
|
// machine). `incomplete:true` + the list; the run exits 2 (could-not-fully-evaluate) below. ok:false +
|