candor-ts 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/policy.mjs +91 -4
- package/query.mjs +150 -5
- package/scan-core.mjs +39 -0
- package/scan.mjs +518 -37
package/AGENTS.md
CHANGED
|
@@ -12,7 +12,7 @@ chains by hand.
|
|
|
12
12
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
13
13
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
14
14
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
15
|
-
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.27)."*
|
|
16
16
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
17
17
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
18
18
|
>
|
package/README.md
CHANGED
|
@@ -192,7 +192,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
192
192
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
193
193
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
194
194
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
195
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
195
|
+
| `{ candor: { version, toolchain, spec: "0.27" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
196
196
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
197
197
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
198
198
|
|
|
@@ -210,7 +210,7 @@ read the Rust source".
|
|
|
210
210
|
|
|
211
211
|
## Status
|
|
212
212
|
|
|
213
|
-
0.19.x, speaking candor-spec 0.
|
|
213
|
+
0.19.x, speaking candor-spec 0.27: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
214
214
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
215
215
|
`--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
|
|
216
216
|
report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
|
+
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.27)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/policy.mjs
CHANGED
|
@@ -796,6 +796,65 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
796
796
|
if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``);
|
|
797
797
|
}
|
|
798
798
|
}
|
|
799
|
+
// ⟨0.27⟩ SPEC §4 — A RULE WHOSE SCOPE BOUND NO FUNCTION IS UNANSWERABLE, AND IS DISCLOSED RATHER THAN
|
|
800
|
+
// SCORED AS SATISFIED. Measured on this engine before the fix: `deny Fs orders` exits 1 on a real
|
|
801
|
+
// violation while `deny Fs ordrs` exits 0 in silence — a one-character typo in a layer name is a
|
|
802
|
+
// permanently green gate, and `unverified` then calls the layer "PROVABLY clean". The asymmetry is the
|
|
803
|
+
// tell: a typo'd EFFECT token already exits 2 naming the accepted vocabulary.
|
|
804
|
+
//
|
|
805
|
+
// Carried as a PROPERTY on the returned array rather than as a second return value, so every existing
|
|
806
|
+
// caller keeps working unchanged and `--gate-json` is untouched: JSON.stringify ignores non-index
|
|
807
|
+
// properties on an array, so the verdict document cannot acquire a field the spec has not pinned.
|
|
808
|
+
//
|
|
809
|
+
// A `deny`/`pure` with NO scope applies to every function and so can never be this kind of typo —
|
|
810
|
+
// excluded. A `forbid` counts a match on either endpoint. Counted over the same names the gate saw.
|
|
811
|
+
const zeroCount = new Map();
|
|
812
|
+
for (const r of pol.deny) if (r.scope) zeroCount.set(r.raw, 0);
|
|
813
|
+
for (const r of pol.forbid) zeroCount.set(r.raw, 0);
|
|
814
|
+
if (zeroCount.size) {
|
|
815
|
+
const names = new Set(functions.map((f) => f.fn));
|
|
816
|
+
for (const k of Object.keys(callgraph ?? {})) names.add(k);
|
|
817
|
+
for (const n of names) {
|
|
818
|
+
for (const r of pol.deny) if (r.scope && scopeMatches(n, r.scope)) zeroCount.set(r.raw, zeroCount.get(r.raw) + 1);
|
|
819
|
+
for (const r of pol.forbid) {
|
|
820
|
+
if (scopeMatches(n, r.from) || scopeMatches(n, r.to)) zeroCount.set(r.raw, zeroCount.get(r.raw) + 1);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// ⟨0.27⟩ CODE-POINT order, explicitly — the `zeroMatch` verdict key pins the `viaDispatchOn` collation
|
|
825
|
+
// (SPEC §4), and JS's default sort orders by UTF-16 code unit, which disagrees above the BMP. The raw
|
|
826
|
+
// line is built from user identifiers, so this is reachable rather than theoretical.
|
|
827
|
+
out.zeroMatch = [...zeroCount].filter(([, c]) => c === 0).map(([raw]) => raw)
|
|
828
|
+
.sort((a, b) => {
|
|
829
|
+
const ai = [...a], bi = [...b];
|
|
830
|
+
for (let i = 0; i < Math.min(ai.length, bi.length); i++) {
|
|
831
|
+
const d = ai[i].codePointAt(0) - bi[i].codePointAt(0);
|
|
832
|
+
if (d) return d;
|
|
833
|
+
}
|
|
834
|
+
return ai.length - bi.length;
|
|
835
|
+
});
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ⟨0.27⟩ EVERY RULE OF A REFUSED POLICY, one `unevaluated` entry per raw line (SPEC §3.1's
|
|
840
|
+
// composed-document clause; candor-java `unhonouredRules` is the model). `policyErrorUnevaluated` above
|
|
841
|
+
// names only the UNHONOURABLE lines — measured, that let a consumer read `deny Fs`, absent from the list
|
|
842
|
+
// on an exit-1 document, as evaluated-and-passed: a per-rule false all-clear arriving through the
|
|
843
|
+
// disclosure itself. The unhonourable lines keep their specific `why`; every other rule line carries the
|
|
844
|
+
// whole-policy refusal, because a policy is evaluated as a whole or not at all. ONE builder for both gate
|
|
845
|
+
// routes, for the same byte-equality reason as its siblings above.
|
|
846
|
+
export function policyRefusalUnevaluated(policyText, errors) {
|
|
847
|
+
const fatal = new Map(policyErrorUnevaluated(errors).map((e) => [e.rule, e.why]));
|
|
848
|
+
const out = [];
|
|
849
|
+
for (const raw of policyText.split(/\r?\n/)) {
|
|
850
|
+
const line = raw.split("#", 1)[0].trim();
|
|
851
|
+
if (!line) continue;
|
|
852
|
+
out.push({ rule: line,
|
|
853
|
+
why: fatal.get(line)
|
|
854
|
+
?? "NOT EVALUATED — a rule elsewhere in this policy cannot be honoured as written (named beside "
|
|
855
|
+
+ "its own entry in this list), and a policy is evaluated as a whole or not at all: a verdict "
|
|
856
|
+
+ "from its readable subset would be a verdict on a policy nobody wrote." });
|
|
857
|
+
}
|
|
799
858
|
return out;
|
|
800
859
|
}
|
|
801
860
|
|
|
@@ -813,7 +872,13 @@ export function discoverConfigPolicy(fromDir) {
|
|
|
813
872
|
for (;;) {
|
|
814
873
|
const cand = nodePath.join(dir, ".candor", "config");
|
|
815
874
|
if (fs.existsSync(cand)) {
|
|
816
|
-
|
|
875
|
+
// A config that EXISTS but cannot be READ is configured-but-unusable, which §3.4 makes exit 2 —
|
|
876
|
+
// never a silent "absent" and never an uncaught throw. The bare `readFileSync` here let an EACCES
|
|
877
|
+
// escape as an uncaught exception: node exits 1, which is the POLICY VIOLATION code, and the
|
|
878
|
+
// armed sentinel survived because nothing replaced it. Two wrong answers from one missing catch.
|
|
879
|
+
// The siblings below swallow to null, which is the other wrong answer — an unreadable pin or
|
|
880
|
+
// policy silently not enforced — so all three now refuse the same way.
|
|
881
|
+
const m = readConfigOrRefuse(cand).split(/\r?\n/)
|
|
817
882
|
.map((l) => l.split("#", 1)[0].trim()).filter(Boolean)
|
|
818
883
|
.map((l) => l.match(/^(\S+)\s*(.*)$/)).find((mm) => mm && mm[1].toLowerCase() === "policy");
|
|
819
884
|
if (!m) return null;
|
|
@@ -829,11 +894,11 @@ export function discoverConfigPolicy(fromDir) {
|
|
|
829
894
|
// nearest `.candor/config` walking UP, else null. Read-only + lenient (the caller decides fail-closed).
|
|
830
895
|
export function discoverConfigText(fromDir) {
|
|
831
896
|
const env = process.env.CANDOR_CONFIG;
|
|
832
|
-
if (env) {
|
|
897
|
+
if (env) { return readConfigOrRefuse(env, "CANDOR_CONFIG"); }
|
|
833
898
|
let dir = nodePath.resolve(fromDir);
|
|
834
899
|
for (;;) {
|
|
835
900
|
const cand = nodePath.join(dir, ".candor", "config");
|
|
836
|
-
if (fs.existsSync(cand)) {
|
|
901
|
+
if (fs.existsSync(cand)) { return readConfigOrRefuse(cand); }
|
|
837
902
|
const parent = nodePath.dirname(dir);
|
|
838
903
|
if (parent === dir) return null;
|
|
839
904
|
dir = parent;
|
|
@@ -846,9 +911,31 @@ export function discoverConfigText(fromDir) {
|
|
|
846
911
|
// see named anywhere in the output — can decide the verdict. That is the ambient-input failure this format
|
|
847
912
|
// exists to refuse, and the remedy is the usual one: not to forbid the input, but to make it unable to act
|
|
848
913
|
// unnamed. Same walk as `discoverConfigText`, deliberately, so the two cannot name different files.
|
|
914
|
+
/**
|
|
915
|
+
* Read a `.candor/config` that DISCOVERY has already found, or refuse (exit 2).
|
|
916
|
+
*
|
|
917
|
+
* The three readers in this file each handled an unreadable-but-present config differently: one let the
|
|
918
|
+
* exception escape (node exits 1 — the POLICY VIOLATION code — with a stack trace, and an armed
|
|
919
|
+
* `--gate-json` sentinel left in place), and two swallowed it to `null`, which reads as "no config" and
|
|
920
|
+
* silently drops whatever the file configured: a policy, a baseline, or an engine pin the operator
|
|
921
|
+
* believes is guarding them. §3.4's posture is the unreadable-policy one — configured-but-unusable
|
|
922
|
+
* fails loud — so all three route through here.
|
|
923
|
+
*/
|
|
924
|
+
function readConfigOrRefuse(p, via = null) {
|
|
925
|
+
try {
|
|
926
|
+
return fs.readFileSync(p, "utf8");
|
|
927
|
+
} catch (e) {
|
|
928
|
+
console.error(`candor-ts: ${via ? `${via}=` : ""}${p} exists but could not be read (${e.code ?? e.message}) `
|
|
929
|
+
+ `— failing (exit 2, unevaluable). A config that cannot be read is a guard the operator believes `
|
|
930
|
+
+ `is on: it may name a policy, a baseline or an engine pin, and treating it as absent would run `
|
|
931
|
+
+ `without them.`);
|
|
932
|
+
process.exit(2);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
849
936
|
export function discoverConfigPath(fromDir) {
|
|
850
937
|
const env = process.env.CANDOR_CONFIG;
|
|
851
|
-
if (env) {
|
|
938
|
+
if (env) { readConfigOrRefuse(env, "CANDOR_CONFIG"); return nodePath.resolve(env); }
|
|
852
939
|
let dir = nodePath.resolve(fromDir);
|
|
853
940
|
for (;;) {
|
|
854
941
|
const cand = nodePath.join(dir, ".candor", "config");
|
package/query.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
|
|
26
26
|
import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, discoverConfigText,
|
|
27
27
|
evaluatePolicy, reportNetClasses, resolveReasonClasses, discoverConfigPath,
|
|
28
|
-
policyVocabularyAnchor, policyErrorText,
|
|
28
|
+
policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable,
|
|
29
29
|
fatalPolicyErrors, refusalVerdict,
|
|
30
30
|
unanswerableScoped } from "./policy.mjs";
|
|
31
31
|
import { hasReport } from "./query-core.mjs";
|
|
@@ -232,7 +232,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
232
232
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
233
233
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
234
234
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
235
|
-
const SPEC_VERSION = "0.
|
|
235
|
+
const SPEC_VERSION = "0.27";
|
|
236
236
|
|
|
237
237
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
238
238
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -437,9 +437,122 @@ function resolveGateVerb(rawArgs, { strict = false } = {}) {
|
|
|
437
437
|
// deprecated-alias machinery, because it has NO POSITIONALS: a stray argument is a usage error, never
|
|
438
438
|
// probed as a report or a policy. Kept out of parseCanonical for that reason — the peel helpers exist to
|
|
439
439
|
// accept the old grammar, and there is no old grammar for a verb introduced at ⟨0.24⟩.
|
|
440
|
+
// ── SPEC §3.3.1 ⟨0.27⟩ sink-arming helpers, shared by the gate verb. The scan entry point has its own
|
|
441
|
+
// copies (scan.mjs) because it must not import from this file; the RULES are the spec's, not shared code.
|
|
442
|
+
|
|
443
|
+
/** Learn `--gate-json` and `--policy` from a verb's argv with NO side effects. */
|
|
444
|
+
function preScanGateArgs(av) {
|
|
445
|
+
let gate = null, policy = null, report = null;
|
|
446
|
+
for (let i = 0; i < av.length; i++) {
|
|
447
|
+
const a = av[i], v = av[i + 1];
|
|
448
|
+
if (a !== "--gate-json" && a !== "--policy" && a !== "--report") continue;
|
|
449
|
+
if (v === undefined || (v.startsWith("-") && v !== "-")) continue;
|
|
450
|
+
if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v; else report = v;
|
|
451
|
+
i++;
|
|
452
|
+
}
|
|
453
|
+
return { gate, policy, report };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Artifact identity, not string identity — `--policy /w/P --gate-json ./P` from /w is one file. */
|
|
457
|
+
function sameArtifactPath(a, b) {
|
|
458
|
+
if (!a || !b || a === "-" || b === "-") return false;
|
|
459
|
+
const resolve = (p) => {
|
|
460
|
+
try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
|
|
461
|
+
try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); }
|
|
462
|
+
catch { return null; }
|
|
463
|
+
};
|
|
464
|
+
const x = resolve(a);
|
|
465
|
+
return x !== null && x === resolve(b);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The files the CWD-discovered \`.candor/config\` names, resolved as the loader resolves them. */
|
|
469
|
+
function configDeclaredInputs() {
|
|
470
|
+
const out = [];
|
|
471
|
+
try {
|
|
472
|
+
const disc = discoverConfigPolicy(process.cwd());
|
|
473
|
+
if (disc?.policyPath) out.push([disc.policyPath, "the config policy key"]);
|
|
474
|
+
const cfgPath = discoverConfigPath(process.cwd());
|
|
475
|
+
if (cfgPath) out.push([cfgPath, 'the discovered .candor/config']);
|
|
476
|
+
} catch { /* lenient: the real load refuses on its own terms */ }
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Refuse a sink that names an input of this run, having written nothing. */
|
|
481
|
+
function refuseGateJsonOverInput(gate, other, flag) {
|
|
482
|
+
if (!sameArtifactPath(gate, other)) return;
|
|
483
|
+
console.error(`candor-ts-query: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
|
|
484
|
+
+ `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy and `
|
|
485
|
+
+ `then gate on the wreckage. Nothing was written; give the verdict its own path.`);
|
|
486
|
+
process.exit(2);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** `.candor/config` is never a verdict sink, wherever it is. */
|
|
490
|
+
function refuseGateJsonAtConfig(gate) {
|
|
491
|
+
if (!gate || gate === "-") return;
|
|
492
|
+
const abs = path.resolve(gate);
|
|
493
|
+
if (path.basename(abs) !== "config" || path.basename(path.dirname(abs)) !== ".candor") return;
|
|
494
|
+
console.error(`candor-ts-query: --gate-json ${gate} is a .candor/config — refusing (exit 2). This would `
|
|
495
|
+
+ `destroy the config that configures this run. Nothing was written; give the verdict its own path.`);
|
|
496
|
+
process.exit(2);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Write the fail-closed refusal every later exit inherits unless a real verdict replaces it. */
|
|
500
|
+
function armQueryGateJson(p) {
|
|
501
|
+
try {
|
|
502
|
+
fs.writeFileSync(p, JSON.stringify(
|
|
503
|
+
refusalVerdict(SPEC_VERSION, "the gate did not complete — this document was written when the run "
|
|
504
|
+
+ "STARTED and was never replaced by a verdict, so the run failed, crashed or was killed before "
|
|
505
|
+
+ "it could decide. It is NOT a verdict about the code; see the run's stderr for the cause."),
|
|
506
|
+
null, 1) + "\n");
|
|
507
|
+
} catch (e) {
|
|
508
|
+
console.error(`candor-ts-query: could not arm --gate-json ${p} fail-closed (${e.message})`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
440
512
|
function resolveGateReportVerb(rawArgs) {
|
|
441
513
|
const usageLine = "usage: candor-ts-query gate --report <locator> --policy <file> [--json] [--gate-json <file>]";
|
|
442
514
|
let reportLocator = null, policyFile = null, gateJsonPath = null, json = false;
|
|
515
|
+
// SPEC §3.3.1 ⟨0.27⟩ — ARM FIRST, AND NEVER OVER AN INPUT. A pre-pass with no side effects, so both
|
|
516
|
+
// the collision refusal and the arming precede every exit in the loop below. See the note where the
|
|
517
|
+
// arming used to live for why the previous ordering was wrong.
|
|
518
|
+
{
|
|
519
|
+
const { gate, policy, report } = preScanGateArgs(rawArgs);
|
|
520
|
+
if (gate) {
|
|
521
|
+
refuseGateJsonOverInput(gate, policy, "--policy");
|
|
522
|
+
// §3.3.1 names "a report being read (`gate --report`)" as an input. Writing the verdict there
|
|
523
|
+
// destroys the very report the gate was asked to judge, and the diagnostic then blames the report
|
|
524
|
+
// rather than the collision.
|
|
525
|
+
refuseGateJsonOverInput(gate, report, "--report");
|
|
526
|
+
refuseGateJsonOverInput(gate, process.env.CANDOR_POLICY, "CANDOR_POLICY");
|
|
527
|
+
// THE CONFIG-DECLARED POLICY. This verb's policy ladder falls back to the \`policy\` key of the
|
|
528
|
+
// config discovered from the CWD, and the guard checked only the flags — so the checked-in form,
|
|
529
|
+
// which is the one a CI job has, was destroyed at exit 0 while the flag form refused. The same
|
|
530
|
+
// hole the scan route closed, one route across.
|
|
531
|
+
for (const [p2, label] of configDeclaredInputs()) refuseGateJsonOverInput(gate, p2, label);
|
|
532
|
+
refuseGateJsonAtConfig(gate);
|
|
533
|
+
if (gate !== "-") armQueryGateJson(gate);
|
|
534
|
+
// …AND THE STREAM'S ANALOG OF ARMING. `armQueryGateJson` writes a fail-closed placeholder to a
|
|
535
|
+
// FILE; a stream cannot hold one, so the equivalent is a hook that emits the refusal on any
|
|
536
|
+
// exit-2 path that has not already written a verdict.
|
|
537
|
+
//
|
|
538
|
+
// Without it, this verb exited 2 during ARGUMENT PARSING with stdout EMPTY, while the same verb
|
|
539
|
+
// refusing later from inside the gate streamed the document — the same operator mistake, two
|
|
540
|
+
// answers, decided by how early it was caught. A machine consumer reading an empty stream after
|
|
541
|
+
// exit 2 cannot tell it from a clean gate.
|
|
542
|
+
//
|
|
543
|
+
// Measured at the 0.27 go/no-go: java, swift and ts all had this hole on the `gate` verb, and
|
|
544
|
+
// PART 36's stream rows never reached it because every one of them runs the SCAN route. The row
|
|
545
|
+
// that catches it is now there.
|
|
546
|
+
else {
|
|
547
|
+
process.on("exit", (code) => {
|
|
548
|
+
if (code === 2 && !globalThis.__candorGateVerdictWritten) {
|
|
549
|
+
console.log(JSON.stringify(refusalVerdict(SPEC_VERSION,
|
|
550
|
+
"the gate did not complete — this run exited before a verdict could be produced", null), null, 1));
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
443
556
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
444
557
|
const a = rawArgs[i];
|
|
445
558
|
if (a === "--report") {
|
|
@@ -469,8 +582,18 @@ function resolveGateReportVerb(rawArgs) {
|
|
|
469
582
|
console.error(`candor-ts-query gate: unexpected argument '${a}' — \`gate\` takes no positionals; the report is a --report locator and the policy a --policy file\n ${usageLine}`);
|
|
470
583
|
process.exit(2);
|
|
471
584
|
}
|
|
585
|
+
// ARMING MOVED ABOVE THE FLAG LOOP (SPEC §3.3.1 ⟨0.27⟩).
|
|
586
|
+
//
|
|
587
|
+
// It used to sit here, and the comment justified it with a ⟨0.24⟩ ruling of my own: "a USAGE error was
|
|
588
|
+
// never a gate invocation, so it must write NOTHING". SPEC §3.3 says the opposite in terms — it names
|
|
589
|
+
// an unknown flag as a broken-gate-config exit-2 cause, and §3.1 adds that "if `--gate-json` was
|
|
590
|
+
// requested and the run exits 2 for ANY reason, a fail-closed document is written", calling a
|
|
591
|
+
// carve-out "a fail-open path with a reason attached". The ruling I built here was that carve-out, and
|
|
592
|
+
// the test pinning it pinned a reading the spec had already superseded. The stale green does not care
|
|
593
|
+
// that the operator's shell also failed.
|
|
594
|
+
const _policy = resolvePolicy(policyFile, null).policyFile;
|
|
472
595
|
const prefix = requireReport(reportLocator !== null ? locatorToPrefix(reportLocator) : discoverReportPrefix());
|
|
473
|
-
return { prefix, policyFile:
|
|
596
|
+
return { prefix, policyFile: _policy, gateJsonPath, json };
|
|
474
597
|
}
|
|
475
598
|
|
|
476
599
|
/**
|
|
@@ -1139,7 +1262,13 @@ switch (cmd) {
|
|
|
1139
1262
|
const gwrite = (obj) => {
|
|
1140
1263
|
const text = JSON.stringify(obj, null, 1);
|
|
1141
1264
|
for (const dest of gdests) {
|
|
1142
|
-
|
|
1265
|
+
// THE FLAG IS SET WHERE THE WRITE HAPPENS, not where the gate is entered. It was set on
|
|
1266
|
+
// entering this verb, under the comment "reaching here means the gate ran and will write its
|
|
1267
|
+
// own document" — which is a claim about the future, and false: the `--policy` fallback ladder
|
|
1268
|
+
// can still exit 2 below without writing. That suppressed the pre-pass hook and returned the
|
|
1269
|
+
// run to EMPTY stdout after exit 2, which is the exact channel the hook was added to close.
|
|
1270
|
+
// Caught by the second go/no-go panel; the first flag placement lasted about an hour.
|
|
1271
|
+
if (dest === "-") { globalThis.__candorGateVerdictWritten = true; console.log(text); continue; }
|
|
1143
1272
|
// A SURFACING side-output: an unwritable path is one stderr line, never a raw ENOENT crash whose
|
|
1144
1273
|
// exit 1 would read as a policy violation on a clean run (the scan path's rule).
|
|
1145
1274
|
try {
|
|
@@ -1182,7 +1311,9 @@ switch (cmd) {
|
|
|
1182
1311
|
// uses (SPEC §3.1 makes byte-equality between the two documents the acceptance test — the scan route
|
|
1183
1312
|
// needed this list so a dominating baseline regression could carry the refusal beside it, and a list on
|
|
1184
1313
|
// one route only would break the equality on the very change that repaired the precedence).
|
|
1185
|
-
|
|
1314
|
+
// ⟨0.27⟩ …listing EVERY rule of the refused policy, not only the unhonourable lines — the shared
|
|
1315
|
+
// builder with the scan route (SPEC §3.1's composed-document clause; byte-equality binds the two).
|
|
1316
|
+
if (gfatal.length) { const why = policyErrorText(policyFile, gfatal); console.error(why); grefuse(why, policyRefusalUnevaluated(gtext, gfatal)); }
|
|
1186
1317
|
// ⟨0.24⟩ THE CONFIG FILE THAT SUPPLIED VOCABULARY THE VERDICT USED (SPEC §3.1 `99eb4e9`) — named on a
|
|
1187
1318
|
// REFERENCE, not only on a firing, because the measured harm was a GREEN verdict a vocabulary file made
|
|
1188
1319
|
// green. Omitted when no alias was used, so every other verdict stays byte-identical to before.
|
|
@@ -1305,6 +1436,17 @@ switch (cmd) {
|
|
|
1305
1436
|
// Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
|
|
1306
1437
|
// document, so `candor-ts-query gate … --json | jq` sees pure JSON.
|
|
1307
1438
|
const gsay = (json || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
|
1439
|
+
// ⟨0.27⟩ SPEC §4 — THE ZERO-MATCH DISCLOSURE BELONGS ON THIS ROUTE TOO. Its absence was found by a
|
|
1440
|
+
// cross-engine differential: java and swift disclosed on `gate --report`, rust and ts did not, so
|
|
1441
|
+
// the same typo'd policy was reported by two engines and silently scored as satisfied by two. §4's
|
|
1442
|
+
// MUST carries no route qualifier, and this is the SUPPLY-CHAIN gate — a consumer pointing a policy
|
|
1443
|
+
// at a report someone else produced. ALWAYS on stderr, never through `gsay`: this is a disclosure
|
|
1444
|
+
// about the policy, not a verdict line, and stdout may be carrying the verdict document.
|
|
1445
|
+
for (const raw of gviol.zeroMatch ?? []) {
|
|
1446
|
+
console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
|
|
1447
|
+
+ `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
|
|
1448
|
+
+ `repos; a typo'd layer name otherwise.`);
|
|
1449
|
+
}
|
|
1308
1450
|
for (const x of gviol) gsay(`[${x.rule}] ${x.detail}`);
|
|
1309
1451
|
// ⟨0.21⟩ COMPLETENESS MANIFEST: a gate cannot be green over code candor never analyzed. The scan path
|
|
1310
1452
|
// exits 2 on its OWN `unanalyzed`; here the same manifest travels ON the report, so the same verdict
|
|
@@ -1325,6 +1467,9 @@ switch (cmd) {
|
|
|
1325
1467
|
if (gvocab) gverdictObj.policyVocabulary = gvocab;
|
|
1326
1468
|
gverdictObj.violations = gviol;
|
|
1327
1469
|
if (gunevaluated.length) gverdictObj.unevaluated = gunevaluated;
|
|
1470
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines above carry, in the machine channel,
|
|
1471
|
+
// in the same position the scan route puts it (§3.1's byte-equality MUST binds the two documents).
|
|
1472
|
+
if (gviol.zeroMatch?.length) gverdictObj.zeroMatch = gviol.zeroMatch;
|
|
1328
1473
|
if (gincomplete) { gverdictObj.incomplete = true; gverdictObj.unanalyzed = g.unanalyzed; }
|
|
1329
1474
|
if (g.coverage.length)
|
|
1330
1475
|
gverdictObj.coverage = { uncovered: g.coverage.length, packages: g.coverage.map((c) => c.name) };
|
package/scan-core.mjs
CHANGED
|
@@ -215,6 +215,45 @@ export const MODEL_SDK_RE =
|
|
|
215
215
|
export function isModelSdkPackage(moduleName) {
|
|
216
216
|
return MODEL_SDK_RE.test(moduleName);
|
|
217
217
|
}
|
|
218
|
+
/// SPEC §2 `fs` — for a call ALREADY classified `Fs`, the read/write direction its verb implies.
|
|
219
|
+
/// Returns ["read"], ["write"], ["read","write"], or [] when the verb does not say.
|
|
220
|
+
///
|
|
221
|
+
/// THE EMPTY CASE IS THE POINT. §2: "when `Fs` is reached but its kind is unknown … the field MUST be
|
|
222
|
+
/// omitted rather than guessed. An empty or partial `fs` would be read as a positive claim ('reads but
|
|
223
|
+
/// never writes'), which is the §4 trust contract's forbidden direction." So an unrecognised verb
|
|
224
|
+
/// contributes nothing and the field stays absent — absence means "kind undetermined", never "read-only".
|
|
225
|
+
///
|
|
226
|
+
/// A syntactic refinement of an effect candor already proved, NOT a soundness claim: a wrong direction
|
|
227
|
+
/// misreports a detail, a wrong EFFECT is the cardinal sin, and those are different failures. Deliberately
|
|
228
|
+
/// the same vocabulary and shape as candor-java's `fsKind` and candor-swift's — the surface is spec'd
|
|
229
|
+
/// four-way, and three engines inventing three verb tables for one field is how a shared field stops
|
|
230
|
+
/// meaning one thing. Node's sync/promise variants are handled by stripping the `Sync` suffix rather than
|
|
231
|
+
/// by listing every pair.
|
|
232
|
+
export function fsKind(moduleName, member) {
|
|
233
|
+
if (!member) return [];
|
|
234
|
+
const m = member.endsWith("Sync") ? member.slice(0, -4) : member;
|
|
235
|
+
// Reads the source AND writes the destination, in one call.
|
|
236
|
+
if (m === "copyFile" || m === "cp") return ["read", "write"];
|
|
237
|
+
const WRITE = new Set([
|
|
238
|
+
"writeFile", "appendFile", "write", "writev", "mkdir", "mkdtemp", "rmdir", "rm", "unlink",
|
|
239
|
+
"rename", "truncate", "ftruncate", "chmod", "fchmod", "lchmod", "chown", "fchown", "lchown",
|
|
240
|
+
"utimes", "futimes", "lutimes", "symlink", "link", "createWriteStream", "outputFile", "ensureDir",
|
|
241
|
+
"ensureFile", "emptyDir", "remove", "move", "outputJson", "writeJson", "writeJSON",
|
|
242
|
+
]);
|
|
243
|
+
const READ = new Set([
|
|
244
|
+
"readFile", "readdir", "read", "readv", "stat", "lstat", "fstat", "statfs", "access", "exists",
|
|
245
|
+
"realpath", "readlink", "createReadStream", "opendir", "watch", "watchFile", "readJson",
|
|
246
|
+
"readJSON", "pathExists", "lstatSync",
|
|
247
|
+
]);
|
|
248
|
+
if (WRITE.has(m)) return ["write"];
|
|
249
|
+
if (READ.has(m)) return ["read"];
|
|
250
|
+
// `open`/`openSync` take a MODE — "r", "w", "a" — so the verb alone does not say. Deliberately no claim
|
|
251
|
+
// rather than a guess at the common case.
|
|
252
|
+
if (m.startsWith("write") || m.startsWith("append")) return ["write"];
|
|
253
|
+
if (m.startsWith("read")) return ["read"];
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
218
257
|
export function kappa(moduleName, member) {
|
|
219
258
|
for (const [mre, vre, eff] of KAPPA_RULES) {
|
|
220
259
|
if (mre.test(moduleName) && (!vre || vre.test(member))) return eff;
|
package/scan.mjs
CHANGED
|
@@ -28,11 +28,11 @@ import { fileURLToPath } from "node:url";
|
|
|
28
28
|
import { createRequire } from "node:module";
|
|
29
29
|
import { execFileSync } from "node:child_process";
|
|
30
30
|
import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText,
|
|
31
|
-
reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText,
|
|
31
|
+
reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, fatalPolicyErrors, refusalVerdict,
|
|
32
32
|
netClassResolver, resolveReasonClasses } from "./policy.mjs";
|
|
33
33
|
import { unverifiedHoleRule, ruleUpgrade, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
|
|
34
34
|
import { printAgents } from "./contract.mjs";
|
|
35
|
-
import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql,
|
|
35
|
+
import { isTestPath, kappa, kappaKnows, fsKind, commandHeadEffects, hostLiteral, tablesInSql,
|
|
36
36
|
modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
|
|
37
37
|
import { emitSurface } from "./surface.mjs";
|
|
38
38
|
|
|
@@ -44,7 +44,18 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
44
44
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
45
45
|
// Reused, never re-littered.
|
|
46
46
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
47
|
-
const SPEC_VERSION = "0.
|
|
47
|
+
const SPEC_VERSION = "0.27";
|
|
48
|
+
/** The `deps` / `CANDOR_DEPS` separator set — ASCII whitespace plus `:` and `,`.
|
|
49
|
+
*
|
|
50
|
+
* ONE CONSTANT BECAUSE TWO SPELLINGS WERE A SILENT GREEN. The §3.3.1 sink-over-input guard and the
|
|
51
|
+
* dep-chain loader each carried their own regex; they disagreed on `\n`, so a newline-separated
|
|
52
|
+
* `CANDOR_DEPS` was one unresolvable token to the guard and two real paths to the loader. A
|
|
53
|
+
* `--gate-json` naming one of those reports was therefore unguarded: arming overwrote it, the scan
|
|
54
|
+
* finished, and the operator's dep report ended up holding this run's `{"ok": true}` at exit 0.
|
|
55
|
+
*
|
|
56
|
+
* NOT JS `\s`, which includes U+00A0: these are PATHS, and a non-breaking space inside one is part of
|
|
57
|
+
* the path, not a separator — java, rust and swift all treat it that way. */
|
|
58
|
+
const DEP_SEPARATORS = /[ \t\n\r:,]+/;
|
|
48
59
|
|
|
49
60
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
50
61
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -119,6 +130,164 @@ See https://github.com/tombaldwin/candor`);
|
|
|
119
130
|
// value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
|
|
120
131
|
const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--workspace] [--agents] [--version] [--help]";
|
|
121
132
|
const argv = process.argv.slice(2);
|
|
133
|
+
// Declared HERE, above the sink guard, because the guard calls `loadCandorConfig` and that reads
|
|
134
|
+
// these: left below, they were in the temporal dead zone, the call threw, and the `catch` around it
|
|
135
|
+
// swallowed the throw — so the config channel the guard exists to enumerate was silently empty and a
|
|
136
|
+
// config-declared policy was destroyed at exit 0 again. A `catch` that hides a programming error is a
|
|
137
|
+
// fail-open with a reason attached.
|
|
138
|
+
const CONFIG_KEYS = new Set(["policy", "baseline", "strict", "no-ambient", "closed-world", "taint", "deps", "unknown-alias", "net-partner", "unknown-ratchet", "engine"]);
|
|
139
|
+
const CONFIG_KEYS_IMPLEMENTED = new Set(["policy", "baseline", "deps", "unknown-ratchet", "engine"]);
|
|
140
|
+
|
|
141
|
+
// ── SPEC §3.3.1 ⟨0.27⟩ ARM FIRST, AND NEVER OVER AN INPUT.
|
|
142
|
+
//
|
|
143
|
+
// This pre-pass learns the sink and this run's inputs with NO side effects, before the parse loop
|
|
144
|
+
// below, for two reasons the loop cannot serve:
|
|
145
|
+
//
|
|
146
|
+
// (1) the loop's own `unknown flag` exit(2) runs BEFORE the arming did, so `--frobnicate --gate-json G`
|
|
147
|
+
// exited leaving the PREVIOUS run's green document at G. §3.3 names an unknown flag as a
|
|
148
|
+
// broken-gate-config exit-2 cause, which MUST leave a refusal — the contract cannot depend on
|
|
149
|
+
// argv order, and it did.
|
|
150
|
+
// (2) arming WRITES, so a sink that names the policy DESTROYS it. Measured: `--policy P --gate-json P`
|
|
151
|
+
// on violating code exited 0 with `ok: true` — the armed JSON replaced P, every line of it parsed
|
|
152
|
+
// as an unknown rule, and the gate ran over zero rules. A machine-readable all-clear produced by
|
|
153
|
+
// deleting the question.
|
|
154
|
+
const preScan = (av) => {
|
|
155
|
+
let gate = null, policy = null, target = null;
|
|
156
|
+
for (let i = 0; i < av.length; i++) {
|
|
157
|
+
const a = av[i], v = av[i + 1];
|
|
158
|
+
if (a === "--gate-json" || a === "--policy" || a === "--out") {
|
|
159
|
+
if (v === undefined || (v !== "-" && v.startsWith("--"))) continue;
|
|
160
|
+
if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v;
|
|
161
|
+
i++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// The scan TARGET, needed to discover the `.candor/config` whose `policy` key may name an input
|
|
165
|
+
// this sink must not overwrite.
|
|
166
|
+
if (!a.startsWith("-") && target === null) target = a;
|
|
167
|
+
}
|
|
168
|
+
return { gate, policy, target };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Every path this run READS, whatever channel it arrived through (SPEC §3.3.1 ⟨0.27⟩).
|
|
172
|
+
//
|
|
173
|
+
// THE FIRST VERSION OF THIS GUARD KEYED ON THE FLAG. With the policy declared by `.candor/config` — the
|
|
174
|
+
// checked-in form, i.e. the one a CI job actually has — `--gate-json <that policy>` destroyed it and
|
|
175
|
+
// exited 0 with `"ok": true` in ALL FOUR ENGINES. A policy does not change what it is according to how
|
|
176
|
+
// the operator handed it over. The config is read LENIENTLY (no exit, no diagnostic): this runs before
|
|
177
|
+
// the real config load and must not pre-empt its refusal.
|
|
178
|
+
// Artifact identity, not string identity: `--policy /w/P --gate-json ./P` from /w is one file, and the
|
|
179
|
+
// engine that already had this guard compared path spellings and lost to exactly that. realpath resolves
|
|
180
|
+
// `.`, `..` and symlinks; for a sink that does not exist yet its parent is resolved instead.
|
|
181
|
+
const sameArtifact = (a, b) => {
|
|
182
|
+
if (!a || !b || a === "-" || b === "-") return false;
|
|
183
|
+
const resolve = (p) => {
|
|
184
|
+
try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
|
|
185
|
+
try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); } catch { return null; }
|
|
186
|
+
};
|
|
187
|
+
const x = resolve(a);
|
|
188
|
+
return x !== null && x === resolve(b);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const runInputs = (target, policyFlag) => {
|
|
192
|
+
const out = [];
|
|
193
|
+
if (policyFlag) out.push([policyFlag, "--policy"]);
|
|
194
|
+
for (const [v, label] of [["CANDOR_POLICY", "CANDOR_POLICY"], ["CANDOR_BASELINE", "CANDOR_BASELINE"],
|
|
195
|
+
["CANDOR_CONFIG", "CANDOR_CONFIG"]]) {
|
|
196
|
+
if (process.env[v]) out.push([process.env[v], label]);
|
|
197
|
+
}
|
|
198
|
+
// ONE DEFINITION, shared with the loader — see DEP_SEPARATORS. This comment used to claim it was
|
|
199
|
+
// "the separator set the dep loader accepts" while spelling a DIFFERENT set one screen away, and the
|
|
200
|
+
// gap between the two was a silent green: a newline-separated `CANDOR_DEPS` registered here as one
|
|
201
|
+
// unresolvable token, so the guard protected nothing, while the loader split it into real paths.
|
|
202
|
+
// `--gate-json` naming one of those reports then DESTROYED it and the run exited 0 with `ok: true`
|
|
203
|
+
// written over the operator's input — §3.3.1's own words, "a machine-readable all-clear produced by
|
|
204
|
+
// deleting the question". Measured live before this change.
|
|
205
|
+
for (const d of (process.env.CANDOR_DEPS ?? "").split(DEP_SEPARATORS).filter(Boolean)) {
|
|
206
|
+
out.push([d, "a CANDOR_DEPS report"]);
|
|
207
|
+
// A DIRECTORY DEP IS EVERY REPORT INSIDE IT. `deps` accepts a directory — `--workspace` writes
|
|
208
|
+
// `.candor/deps/` and hands that back, so it is the common spelling — and the loader then walks it
|
|
209
|
+
// and reads each `*.json`. Registering only the DIRECTORY left those files unnamed, so
|
|
210
|
+
// `--gate-json <depdir>/lib.json` was unguarded: arming destroyed the operator's dep report, the
|
|
211
|
+
// run chained the wreckage and exited 0 with `ok: true` over it. Measured in all four engines.
|
|
212
|
+
//
|
|
213
|
+
// EXPANDED HERE, not by making `sameArtifact` directory-aware. That was tried and is far too
|
|
214
|
+
// broad: the scan TARGET is an input too, and a verdict written into the tree being scanned is
|
|
215
|
+
// ordinary usage — the general rule refused it and took 33 tests with it. Only a DEP directory has
|
|
216
|
+
// its CONTENTS read, so only a dep directory expands.
|
|
217
|
+
try {
|
|
218
|
+
if (fs.statSync(d).isDirectory()) {
|
|
219
|
+
for (const f of fs.readdirSync(d)) {
|
|
220
|
+
if (f.endsWith(".json") && !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json")
|
|
221
|
+
&& !f.endsWith(".locs.json")) {
|
|
222
|
+
out.push([path.join(d, f), "a CANDOR_DEPS report"]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch { /* not a directory, or unreadable — the token itself is still registered above */ }
|
|
227
|
+
}
|
|
228
|
+
// …AND THE CONFIG'S OWN KEYS, THROUGH THE ENGINE'S OWN LOADER AND ITS OWN DISCOVERY. This used to
|
|
229
|
+
// re-derive both, and a review took it apart: the home directory was computed as parent-of-parent
|
|
230
|
+
// unconditionally where the loader only steps out of a trailing `.candor/` segment, so an out-of-tree
|
|
231
|
+
// CANDOR_CONFIG had its relative values anchored one level too high and the guard protected a path
|
|
232
|
+
// the run never reads. A second parser is a second set of holes; `loadCandorConfig` is called inside a
|
|
233
|
+
// try so it can still refuse for real a moment later.
|
|
234
|
+
const cfgFile = discoverConfigFile(target ?? ".");
|
|
235
|
+
if (cfgFile) {
|
|
236
|
+
out.push([cfgFile, "the discovered .candor/config"]);
|
|
237
|
+
try {
|
|
238
|
+
const cfg = loadCandorConfig(target ?? ".", { lenient: true });
|
|
239
|
+
for (const key of ["policy", "baseline"]) {
|
|
240
|
+
if (cfg[key]) out.push([cfg[key], `the config's \`${key}\``]);
|
|
241
|
+
}
|
|
242
|
+
for (const one of (cfg.deps ?? "").split(":").filter(Boolean)) {
|
|
243
|
+
out.push([one, "the config's `deps`"]);
|
|
244
|
+
}
|
|
245
|
+
} catch { /* lenient: the real load refuses on its own terms */ }
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
const { gate, policy, target: preTarget } = preScan(argv);
|
|
252
|
+
for (const [other, flag] of (gate ? runInputs(preTarget, policy) : [])) {
|
|
253
|
+
if (gate && sameArtifact(gate, other)) {
|
|
254
|
+
console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
|
|
255
|
+
+ `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy `
|
|
256
|
+
+ `and then gate on the wreckage. Nothing was written; give the verdict its own path.`);
|
|
257
|
+
process.exit(2);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// `.candor/config` is never a verdict sink, wherever it is. The per-input checks above can only name
|
|
261
|
+
// inputs the run was TOLD about; the config is DISCOVERED by walking up from the target, so by the
|
|
262
|
+
// time its path is known the arming has already destroyed it. A check on the SHAPE needs no
|
|
263
|
+
// discovery, so it runs before the first write and covers a config found anywhere up the tree.
|
|
264
|
+
if (gate && gate !== "-") {
|
|
265
|
+
const abs = path.resolve(gate);
|
|
266
|
+
if (path.basename(abs) === "config" && path.basename(path.dirname(abs)) === ".candor") {
|
|
267
|
+
console.error(`candor-ts: --gate-json ${gate} is a .candor/config — refusing (exit 2). The verdict `
|
|
268
|
+
+ `is armed before the config is read, so this would destroy the config that configures this `
|
|
269
|
+
+ `run. Nothing was written; give the verdict its own path.`);
|
|
270
|
+
process.exit(2);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (gate && sameArtifact(gate, process.env.CANDOR_CONFIG)) {
|
|
274
|
+
console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
|
|
275
|
+
process.exit(2);
|
|
276
|
+
}
|
|
277
|
+
if (gate && gate !== "-") armGateJsonFailClosed(gate);
|
|
278
|
+
}
|
|
279
|
+
// ⟨0.27⟩ THE STREAM SINK'S ANALOG OF ARMING — SPEC §3.1's stream-sink clause. `--gate-json -` cannot be
|
|
280
|
+
// armed (a stream has no stale previous document, and a placeholder would put TWO documents in a
|
|
281
|
+
// consumer's pipe), but the document-on-every-exit rule applies in full: an exit-2 cause that fires
|
|
282
|
+
// before the gate tail — an unknown flag, a valueless gate-adjacent flag, a missing target — must still
|
|
283
|
+
// leave the fail-closed refusal as the stream's only content. Measured: an unhonourable policy wrote the
|
|
284
|
+
// refusal to stdout while an unknown flag exited 2 leaving stdout EMPTY — the same operator mistake,
|
|
285
|
+
// answered or not according to which early exit fired, and an empty stream throws the consumer back to
|
|
286
|
+
// scraping stderr. File sinks need nothing here: the arming above already left a refusal in place.
|
|
287
|
+
const preGateSink = preScan(argv).gate;
|
|
288
|
+
const refuseEarlyToStream = (why) => {
|
|
289
|
+
if (preGateSink === "-") console.log(JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1));
|
|
290
|
+
};
|
|
122
291
|
let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, gateJsonPath = null, allowJs = false, wantAgents = false, wantJson = false, wantWorkspace = false, wantDepInits = false;
|
|
123
292
|
for (let i = 0; i < argv.length; i++) {
|
|
124
293
|
const a = argv[i];
|
|
@@ -132,7 +301,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
132
301
|
else if (a === "--dep-inits") wantDepInits = true;
|
|
133
302
|
else if (a === "--out" || a === "--policy" || a === "--gate-json") {
|
|
134
303
|
const v = argv[i + 1];
|
|
135
|
-
if (v === undefined || v.startsWith("--")) {
|
|
304
|
+
if (v === undefined || v.startsWith("--")) {
|
|
305
|
+
console.error(`candor-ts: ${a} requires a value (${usage})`);
|
|
306
|
+
refuseEarlyToStream(`${a} requires a value`);
|
|
307
|
+
process.exit(2);
|
|
308
|
+
}
|
|
136
309
|
if (a === "--out") outPrefix = v; else if (a === "--policy") policyPath = v; else gateJsonPath = v;
|
|
137
310
|
i++;
|
|
138
311
|
}
|
|
@@ -140,13 +313,27 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
140
313
|
// (SPEC §6.2/§7). `-h`/`-V`/`--help`/`--version` are print-and-exit modes consumed above, so by here
|
|
141
314
|
// a single-dash token (`-x`, the typo `-policy`) can only be a mistake; treating it as the scan
|
|
142
315
|
// target would silently scan the wrong thing.
|
|
143
|
-
else if (a.startsWith("-")) {
|
|
316
|
+
else if (a.startsWith("-")) {
|
|
317
|
+
console.error(`candor-ts: unknown flag ${a} (${usage})`);
|
|
318
|
+
// ⟨0.27⟩ §3.3 names an unknown flag as a broken-gate-config exit-2 cause; the stream sink gets the
|
|
319
|
+
// refusal document too (see refuseEarlyToStream — the file sink is already armed).
|
|
320
|
+
refuseEarlyToStream(`unknown flag ${a}`);
|
|
321
|
+
process.exit(2);
|
|
322
|
+
}
|
|
144
323
|
else if (target === null) target = a;
|
|
145
324
|
else if (outPrefix === null) outPrefix = a; // legacy positional prefix
|
|
146
|
-
else {
|
|
325
|
+
else {
|
|
326
|
+
console.error(`candor-ts: unexpected extra argument ${a} (${usage})`);
|
|
327
|
+
refuseEarlyToStream(`unexpected extra argument ${a}`);
|
|
328
|
+
process.exit(2);
|
|
329
|
+
}
|
|
147
330
|
}
|
|
148
331
|
if (wantAgents) { printAgents(); process.exit(0); }
|
|
149
|
-
if (target === null) {
|
|
332
|
+
if (target === null) {
|
|
333
|
+
console.error(usage);
|
|
334
|
+
refuseEarlyToStream("no scan target");
|
|
335
|
+
process.exit(2);
|
|
336
|
+
}
|
|
150
337
|
|
|
151
338
|
// ---- .candor/config (candor-spec §config; the checked-in alternative to the CANDOR_* env vars) -----
|
|
152
339
|
// Discovery is anchored to the SCAN TARGET (walk up from the target dir to the repo root's
|
|
@@ -156,14 +343,15 @@ if (target === null) { console.error(usage); process.exit(2); }
|
|
|
156
343
|
// never vanish silently (the §6.2 unreadable-policy posture). Only genuine absence is an empty config.
|
|
157
344
|
// Keys are the shared FAMILY vocabulary; a key OUTSIDE it warns (typo protection: a misspelt `policy`
|
|
158
345
|
// must not silently drop the gate).
|
|
159
|
-
|
|
346
|
+
// ⟨0.27⟩ `engine` (SPEC §3.4) is RECOGNIZED and IMPLEMENTED here — see enforceEnginePin. It must be in
|
|
347
|
+
// BOTH sets: missing from the vocabulary it is reported as unknown, and missing from IMPLEMENTED it is
|
|
348
|
+
// disclosed as inert — both tell an operator their pin was ignored while the engine is enforcing it.
|
|
160
349
|
// The subset this engine actually wires to a mode — `policy` (the gate), `baseline` (AS-EFF-005),
|
|
161
350
|
// `deps` (the cross-package report chain) and `unknown-ratchet` (the baseline guard's opt-in). The rest
|
|
162
351
|
// of the vocabulary is spec-inert HERE: it drives other engines' gates. But a checked-in enforcement key
|
|
163
352
|
// that silently does nothing is a DECLARED-GATE-SILENTLY-OFF — the reader believes the gate is on — so
|
|
164
353
|
// an inert recognized key DISCLOSES loudly (stderr only; verdict/report/exit code untouched) instead of
|
|
165
354
|
// staying mute. Same posture + message shape as candor-scan's CONFIG_KEYS_IMPLEMENTED.
|
|
166
|
-
const CONFIG_KEYS_IMPLEMENTED = new Set(["policy", "baseline", "deps", "unknown-ratchet"]);
|
|
167
355
|
// The ANCHOR a config file's RELATIVE path values (policy/deps) resolve against: the repo the config
|
|
168
356
|
// belongs to — the parent of its `.candor/` directory (the standard layout; candor-init scaffolds
|
|
169
357
|
// `policy arch.policy` meaning the repo root's), else the config file's own directory. NEVER the
|
|
@@ -174,28 +362,139 @@ function configAnchor(file) {
|
|
|
174
362
|
const dir = path.dirname(path.resolve(file));
|
|
175
363
|
return path.basename(dir) === ".candor" ? path.dirname(dir) : dir;
|
|
176
364
|
}
|
|
177
|
-
|
|
365
|
+
// ⟨0.27⟩ SPEC §3.4 `engine` — THE ENGINE↔BASELINE COUPLING, enforced instead of hoped for.
|
|
366
|
+
//
|
|
367
|
+
// The committed baseline is a snapshot of what ONE engine build reported, and an engine swap is
|
|
368
|
+
// baseline-invalidating. What a PIN adds over the provenance checks already in place is that it is
|
|
369
|
+
// DECLARATIVE — a build id is a hash nobody can write down, so the intended version lived in CI config,
|
|
370
|
+
// decoupled from the baseline it is married to. It also tells tooling which engine to FETCH, and it
|
|
371
|
+
// reaches a run with NO baseline configured at all.
|
|
372
|
+
//
|
|
373
|
+
// TWO OF THE FIVE VERDICTS MUST NOT CHANGE THE EXIT CODE: an ABSENT pin (the key is opt-in by
|
|
374
|
+
// construction) and an UNDETERMINED one, where §3.1's unanswerable-condition rule applies — disclosed,
|
|
375
|
+
// never scored, INCLUDING as satisfied. Exit 2 on a mismatch, never 1: unevaluable, not violating.
|
|
376
|
+
//
|
|
377
|
+
// A pin qualified for another implementation is not ours to check — one config serves the whole family,
|
|
378
|
+
// and the family versions as a LADDER, so a bare version in a polyglot repo would fail whichever engine
|
|
379
|
+
// had not yet caught up.
|
|
380
|
+
const ENGINE_IMPLS = new Set(["java", "rust", "ts", "swift", "agents"]);
|
|
381
|
+
function enginePinFor(text, implName) {
|
|
382
|
+
let wild = null, qual = null, bad = false;
|
|
383
|
+
for (const rawLine of (text ?? "").split("\n")) {
|
|
384
|
+
const line = rawLine.split("#")[0].trim();
|
|
385
|
+
if (!line) continue;
|
|
386
|
+
const parts = line.split(/\s+/);
|
|
387
|
+
if (parts[0].toLowerCase() !== "engine") continue;
|
|
388
|
+
const rest = parts.slice(1);
|
|
389
|
+
// Two lines that DISAGREE about the same key are kept BOTH, so they cannot parse as a version and
|
|
390
|
+
// surface as malformed. One silently discarding the other is the failure this key exists to stop.
|
|
391
|
+
const slot = (cur, v) => (cur !== null && cur !== v ? `${cur} / ${v}` : v);
|
|
392
|
+
// A KNOWN QUALIFIER DECIDES OWNERSHIP BEFORE ARITY. Checking the one-token case first made `engine swift` a WILDCARD pin whose version is the literal "swift" -> MALFORMED -> exit 2 in every engine, so one operator forgetting a version on a qualified line killed the whole family. SPEC 3.4 says the skip is whole-line 'whatever follows it' -- and nothing following it is a case of that too.
|
|
393
|
+
if (rest.length && ENGINE_IMPLS.has(rest[0].toLowerCase())) {
|
|
394
|
+
if (rest[0].toLowerCase() === implName) { if (rest.length === 2) qual = slot(qual, rest[1]); else bad = true; }
|
|
395
|
+
continue; // another impl's line, whatever follows it
|
|
396
|
+
}
|
|
397
|
+
if (rest.length === 0) bad = true;
|
|
398
|
+
else if (rest.length === 1) wild = slot(wild, rest[0]);
|
|
399
|
+
else bad = true;
|
|
400
|
+
}
|
|
401
|
+
if (bad) return "<unreadable>";
|
|
402
|
+
// AN UNREADABLE UNQUALIFIED LINE IS NOT HIDDEN BY A QUALIFIED PIN. `qual ?? wild` returned the qualifi
|
|
403
|
+
// ed value, so `engine garbage` beside a good qualified line passed SILENTLY here while candor-java exited
|
|
404
|
+
// 2 — the exact mirror of the bug just fixed in java, four engines the other way. Unreadability is a property of the LINE; precedence only decides which VERSION applies.
|
|
405
|
+
if (wild !== null && normalizePinVersion(wild) === null) return wild;
|
|
406
|
+
return qual ?? wild;
|
|
407
|
+
}
|
|
408
|
+
function normalizePinVersion(raw) {
|
|
409
|
+
const s = String(raw ?? "").trim().replace(/^[vV]/, "");
|
|
410
|
+
if (!/^\d+\.\d+(\.\d+)?$/.test(s)) return null;
|
|
411
|
+
return s.split(".").length === 2 ? `${s}.0` : s;
|
|
412
|
+
}
|
|
413
|
+
function enforceEnginePin(targetPath) {
|
|
414
|
+
const pin = enginePinFor(discoverConfigText(targetPath), "ts");
|
|
415
|
+
if (pin === null || pin === undefined) return; // ABSENT
|
|
416
|
+
const want = normalizePinVersion(pin);
|
|
417
|
+
if (want === null) {
|
|
418
|
+
console.error(`candor-ts: .candor/config has an \`engine\` line that is not an engine version.`);
|
|
419
|
+
console.error(` want \`engine <version>\` (e.g. \`engine v${PKG_VERSION}\`) or \`engine <impl> <version>\``);
|
|
420
|
+
console.error(` (e.g. \`engine ts v${PKG_VERSION}\`) for a repo scanned by more than one engine.`);
|
|
421
|
+
console.error(` Failing (exit 2) rather than ignoring it: a pin that cannot be read is a`);
|
|
422
|
+
console.error(` guard the operator believes is on.`);
|
|
423
|
+
process.exit(2);
|
|
424
|
+
}
|
|
425
|
+
const running = normalizePinVersion(PKG_VERSION) ?? String(PKG_VERSION ?? "").trim();
|
|
426
|
+
if (!running || running === "unknown") { // UNDETERMINED — disclose, never score
|
|
427
|
+
console.error(`candor-ts: .candor/config pins engine ${pin}, and this build does not know its own release,`);
|
|
428
|
+
console.error(` so the pin CANNOT be checked. Disclosed, not scored — neither passed nor failed.`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (want === running) return; // MATCH
|
|
432
|
+
console.error(`candor-ts: .candor/config pins engine ${pin} but this build is candor-ts ${PKG_VERSION}.`);
|
|
433
|
+
console.error(` The pin and the committed baseline move together — a newer engine resolves more`);
|
|
434
|
+
console.error(` dispatch, so its report is not comparable with a baseline the pinned engine wrote.`);
|
|
435
|
+
console.error(` Either run the pinned engine, or update the pin and regenerate the baseline in the`);
|
|
436
|
+
console.error(` same change. Exit 2 (unevaluable), not 1 — this is not a policy violation.`);
|
|
437
|
+
// ONE call, not two: an insertion script matched both pin branches to this single exit, and the
|
|
438
|
+
// duplicate put TWO documents on the stream — which parses as neither. The other branch (a build that
|
|
439
|
+
// cannot determine its own release) RETURNS rather than exiting: disclosed, not scored, so no refusal
|
|
440
|
+
// belongs there.
|
|
441
|
+
refuseEarlyToStream(`.candor/config pins engine ${pin}, which this build does not satisfy`);
|
|
442
|
+
process.exit(2);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// WHICH config file this run reads, with NO side effects (SPEC §3.4). Extracted so the §3.3.1 sink
|
|
446
|
+
// guard asks the same question the loader answers instead of re-deriving the walk — a review took the
|
|
447
|
+
// guard's own copy apart on exactly that divergence.
|
|
448
|
+
function discoverConfigFile(targetPath) {
|
|
449
|
+
const env = process.env.CANDOR_CONFIG;
|
|
450
|
+
if (env) {
|
|
451
|
+
try { return fs.statSync(env).isFile() ? env : null; } catch { return null; }
|
|
452
|
+
}
|
|
453
|
+
let dir = path.resolve(targetPath ?? ".");
|
|
454
|
+
try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
|
|
455
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
456
|
+
const cand = path.join(d, ".candor", "config");
|
|
457
|
+
if (fs.existsSync(cand)) return cand;
|
|
458
|
+
if (path.dirname(d) === d) break; // filesystem root
|
|
459
|
+
}
|
|
460
|
+
return fs.existsSync(".candor/config") ? ".candor/config" : null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/// `lenient: true` THROWS where this would otherwise `process.exit(2)`.
|
|
464
|
+
///
|
|
465
|
+
/// The collision pre-pass needs the config's DECLARED input paths before the sink is armed, and it
|
|
466
|
+
/// wrapped this call in a try under the comment "the real load refuses on its own terms" — which
|
|
467
|
+
/// assumes the failure arrives as an exception. It does not: `process.exit` is not catchable, so an
|
|
468
|
+
/// unreadable config killed the run INSIDE that try, before arming, leaving a pre-seeded green verdict
|
|
469
|
+
/// intact at the file sink. SPEC §3.3's own words for that: "a refusal that writes nothing leaves the
|
|
470
|
+
/// previous run's green document on disk." java answers the same input with `refused: true`.
|
|
471
|
+
///
|
|
472
|
+
/// Nothing is lost by leniency here. If the config cannot be read it declares no inputs anyone can
|
|
473
|
+
/// name, so the collision check over them is vacuous; arming then proceeds and the REAL load refuses a
|
|
474
|
+
/// moment later — now with the sink armed, so the refusal reaches it. A second parser was the
|
|
475
|
+
/// alternative, and a second parser is a second set of holes.
|
|
476
|
+
function loadCandorConfig(targetPath, { lenient = false } = {}) {
|
|
178
477
|
let file = process.env.CANDOR_CONFIG ?? null;
|
|
179
478
|
if (file !== null) {
|
|
180
479
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
480
|
+
if (lenient) throw new Error(`CANDOR_CONFIG set but ${file} is not a readable file`);
|
|
181
481
|
console.error(`candor-ts: CANDOR_CONFIG set but ${file} is not a readable file — failing (exit 2)`);
|
|
482
|
+
// The config is the EARLIEST exit-2 cause, and the one the stream sink is least likely to be
|
|
483
|
+
// armed for — which is exactly why it was the last one still leaving stdout empty. Found by
|
|
484
|
+
// PART 36 (b11), a row written before this line was.
|
|
485
|
+
refuseEarlyToStream(`CANDOR_CONFIG set but ${file} is not a readable file`);
|
|
182
486
|
process.exit(2);
|
|
183
487
|
}
|
|
184
488
|
} else {
|
|
185
|
-
|
|
186
|
-
try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
|
|
187
|
-
for (let d = dir; ; d = path.dirname(d)) {
|
|
188
|
-
const cand = path.join(d, ".candor", "config");
|
|
189
|
-
if (fs.existsSync(cand)) { file = cand; break; }
|
|
190
|
-
if (path.dirname(d) === d) break; // filesystem root
|
|
191
|
-
}
|
|
192
|
-
if (file === null && fs.existsSync(".candor/config")) file = ".candor/config";
|
|
489
|
+
file = discoverConfigFile(targetPath);
|
|
193
490
|
if (file === null) return {};
|
|
194
491
|
}
|
|
195
492
|
let text;
|
|
196
493
|
try { text = fs.readFileSync(file, "utf8"); }
|
|
197
494
|
catch (e) {
|
|
495
|
+
if (lenient) throw new Error(`config ${file} exists but could not be read (${e.message})`);
|
|
198
496
|
console.error(`candor-ts: config ${file} exists but could not be read (${e.message}) — failing (exit 2)`);
|
|
497
|
+
refuseEarlyToStream(`config ${file} exists but could not be read`);
|
|
199
498
|
process.exit(2);
|
|
200
499
|
}
|
|
201
500
|
const cfg = {};
|
|
@@ -230,10 +529,50 @@ function loadCandorConfig(targetPath) {
|
|
|
230
529
|
const anchor = configAnchor(file);
|
|
231
530
|
if (cfg.policy) cfg.policy = path.resolve(anchor, cfg.policy);
|
|
232
531
|
if (cfg.baseline) cfg.baseline = path.resolve(anchor, cfg.baseline);
|
|
233
|
-
|
|
532
|
+
// ASCII whitespace ONLY, like java and swift: these are PATHS, and JS `\s` includes U+00A0, so a dep
|
|
533
|
+
// path containing a non-breaking space split into two halves that were then both "skipped" — a green
|
|
534
|
+
// run with the dep silently unchained, where java and rust loaded it.
|
|
535
|
+
if (cfg.deps) cfg.deps = cfg.deps.split(/[ \t:,]+/).filter(Boolean).map((t) => path.resolve(anchor, t)).join(":");
|
|
234
536
|
return cfg;
|
|
235
537
|
}
|
|
538
|
+
// ⟨0.24⟩/⟨0.27⟩ ARM THE VERDICT FAIL-CLOSED. Every exit path then leaves a refusal behind unless the run
|
|
539
|
+
// got far enough to replace it with a real verdict. A review found the pin refusal leaving the PREVIOUS
|
|
540
|
+
// run's document on disk — a CI wrapper reading the artifact instead of the exit code then reports a pass
|
|
541
|
+
// over a run that refused. candor-java's `armGateJson` is the model; the wording is about the RUN, not
|
|
542
|
+
// about the code.
|
|
543
|
+
//
|
|
544
|
+
// A `function` declaration, not a `const`: it is CALLED from the pre-pass above the arg loop, and only a
|
|
545
|
+
// hoisted declaration can be. The write is inlined rather than calling `writeAtomic` for the same reason
|
|
546
|
+
// in reverse — that helper is a `const` declared ~4700 lines below, so calling it would be a
|
|
547
|
+
// temporal-dead-zone throw.
|
|
548
|
+
function armGateJsonFailClosed(p) {
|
|
549
|
+
try {
|
|
550
|
+
const _tmp = `${p}.${process.pid}.arm`;
|
|
551
|
+
fs.writeFileSync(_tmp, JSON.stringify({
|
|
552
|
+
spec: SPEC_VERSION, ok: false, refused: true,
|
|
553
|
+
reason: "the gate did not complete — this document was written when the run STARTED and was never "
|
|
554
|
+
+ "replaced by a verdict, so the run failed, crashed or was killed before it could decide. It is "
|
|
555
|
+
+ "NOT a verdict about the code; see the run's stderr for the cause.",
|
|
556
|
+
}, null, 1) + "\n");
|
|
557
|
+
fs.renameSync(_tmp, p);
|
|
558
|
+
} catch (e) {
|
|
559
|
+
console.error(`candor-ts: could not arm --gate-json ${p} fail-closed (${e.message}) — if this run `
|
|
560
|
+
+ `does not complete, that path may still hold a PREVIOUS run's verdict`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// MOVED ABOVE THE CONFIG LOAD. `loadCandorConfig` is ITSELF an exit-2 cause (an unusable
|
|
564
|
+
// CANDOR_CONFIG, or a committed `.candor/config` that cannot be read), and arming after it left a
|
|
565
|
+
// config refusal exiting 2 with the PREVIOUS run's green still on disk — while the comment below
|
|
566
|
+
// said "BEFORE ANYTHING THAT CAN EXIT". The rule only holds if the arming really is first.
|
|
567
|
+
// ⟨0.24⟩ ARM THE VERDICT FAIL-CLOSED BEFORE ANYTHING THAT CAN EXIT. A review found the pin refusal
|
|
568
|
+
// leaving the PREVIOUS run's `--gate-json` document on disk — a CI wrapper reading the artifact instead
|
|
569
|
+
// of the exit code then reports a pass over a run that refused. Arming at the START makes this a CLASS
|
|
570
|
+
// fix: every exit path leaves a refusal unless the run got far enough to replace it. candor-java's
|
|
571
|
+
// `armGateJson` is the model, and the wording is about the RUN, not about the code.
|
|
572
|
+
// (armed by the pre-pass above, before the arg loop — see SPEC §3.3.1 ⟨0.27⟩. Arming HERE was still
|
|
573
|
+
// after the loop's unknown-flag exit, so the contract depended on argv order.)
|
|
236
574
|
const candorConfig = loadCandorConfig(target);
|
|
575
|
+
enforceEnginePin(target); // ⟨0.27⟩ §3.4 — AFTER the arming, so its exit 2 cannot leave a stale verdict
|
|
237
576
|
// precedence: the --policy flag / CANDOR_POLICY env already populated policyPath; the config is the floor.
|
|
238
577
|
// A BARE `policy` line ("" value) means configured-with-empty → the unreadable-policy path fails loud.
|
|
239
578
|
if (policyPath === null && candorConfig.policy !== undefined) policyPath = candorConfig.policy;
|
|
@@ -241,7 +580,12 @@ if (policyPath === null && candorConfig.policy !== undefined) policyPath = cando
|
|
|
241
580
|
// (path-valued keys are already resolved against the config's anchor above). No CLI flag — matching
|
|
242
581
|
// candor-java, the reference engine (env/config only). A BARE `baseline` line ("") fails loud below.
|
|
243
582
|
let baselinePath = process.env.CANDOR_BASELINE ?? null;
|
|
244
|
-
|
|
583
|
+
// WHICH SOURCE supplied it decides what a MISSING file means: `CANDOR_BASELINE` is set unconditionally
|
|
584
|
+
// by the adopt workflow, so an absent path there is "the ratchet is not adopted yet"; a checked-in
|
|
585
|
+
// `baseline` line DECLARES this repo has one, so an absent path there was deleted or never committed —
|
|
586
|
+
// and the guard passing green over it is a gate that silently stopped gating.
|
|
587
|
+
let baselineFromConfig = false;
|
|
588
|
+
if (baselinePath === null && candorConfig.baseline !== undefined) { baselinePath = candorConfig.baseline; baselineFromConfig = true; }
|
|
245
589
|
// ⟨unknown-ratchet⟩ OPT-IN (config `unknown-ratchet` / CANDOR_UNKNOWN_RATCHET, default OFF): flip an
|
|
246
590
|
// Unknown-ONLY gain vs the baseline from advisory to an AS-EFF-005 failure (exit 1). Env-override truthy
|
|
247
591
|
// semantics mirror candor-java's Config.flag exactly — env var PRESENCE means on (env can't express off);
|
|
@@ -290,7 +634,15 @@ function fromTsconfig(cfgPath, baseDir) {
|
|
|
290
634
|
return names.filter((f) => !isTestPath(path.relative(baseDir, f)));
|
|
291
635
|
}
|
|
292
636
|
const stat = fs.existsSync(target) ? fs.statSync(target) : null;
|
|
293
|
-
if (!stat) {
|
|
637
|
+
if (!stat) {
|
|
638
|
+
console.error(`candor-ts: no such path: ${target}`);
|
|
639
|
+
// The SAME two lines as every other early exit in this file. `refuseEarlyToStream` WRITES AND
|
|
640
|
+
// RETURNS — it is not a `Never`, and calling it INSTEAD of the exit lets the run continue past its
|
|
641
|
+
// own refusal (measured, while getting this wrong: an unreadable dep wrote its refusal and then
|
|
642
|
+
// exited 0). rust's equivalent is typed `-> !`, which is why the same slip could not happen there.
|
|
643
|
+
refuseEarlyToStream(`no such path: ${target}`);
|
|
644
|
+
process.exit(2);
|
|
645
|
+
}
|
|
294
646
|
if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
295
647
|
rootDir = path.dirname(path.resolve(target));
|
|
296
648
|
fileNames = fromTsconfig(path.resolve(target), rootDir);
|
|
@@ -315,7 +667,13 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
|
315
667
|
})(rootDir);
|
|
316
668
|
}
|
|
317
669
|
}
|
|
318
|
-
if (fileNames.length === 0) {
|
|
670
|
+
if (fileNames.length === 0) {
|
|
671
|
+
console.error(`candor-ts: no TypeScript sources under ${target}`);
|
|
672
|
+
// An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
|
|
673
|
+
// get nothing. §3.1 exempts no cause, and this one is easy to hit in CI (a path that moved).
|
|
674
|
+
refuseEarlyToStream(`no TypeScript sources under ${target}`);
|
|
675
|
+
process.exit(2);
|
|
676
|
+
}
|
|
319
677
|
// Builtin typings FALLBACK: the engine ships @types/node as its own dependency, so a target that
|
|
320
678
|
// hasn't installed it still resolves node:fs/node:net/… (found by the first npx-distribution
|
|
321
679
|
// probe: a bare fixture read Unknown for fs.readFileSync because nothing supplied the builtin
|
|
@@ -798,16 +1156,69 @@ const corruptDepPkgs = new Set();
|
|
|
798
1156
|
// --workspace's auto-scanned deps dir is prepended to the explicit CANDOR_DEPS/config spec (both chain).
|
|
799
1157
|
const spec = [workspaceDepsDir, depInitsDir, process.env.CANDOR_DEPS ?? candorConfig.deps ?? ""].filter(Boolean).join(":");
|
|
800
1158
|
const files = [];
|
|
801
|
-
|
|
1159
|
+
// ASCII WHITESPACE ONLY, the same rule as the config loader above and as java, rust and swift. JS
|
|
1160
|
+
// `\s` includes U+00A0, so a dep path holding a non-breaking space split into two halves — and since
|
|
1161
|
+
// ⟨0.27⟩ made an unresolvable dep token FATAL, that turned a path the other three engines load into a
|
|
1162
|
+
// hard exit 2 naming a truncated path the operator never wrote. The config loader was fixed and this
|
|
1163
|
+
// one, which every config-declared dep is also routed through, was not: one rule, two spellings.
|
|
1164
|
+
for (const tok of spec.split(DEP_SEPARATORS).filter(Boolean)) {
|
|
802
1165
|
try {
|
|
803
1166
|
if (fs.statSync(tok).isDirectory())
|
|
804
1167
|
for (const f of fs.readdirSync(tok)) if (f.endsWith(".json") && !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.endsWith(".locs.json")) files.push(path.join(tok, f));
|
|
805
1168
|
if (fs.statSync(tok).isFile()) files.push(tok);
|
|
806
|
-
} catch {
|
|
1169
|
+
} catch {
|
|
1170
|
+
// ⟨0.27⟩ SPEC §2: A CONFIGURED DEP THAT CANNOT BE READ IS UNEVALUABLE, NOT REDUCED COVERAGE.
|
|
1171
|
+
// Skipping it continued the run, and the caller of that dep then serialised `inferred: []` — a
|
|
1172
|
+
// ⟨0.21⟩ purity claim, published in the REPORT, about a function whose dependency the operator
|
|
1173
|
+
// configured precisely so it would not be one. This engine's note said only "skipped", so the
|
|
1174
|
+
// omission was not even qualified in the channel a human reads, let alone the artifact a chained
|
|
1175
|
+
// consumer reads. java and swift already refused; this engine and rust continued.
|
|
1176
|
+
console.error(`candor-ts: CANDOR_DEPS names ${tok} but it is not a readable file or directory — `
|
|
1177
|
+
+ `failing (exit 2, unevaluable). A configured dep that is not there is not reduced coverage: `
|
|
1178
|
+
+ `its callers would serialise \`inferred: []\`, which is a purity claim about code this scan `
|
|
1179
|
+
+ `never saw. Scan that dependency, or remove it from the \`deps\` config / CANDOR_DEPS.`);
|
|
1180
|
+
// The TOKEN arm needed this too. The read and parse arms below were routed to the stream and this
|
|
1181
|
+
// one was not — three exits for one rule, two of them answered on the machine channel and one
|
|
1182
|
+
// silent, which a conformance row (PART 36 b8) caught immediately once the cause was posed at all.
|
|
1183
|
+
refuseEarlyToStream(`configured dependency ${tok} is not a readable file or directory`);
|
|
1184
|
+
process.exit(2);
|
|
1185
|
+
}
|
|
807
1186
|
}
|
|
808
1187
|
for (const f of files) {
|
|
1188
|
+
// ⟨0.27⟩ READ AND PARSE OUTSIDE THE TRY, because SPEC §2 binds them and the try was swallowing them.
|
|
1189
|
+
// The rule is one sentence — a configured dep path that "does not exist OR CANNOT BE READ MUST exit
|
|
1190
|
+
// 2, naming it" — and the 0.27 work implemented only the first half, at the token check above. A path
|
|
1191
|
+
// that resolved to a file which then failed to open, or held malformed JSON, was SKIPPED at exit 0,
|
|
1192
|
+
// and the caller of that dep serialised `inferred: []`: the ⟨0.21⟩ purity claim the token check
|
|
1193
|
+
// exists to prevent, reached by a different door.
|
|
1194
|
+
//
|
|
1195
|
+
// Found by the 0.27 go/no-go panel, which tested this engine's own changelog claim instead of
|
|
1196
|
+
// believing it. java and swift refused on both halves already; this and rust made the family 2-v-2
|
|
1197
|
+
// on a MUST. The surviving `catch` below still guards the PROCESSING of a well-formed document,
|
|
1198
|
+
// which is a different failure and stays a skip.
|
|
1199
|
+
let raw;
|
|
1200
|
+
try {
|
|
1201
|
+
raw = fs.readFileSync(f, "utf8");
|
|
1202
|
+
} catch {
|
|
1203
|
+
console.error(`candor-ts: CANDOR_DEPS report ${f} could not be read —`);
|
|
1204
|
+
console.error(` failing (exit 2, unevaluable). A configured dep this scan cannot read is not`);
|
|
1205
|
+
console.error(` reduced coverage: its callers would serialise \`inferred: []\`, a purity claim`);
|
|
1206
|
+
console.error(` about code this scan never saw.`);
|
|
1207
|
+
refuseEarlyToStream(`configured dependency report ${f} could not be read`);
|
|
1208
|
+
process.exit(2);
|
|
1209
|
+
}
|
|
1210
|
+
let parsed;
|
|
809
1211
|
try {
|
|
810
|
-
|
|
1212
|
+
parsed = JSON.parse(raw);
|
|
1213
|
+
} catch {
|
|
1214
|
+
console.error(`candor-ts: CANDOR_DEPS report ${f} is not valid JSON —`);
|
|
1215
|
+
console.error(` failing (exit 2, unevaluable). Same reason as an unreadable one: a report`);
|
|
1216
|
+
console.error(` that cannot be parsed makes no claim, and continuing would publish one.`);
|
|
1217
|
+
refuseEarlyToStream(`configured dependency report ${f} is not valid JSON`);
|
|
1218
|
+
process.exit(2);
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
const d = parsed;
|
|
811
1222
|
// A report whose version can't be VERIFIED is not trusted (§2.1) — a missing header is as
|
|
812
1223
|
// untrustworthy as a mismatched one (the Rust engine's rule; the engines split on this).
|
|
813
1224
|
const stale = d.candor?.version !== ENGINE_VERSION;
|
|
@@ -928,7 +1339,7 @@ const corruptDepPkgs = new Set();
|
|
|
928
1339
|
if (!stale && strs(e.netClass).includes("unknown-host")) cell.netIncomplete = true;
|
|
929
1340
|
crossDeps.set(e.hash, cell);
|
|
930
1341
|
}
|
|
931
|
-
} catch { console.error(`candor-ts: CANDOR_DEPS report
|
|
1342
|
+
} catch { console.error(`candor-ts: CANDOR_DEPS report could not be processed, skipped: ${f}`); }
|
|
932
1343
|
}
|
|
933
1344
|
// A package chained TWICE — once fresh, once stale — is covered by the fresh report, so it is not a
|
|
934
1345
|
// stale-only package and must not pick up the disclosure below on top of a real answer.
|
|
@@ -1729,7 +2140,7 @@ for (const sf of sources) {
|
|
|
1729
2140
|
const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
|
|
1730
2141
|
if (!fns.has(ctorQual)) {
|
|
1731
2142
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
1732
|
-
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
|
|
2143
|
+
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
|
|
1733
2144
|
hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
|
|
1734
2145
|
blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
|
|
1735
2146
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
@@ -1753,7 +2164,7 @@ for (const sf of sources) {
|
|
|
1753
2164
|
// producer's namespace nesting, so widening the hash would break report chaining.
|
|
1754
2165
|
const nsp = namespacePrefixOf(node);
|
|
1755
2166
|
const qual = isFunctionScoped(node) ? `${mod}.${nsp}${n}#${line + 1}:${character + 1}` : `${mod}.${nsp}${n}`;
|
|
1756
|
-
fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2167
|
+
fns.set(qual, { local: n, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
1757
2168
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
1758
2169
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
1759
2170
|
endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
|
|
@@ -2054,7 +2465,7 @@ function moduleUnit(sf) {
|
|
|
2054
2465
|
const qual = `${mod}.<module>`;
|
|
2055
2466
|
let rec = fns.get(qual);
|
|
2056
2467
|
if (!rec) {
|
|
2057
|
-
rec = { local: qual, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2468
|
+
rec = { local: qual, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2058
2469
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
2059
2470
|
entry: false, unitKind: "initializer",
|
|
2060
2471
|
loc: `${path.relative(rootDir, sf.fileName)}:1:1`,
|
|
@@ -2077,7 +2488,7 @@ function staticBlockUnit(node) {
|
|
|
2077
2488
|
const qual = `${mod}.${cname}.<static-init>`;
|
|
2078
2489
|
let rec = fns.get(qual);
|
|
2079
2490
|
if (!rec) {
|
|
2080
|
-
rec = { local: "<static-init>", direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2491
|
+
rec = { local: "<static-init>", direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
2081
2492
|
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
2082
2493
|
entry: false, unitKind: "initializer",
|
|
2083
2494
|
loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1`,
|
|
@@ -3403,6 +3814,17 @@ function visitCalls(node) {
|
|
|
3403
3814
|
eff = null;
|
|
3404
3815
|
if (eff) {
|
|
3405
3816
|
rec.direct.add(eff);
|
|
3817
|
+
// SPEC §2 `fs` — refine an Fs we just PROVED with the direction its verb implies. DIRECT only
|
|
3818
|
+
// (never propagated over edges): a caller reaching one writer and one undetermined callee would
|
|
3819
|
+
// otherwise inherit ["write"] and thereby claim "writes but never reads" — the partial claim §2
|
|
3820
|
+
// forbids. An unrecognised verb adds nothing, so the field stays absent rather than half-true.
|
|
3821
|
+
if (eff === "Fs") {
|
|
3822
|
+
// A verb revealing no direction records the POISON marker "?" rather than nothing. Abstaining
|
|
3823
|
+
// would let a caller inherit a neighbour's ["write"] and claim "writes but never reads" over a
|
|
3824
|
+
// reach whose kind was never determined — the partial claim §2 forbids. Suppressed at emit.
|
|
3825
|
+
const ks = fsKind(mod, member);
|
|
3826
|
+
if (ks.length === 0) rec.fsKinds.add("?"); else for (const k of ks) rec.fsKinds.add(k);
|
|
3827
|
+
}
|
|
3406
3828
|
// a κ rule that resolves to the Unknown trust-marker (node:vm code execution) is a direct
|
|
3407
3829
|
// Unknown SOURCE — SPEC §4 requires a why on it, like eval's `reflect:eval`. (The rest of
|
|
3408
3830
|
// the κ table is concrete effects, which carry no why.)
|
|
@@ -4339,7 +4761,10 @@ const inferred = new Map([...fns.keys()].map((k) => [k, new Set(fns.get(k).direc
|
|
|
4339
4761
|
if (!queued.has(c)) { queued.add(c); queue.push(c); }
|
|
4340
4762
|
}
|
|
4341
4763
|
}
|
|
4342
|
-
|
|
4764
|
+
// `fsKinds` joins the propagated surfaces: kinds TRAVEL the call graph (a caller that transitively only
|
|
4765
|
+
// writes IS a writer), and the "?" poison travels with them so a caller of an undetermined-kind function
|
|
4766
|
+
// inherits the SUPPRESSION rather than a half-answer. Pinned by conformance PART 31.
|
|
4767
|
+
for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsKinds"]) {
|
|
4343
4768
|
const queue = [...fns.keys()];
|
|
4344
4769
|
const queued = new Set(queue);
|
|
4345
4770
|
for (let head = 0; head < queue.length; head++) {
|
|
@@ -4393,6 +4818,14 @@ for (const [name, rec] of fns) {
|
|
|
4393
4818
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
4394
4819
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
4395
4820
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
4821
|
+
// SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
|
|
4822
|
+
// (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
|
|
4823
|
+
//
|
|
4824
|
+
// Kinds TRAVEL (see the propagation loop); the "?" poison is what stops a PARTIAL answer travelling with
|
|
4825
|
+
// them. Present ⇒ some contributing Fs had no determined kind ⇒ suppress the whole field, because
|
|
4826
|
+
// ["write"] there would claim "writes but never reads" about a function that may do both.
|
|
4827
|
+
if (inf.includes("Fs") && rec.fsKinds.size && !rec.fsKinds.has("?"))
|
|
4828
|
+
entry.fs = [...rec.fsKinds].sort();
|
|
4396
4829
|
// ⟨0.6⟩ unknownWhy — REQUIRED on a DIRECT Unknown SOURCE (this fn's own body has the unresolvable call,
|
|
4397
4830
|
// so `rec.direct` carries Unknown), absent on a purely-transitive Unknown. The rich per-site reasons
|
|
4398
4831
|
// (rec.why: callback:/dispatch:/dynamic-key:) when recorded, else a generic fallback so a source is
|
|
@@ -4834,8 +5267,13 @@ function fnv1aHex(sortedQuals) {
|
|
|
4834
5267
|
|
|
4835
5268
|
// `package` names what this report COVERS — a consumer chaining it registers coverage even when
|
|
4836
5269
|
// `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
|
|
5270
|
+
// ⟨0.27⟩ SPEC §2.1 `resolves`: the OPTIONAL refinement surfaces this producer computes. Without it the
|
|
5271
|
+
// absence of such a field is overloaded between "does not compute this" and "computed and could not
|
|
5272
|
+
// determine it", and a consumer cannot read the omission at all. candor-ts resolves `fs` read/write kinds,
|
|
5273
|
+
// so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
|
|
5274
|
+
// false "undetermined", which is the inversion the field exists to prevent.
|
|
4837
5275
|
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
4838
|
-
package: pkgName, functions };
|
|
5276
|
+
resolves: ["fs"], package: pkgName, functions };
|
|
4839
5277
|
// ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
|
|
4840
5278
|
// name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
|
|
4841
5279
|
// the --gate-json advisory, so the three can never tell different stories.
|
|
@@ -5023,6 +5461,11 @@ if (!wantJson) {
|
|
|
5023
5461
|
// a `… | jq` / `… | candor-sarif` pipe never breaks.
|
|
5024
5462
|
const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
|
5025
5463
|
let gateViolations = [];
|
|
5464
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the raw text of every rule whose SCOPE bound no function, captured off the
|
|
5465
|
+
// gate evaluation and emitted onto the verdict document. The stderr lines alone left a machine consumer
|
|
5466
|
+
// unable to see that a rule bound nothing — the typo'd-scope silent green, one channel over. Disclosure
|
|
5467
|
+
// only: `ok` and the exit code never consult it.
|
|
5468
|
+
let gateZeroMatch = [];
|
|
5026
5469
|
// ⟨0.24⟩ the `.candor/config` that supplied POLICY VOCABULARY this verdict actually used — named on the
|
|
5027
5470
|
// document (SPEC §3.1 `99eb4e9`), null when no alias was referenced so the verdict stays byte-identical.
|
|
5028
5471
|
let policyVocabulary = null;
|
|
@@ -5075,7 +5518,20 @@ const writeRefusal = (reason, unevaluated = null) => {
|
|
|
5075
5518
|
// must not silently NARROW the guard back to report-only.
|
|
5076
5519
|
if (baselinePath !== null) {
|
|
5077
5520
|
const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
|
|
5078
|
-
if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
5521
|
+
if (baselinePath !== "" && !fs.existsSync(baselinePath) && baselineFromConfig) {
|
|
5522
|
+
// A CHECKED-IN DECLARATION IS NOT THE SAME ABSENCE — see baselineFromConfig above. Exit 2: the
|
|
5523
|
+
// gateless-green class, and an adopter review measured this as the second-likeliest first-commit
|
|
5524
|
+
// mistake (`.candor/` committed, the baseline not).
|
|
5525
|
+
console.error(`candor-ts: .candor/config declares \`baseline ${baselinePath}\` but that file is not `
|
|
5526
|
+
+ `there — failing (exit 2). A checked-in declaration says this repo HAS a baseline, so an absent `
|
|
5527
|
+
+ `one was deleted or never committed. Commit it, or record one: candor-ts <target> --out <prefix>.`);
|
|
5528
|
+
// WRITE THE REFUSAL DOCUMENT BEFORE EXITING. Without this the `--gate-json` file keeps whatever the
|
|
5529
|
+
// LAST run left there — so a CI wrapper that reads the artifact instead of the exit code sees the
|
|
5530
|
+
// previous run's `ok: true` and reports a pass, which is the stale-artifact false green this format
|
|
5531
|
+
// exists to refuse. java, rust and swift all overwrite on this branch; ts alone did not.
|
|
5532
|
+
writeRefusal(`.candor/config declares \`baseline ${baselinePath}\` but that file is not there`);
|
|
5533
|
+
process.exit(2);
|
|
5534
|
+
} else if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
5079
5535
|
console.error(`candor-ts: CANDOR_BASELINE ${baselinePath} does not exist — the regression guard is `
|
|
5080
5536
|
+ `not active (record one: candor-ts <target> --out <prefix>, then point at the report .json).`);
|
|
5081
5537
|
} else {
|
|
@@ -5085,6 +5541,10 @@ if (baselinePath !== null) {
|
|
|
5085
5541
|
if (!Array.isArray(arr)) {
|
|
5086
5542
|
console.error(`candor-ts: baseline ${shownB} exists but could not be parsed (corrupt/truncated?) — `
|
|
5087
5543
|
+ `failing (exit 2); the guard must not silently pass on an unreadable baseline. Regenerate it with this build.`);
|
|
5544
|
+
// ⟨0.27⟩ the refusal document has no exempt cause AND no exempt sink (SPEC §3.1): a file sink holds
|
|
5545
|
+
// the armed placeholder, but `--gate-json -` is not armed, so without this write the stream carried
|
|
5546
|
+
// NOTHING on this cause. Writing here also replaces the placeholder with the specific reason.
|
|
5547
|
+
writeRefusal(`baseline ${shownB} exists but could not be parsed — guard NOT evaluated`);
|
|
5088
5548
|
process.exit(2);
|
|
5089
5549
|
}
|
|
5090
5550
|
const baseVersion = !Array.isArray(root) && root.candor && typeof root.candor === "object"
|
|
@@ -5092,12 +5552,14 @@ if (baselinePath !== null) {
|
|
|
5092
5552
|
if (baseVersion === null) {
|
|
5093
5553
|
console.error(`candor-ts: the baseline ${shownB} has no provenance header (a legacy/bare-array report) — `
|
|
5094
5554
|
+ `a baseline is comparable only to its producing build (§2.1). Failing (exit 2); regenerate it with this build.`);
|
|
5555
|
+
writeRefusal(`baseline ${shownB} has no provenance header — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5095
5556
|
process.exit(2);
|
|
5096
5557
|
}
|
|
5097
5558
|
if (baseVersion !== ENGINE_VERSION) {
|
|
5098
5559
|
console.error(`candor-ts: the baseline ${shownB} was produced by engine build ${baseVersion} but this is `
|
|
5099
5560
|
+ `build ${ENGINE_VERSION} — an engine swap is baseline-invalidating and the gate cannot evaluate `
|
|
5100
5561
|
+ `(exit 2; never a silent skip, never a bogus AS-EFF-005 wave). Regenerate deliberately with this build.`);
|
|
5562
|
+
writeRefusal(`baseline ${shownB} was produced by engine build ${baseVersion}, not this build — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5101
5563
|
process.exit(2);
|
|
5102
5564
|
}
|
|
5103
5565
|
const base = new Map();
|
|
@@ -5124,6 +5586,7 @@ if (baselinePath !== null) {
|
|
|
5124
5586
|
console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
|
|
5125
5587
|
+ `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
|
|
5126
5588
|
+ `report-only. Regenerate the baseline with this build.`);
|
|
5589
|
+
writeRefusal(`baseline callgraph ${sidecarPath} could not be parsed — guard NOT evaluated`); // ⟨0.27⟩ see above
|
|
5127
5590
|
process.exit(2);
|
|
5128
5591
|
}
|
|
5129
5592
|
// The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
|
|
@@ -5231,9 +5694,11 @@ if (policyPath !== null) {
|
|
|
5231
5694
|
if (policyErrs.length) {
|
|
5232
5695
|
const why = policyErrorText(policyPath, policyErrs);
|
|
5233
5696
|
console.error(why);
|
|
5234
|
-
// ⟨0.
|
|
5235
|
-
//
|
|
5236
|
-
|
|
5697
|
+
// ⟨0.27⟩ ONE `unevaluated` ENTRY PER RULE OF THE POLICY — not only the unhonourable lines (SPEC
|
|
5698
|
+
// §3.1's composed-document clause). Measured: listing only the bad token's line let a consumer read
|
|
5699
|
+
// `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
|
|
5700
|
+
// so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
|
|
5701
|
+
policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
|
|
5237
5702
|
} else {
|
|
5238
5703
|
// ⟨0.24⟩ the config file that supplied vocabulary the verdict USED, so an ambient `.candor/config` — the
|
|
5239
5704
|
// walk goes up through every parent, and CANDOR_CONFIG overrides it outright — cannot move a verdict while
|
|
@@ -5242,7 +5707,20 @@ if (policyPath !== null) {
|
|
|
5242
5707
|
const p = discoverConfigPath(policyVocabularyAnchor(policyPath, target));
|
|
5243
5708
|
if (p) policyVocabulary = { config: p, aliases: gatePolicy.aliasesUsed };
|
|
5244
5709
|
}
|
|
5245
|
-
|
|
5710
|
+
const gateOut = evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners);
|
|
5711
|
+
// ⟨0.27⟩ SPEC §4 — a rule that bound NO function is disclosed, never scored as satisfied. The exit
|
|
5712
|
+
// code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
|
|
5713
|
+
// repositories and a layer exists in only some of them, so refusal would make a shared policy
|
|
5714
|
+
// unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
|
|
5715
|
+
for (const raw of gateOut.zeroMatch ?? []) {
|
|
5716
|
+
console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
|
|
5717
|
+
+ `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
|
|
5718
|
+
+ `repos; a typo'd layer name otherwise.`);
|
|
5719
|
+
}
|
|
5720
|
+
// ⟨0.27⟩ captured BEFORE the concat below — `concat` returns a plain array, so the `zeroMatch`
|
|
5721
|
+
// property riding `gateOut` would be silently lost with it (see the gateZeroMatch declaration).
|
|
5722
|
+
gateZeroMatch = gateOut.zeroMatch ?? [];
|
|
5723
|
+
gateViolations = gateViolations.concat(gateOut);
|
|
5246
5724
|
// Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
|
|
5247
5725
|
// in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
|
|
5248
5726
|
// fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
|
|
@@ -5317,6 +5795,9 @@ if (gateJsonPath) {
|
|
|
5317
5795
|
// consumer reading exit 1 must be able to see that the POLICY half of the gate never ran — the same
|
|
5318
5796
|
// `unevaluated` key, in the same position, that `gate --report` uses for its answerability refusals.
|
|
5319
5797
|
if (policyRefusal) verdictObj.unevaluated = policyRefusal.unevaluated;
|
|
5798
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines carry, in the machine channel. Omitted
|
|
5799
|
+
// when empty so a fully-binding verdict is byte-identical; never consulted for `ok` or the exit code.
|
|
5800
|
+
if (gateZeroMatch.length) verdictObj.zeroMatch = gateZeroMatch;
|
|
5320
5801
|
// ⟨0.21⟩ (Gap 2) the machine-legible incompleteness: the units candor couldn't analyze, so a CI/agent
|
|
5321
5802
|
// reading the JSON learns WHY the gate can't certify (the stderr warning alone used to hide this from a
|
|
5322
5803
|
// machine). `incomplete:true` + the list; the run exits 2 (could-not-fully-evaluate) below. ok:false +
|