candor-ts 0.29.1 → 0.30.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 +10 -1
- package/README.md +2 -2
- package/package.json +3 -2
- package/query-core.mjs +63 -4
- package/query.mjs +26 -6
- package/scan.mjs +105 -11
- package/scratch.mjs +58 -0
package/AGENTS.md
CHANGED
|
@@ -21,7 +21,7 @@ the TypeScript-specific production + query surface.
|
|
|
21
21
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
22
22
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
23
23
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
24
|
-
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.
|
|
24
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.30)."*
|
|
25
25
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
26
26
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
27
27
|
>
|
|
@@ -292,3 +292,12 @@ far as candor could see, but it could not see through these" (a LOWER bound), no
|
|
|
292
292
|
dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
|
|
293
293
|
`package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to
|
|
294
294
|
the declared set instead of contributing nothing.
|
|
295
|
+
|
|
296
|
+
- **⟨0.30⟩ A GREEN GATE CAN NOW EXIT 2 — read `outOfScope` before you trust a pass.** When a policy is
|
|
297
|
+
configured, candor also reads the files the scan EXCLUDED (test files, build scripts, archives under the
|
|
298
|
+
root, files outside the build's program) and reports any that perform an effect the policy DENIES, under
|
|
299
|
+
the report's `outOfScope` key. A non-empty block makes the verdict `ok:false`, `incomplete:true`, exit 2
|
|
300
|
+
— *"I could not see enough of this tree to certify it"*, which is NOT the same as "your code violates":
|
|
301
|
+
those functions are never in `violations`, because the gate did not judge them. Branch on `incomplete`
|
|
302
|
+
to tell the two apart. An absent key means the producer was never asked (no policy at scan time), and an
|
|
303
|
+
empty one means asked-and-clear.
|
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.30" }, 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.
|
|
213
|
+
0.30.0, speaking candor-spec 0.30: 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.30.0",
|
|
4
|
+
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.30)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"verify-emit.mjs",
|
|
67
67
|
"verify-loader.mjs",
|
|
68
68
|
"verify-syscall.mjs",
|
|
69
|
+
"scratch.mjs",
|
|
69
70
|
"sensitivity.mjs",
|
|
70
71
|
"transitive-recall.mjs"
|
|
71
72
|
],
|
package/query-core.mjs
CHANGED
|
@@ -557,7 +557,16 @@ export function reportCompleteness(prefix) {
|
|
|
557
557
|
// want different repairs and because `judgedNothing` is PINNED to "reports declaring `analyzed.count:
|
|
558
558
|
// 0`", which a row-3 report is not.
|
|
559
559
|
return { unanalyzed: reportUnanalyzed(prefix), judgedNothing: reportJudgedNothingFiles(prefix),
|
|
560
|
-
noManifest: reportNoManifestFiles(prefix),
|
|
560
|
+
noManifest: reportNoManifestFiles(prefix),
|
|
561
|
+
// ⟨0.30⟩ the peek's findings, so an advisory verb keying `--strict` on this object is at least
|
|
562
|
+
// as pessimistic as the gate over the same bytes (the ⟨0.24⟩ MUST).
|
|
563
|
+
...(() => {
|
|
564
|
+
const o = reportOutOfScope(prefix);
|
|
565
|
+
// A corrupt key rides `unreadable`, which is ALREADY an arm of the strict exit — so the
|
|
566
|
+
// fail-closed path is the one the ⟨0.24⟩ rule established, not a new one beside it.
|
|
567
|
+
return { outOfScope: o.findings,
|
|
568
|
+
unreadable: [...reportUnreadableFiles(prefix), ...o.corrupt] };
|
|
569
|
+
})() };
|
|
561
570
|
}
|
|
562
571
|
|
|
563
572
|
/**
|
|
@@ -574,7 +583,38 @@ export function reportCompleteness(prefix) {
|
|
|
574
583
|
* exit code; see `advisoryAnswer`, whose exit-bearing callers key `--strict` on manifest + unreadable.
|
|
575
584
|
*/
|
|
576
585
|
export const mustHedge = (c) => !!(c && (c.unanalyzed?.length || c.judgedNothing?.length
|
|
577
|
-
|| c.noManifest?.length || c.unreadable?.length
|
|
586
|
+
|| c.noManifest?.length || c.unreadable?.length
|
|
587
|
+
|| c.outOfScope?.length));
|
|
588
|
+
|
|
589
|
+
/** ⟨0.30⟩ The peek's findings across the reports under a locator. Read leniently HERE (a malformed key is
|
|
590
|
+
* the gate's refusal to make, and this feeds a disclosure) but non-emptiness raises the same hedge the
|
|
591
|
+
* gate raises, which is what keeps the advisory verbs bound to it. */
|
|
592
|
+
export function reportOutOfScope(prefix) {
|
|
593
|
+
const out = [];
|
|
594
|
+
// ACCEPT A FULL PATH AS WELL AS A PREFIX. `reportFilesAt` appends `.json`, so a locator that already
|
|
595
|
+
// ends in `.json` — which is exactly what `--report <file>` gives — expanded to nothing and this
|
|
596
|
+
// returned `[]`. The hedge then never fired and `unverified --strict` certified a report the gate
|
|
597
|
+
// refuses. `loadGateReport` tolerates both spellings, so the two disagreed about the same locator.
|
|
598
|
+
const files = (prefix.endsWith(".json") && fs.existsSync(prefix)) ? [prefix] : reportFilesAt(prefix);
|
|
599
|
+
const corrupt = [];
|
|
600
|
+
for (const f of files) {
|
|
601
|
+
try {
|
|
602
|
+
const d = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
603
|
+
// PRESENT-BUT-NOT-A-LIST IS CORRUPT, and it must reach the ADVISORY verbs too. Read leniently here,
|
|
604
|
+
// the key vanished and `--strict` certified a report `gate --report` refuses at exit 2 — the ⟨0.24⟩
|
|
605
|
+
// relation broken one shape over from where it was closed. The gate already refuses this; an
|
|
606
|
+
// advisory verb that does not is LESS pessimistic than the gate over the same bytes.
|
|
607
|
+
if (d?.outOfScope !== undefined && !Array.isArray(d.outOfScope)) { corrupt.push(f); continue; }
|
|
608
|
+
if (Array.isArray(d?.outOfScope)) {
|
|
609
|
+
for (const e of d.outOfScope) {
|
|
610
|
+
if (e && typeof e === "object") out.push(e);
|
|
611
|
+
else { corrupt.push(f); break; }
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
} catch { /* unparseable TEXT is `unreadable`'s business, not this key's */ }
|
|
615
|
+
}
|
|
616
|
+
return { findings: out, corrupt };
|
|
617
|
+
}
|
|
578
618
|
|
|
579
619
|
/**
|
|
580
620
|
* ⟨0.28⟩ The disclosure KEYS, defined ONCE, for spreading into a verb's answer document — `{}` when there
|
|
@@ -740,7 +780,7 @@ export function loadReport(prefix) {
|
|
|
740
780
|
*/
|
|
741
781
|
export function loadGateReport(prefix) {
|
|
742
782
|
const files = reportFilesAt(prefix);
|
|
743
|
-
const functions = [], unanalyzed = [], cov = new Map(), corrupt = [];
|
|
783
|
+
const functions = [], unanalyzed = [], cov = new Map(), corrupt = [], outOfScope = [];
|
|
744
784
|
let hardFail = false, analyzed = 0;
|
|
745
785
|
// ⟨0.24⟩ did the report handed to the gate judge ANYTHING? Per FILE, then ANDed across the multi-report
|
|
746
786
|
// siblings, because the union of several reports has judged something as soon as ONE of them has — the
|
|
@@ -799,6 +839,25 @@ export function loadGateReport(prefix) {
|
|
|
799
839
|
else unanalyzed.push({ path: typeof u.path === "string" ? u.path : "", reason: typeof u.reason === "string" ? u.reason : "" });
|
|
800
840
|
}
|
|
801
841
|
}
|
|
842
|
+
// ⟨0.30⟩ THE PEEK'S FINDINGS, off the report rather than recomputed — this route CANNOT peek (it has
|
|
843
|
+
// no target, only a document), and that is exactly why the field rides the report. Concatenated across
|
|
844
|
+
// siblings like `functions` and `unanalyzed` above. ABSENT stays absent: ⟨0.26⟩ makes an absent key
|
|
845
|
+
// "this producer cannot answer", and a report produced with no policy was never asked, so it must not
|
|
846
|
+
// trigger the ⟨0.30⟩ verdict. Only well-formed entries count — a malformed one is corrupt input, and
|
|
847
|
+
// silently dropping it is how a fail-closed rung turns back into a green one.
|
|
848
|
+
const oos = parsed.outOfScope;
|
|
849
|
+
// PRESENT-BUT-NOT-A-LIST IS CORRUPT, NOT ABSENT. This had no `else` on the Array.isArray guard, so
|
|
850
|
+
// `"outOfScope": "oops"` was silently coerced to nothing and `gate --report` answered exit 0,
|
|
851
|
+
// `ok:true`, "no violations" over a report whose peek had found a denied effect — the exact
|
|
852
|
+
// fail-open coercion the strict read exists to prevent, in the commit that claims to prevent it.
|
|
853
|
+
// rust, java and swift all refuse this shape; only this route did not. (Found by review, MEASURED.)
|
|
854
|
+
if (oos !== undefined && !Array.isArray(oos))
|
|
855
|
+
corrupt.push(`${f}: \`outOfScope\` is present and is not a list`);
|
|
856
|
+
else if (Array.isArray(oos))
|
|
857
|
+
for (const e of oos)
|
|
858
|
+
if (e && typeof e === "object" && Array.isArray(e.effects) && e.effects.length)
|
|
859
|
+
outOfScope.push(e);
|
|
860
|
+
else corrupt.push(`${f}: \`outOfScope\` (an element is not an object carrying a non-empty \`effects\`)`);
|
|
802
861
|
// ⟨0.15⟩ the κ ledger. Merged + re-sorted the PRODUCER's way (count desc, name asc by code point —
|
|
803
862
|
// reportCoverage's rule, and scan.mjs's), so a single-report prefix reproduces the emitted order exactly.
|
|
804
863
|
const unc = parsed.coverage?.uncovered;
|
|
@@ -811,7 +870,7 @@ export function loadGateReport(prefix) {
|
|
|
811
870
|
}
|
|
812
871
|
const coverage = [...cov.entries()].sort((a, b) => b[1] - a[1] || byCodePoint(a[0], b[0]))
|
|
813
872
|
.map(([name, calls]) => ({ name, calls }));
|
|
814
|
-
return { functions, analyzed, unanalyzed, coverage, judgedNothing, hardFail: hardFail || corrupt.length > 0, corrupt };
|
|
873
|
+
return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, hardFail: hardFail || corrupt.length > 0, corrupt };
|
|
815
874
|
}
|
|
816
875
|
// The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
|
|
817
876
|
// true iff a sidecar file was MATCHED but failed to read/parse — its edges were DROPPED (disclosed on
|
package/query.mjs
CHANGED
|
@@ -509,7 +509,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
509
509
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
510
510
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
511
511
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
512
|
-
const SPEC_VERSION = "0.
|
|
512
|
+
const SPEC_VERSION = "0.30";
|
|
513
513
|
|
|
514
514
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
515
515
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -1852,7 +1852,12 @@ switch (cmd) {
|
|
|
1852
1852
|
// --report` REFUSES over a corrupt member — measured, exit 2 — so exiting 0/1 here claimed this verb
|
|
1853
1853
|
// got FURTHER than the gate on identical bytes. The unreadable note above already SAID the exit was
|
|
1854
1854
|
// bounded by the gate's while this line did not read the field — a documented limitation, unmeasured.
|
|
1855
|
-
|
|
1855
|
+
// ⟨0.30⟩ …and the peek's findings, because ⟨0.24⟩ binds this verb to the GATE's pessimism: "AN
|
|
1856
|
+
// ADVISORY VERB MUST NEVER BE LESS SENSITIVE TO INCOMPLETENESS THAN THE GATE OVER THE SAME BYTES",
|
|
1857
|
+
// and "THE SAME RULE BINDS EVERY ADVISORY VERB THAT ANSWERS `ok` — `unverified`, `fix-gate`, and any
|
|
1858
|
+
// later sibling". ⟨0.30⟩ moved the gate to exit 2 on this cause and left these verbs certifying:
|
|
1859
|
+
// MEASURED, `gate --report` exited 2 while this printed a clean answer at exit 0 over the same bytes.
|
|
1860
|
+
process.exit(fgUnan.length || fgComp.unreadable.length || fgComp.outOfScope?.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
|
|
1856
1861
|
break; // unreachable
|
|
1857
1862
|
}
|
|
1858
1863
|
case "unverified": {
|
|
@@ -1917,7 +1922,8 @@ switch (cmd) {
|
|
|
1917
1922
|
// ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger — see fix-gate above. Measured on this verb
|
|
1918
1923
|
// before the fix: over one good report plus one unparsable sibling, `gate --report` exited 2 and
|
|
1919
1924
|
// `unverified --strict` exited 0 — and `--strict` is how CI consumes it.
|
|
1920
|
-
|
|
1925
|
+
// ⟨0.30⟩ the peek's findings too — see the fix-gate exit above for the ⟨0.24⟩ MUST this satisfies.
|
|
1926
|
+
process.exit(uUnan.length || uComp.unreadable.length || uComp.outOfScope?.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
|
|
1921
1927
|
break; // unreachable
|
|
1922
1928
|
}
|
|
1923
1929
|
case "gate": {
|
|
@@ -2146,7 +2152,11 @@ switch (cmd) {
|
|
|
2146
2152
|
// ⟨0.21⟩ COMPLETENESS MANIFEST: a gate cannot be green over code candor never analyzed. The scan path
|
|
2147
2153
|
// exits 2 on its OWN `unanalyzed`; here the same manifest travels ON the report, so the same verdict
|
|
2148
2154
|
// follows from it. A real violation (exit 1) dominates, as it does there.
|
|
2149
|
-
|
|
2155
|
+
// ⟨0.30⟩ the SECOND cause, read off the report because this route cannot peek — the report carries the
|
|
2156
|
+
// peek's findings, which is what makes §3.1 byte-equality hold here by construction rather than by two
|
|
2157
|
+
// authors agreeing. ABSENT is not empty: a report produced with no policy was never asked.
|
|
2158
|
+
const gscope = g.outOfScope ?? [];
|
|
2159
|
+
const gincomplete = g.unanalyzed.length > 0 || gscope.length > 0;
|
|
2150
2160
|
// The verdict document — the SAME builder shape scan.mjs writes, field for field and in the same key
|
|
2151
2161
|
// order, because §3.1 ⟨0.24⟩ makes byte-equality with `scan --policy`'s `--gate-json` the acceptance
|
|
2152
2162
|
// test. `analyzed.count`, `incomplete`/`unanalyzed` and the ⟨0.15⟩ coverage advisory all come off the
|
|
@@ -2172,7 +2182,12 @@ switch (cmd) {
|
|
|
2172
2182
|
// not be answered, this carries text that never became a rule at all. Omitted when empty; `ok` and
|
|
2173
2183
|
// the exit do not consult it (the line-level leniency is unchanged, only disclosed).
|
|
2174
2184
|
if (gpol.ignored?.length) gverdictObj.ignored = gpol.ignored;
|
|
2175
|
-
if (gincomplete) {
|
|
2185
|
+
if (gincomplete) {
|
|
2186
|
+
gverdictObj.incomplete = true;
|
|
2187
|
+
if (g.unanalyzed.length) gverdictObj.unanalyzed = g.unanalyzed;
|
|
2188
|
+
}
|
|
2189
|
+
// ⟨0.30⟩ same key, same position as the scan route's — §3.1's byte-equality is the acceptance test.
|
|
2190
|
+
if (gscope.length) gverdictObj.outOfScope = gscope;
|
|
2176
2191
|
if (g.coverage.length)
|
|
2177
2192
|
gverdictObj.coverage = { uncovered: g.coverage.length, packages: g.coverage.map((c) => c.name) };
|
|
2178
2193
|
if (gviol.length) {
|
|
@@ -2186,7 +2201,12 @@ switch (cmd) {
|
|
|
2186
2201
|
if (gunevaluated.length)
|
|
2187
2202
|
grefuse(`${gunevaluated.length} policy rule(s) could not be evaluated against this report`, gunevaluated);
|
|
2188
2203
|
if (gincomplete) {
|
|
2189
|
-
|
|
2204
|
+
// ⟨0.30⟩ NAME THE CAUSE THAT ACTUALLY FIRED. Two causes reach this exit now, and a message that
|
|
2205
|
+
// always says "could not analyze" would report the wrong repair for the scope one — the operator
|
|
2206
|
+
// would go looking for a parse failure that is not there.
|
|
2207
|
+
const why = g.unanalyzed.length
|
|
2208
|
+
? `gate NOT certified — the report declares ${g.unanalyzed.length} unit(s) candor could not analyze; a gate cannot be green over unanalyzed code`
|
|
2209
|
+
: `gate NOT certified — the report names ${gscope.length} function(s) OUTSIDE the scan's scope performing an effect this policy denies; the gate did not judge them, so the verdict is incomplete rather than a pass`;
|
|
2190
2210
|
console.error(`candor-ts: ${why}`);
|
|
2191
2211
|
// The INCOMPLETE verdict is a JUDGEMENT, not a refusal: it names what was analyzed and what was not,
|
|
2192
2212
|
// and §3.1 makes byte-equality with `scan --policy`'s document the acceptance test for exactly it.
|
package/scan.mjs
CHANGED
|
@@ -45,7 +45,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
45
45
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
46
46
|
// Reused, never re-littered.
|
|
47
47
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
48
|
-
const SPEC_VERSION = "0.
|
|
48
|
+
const SPEC_VERSION = "0.30";
|
|
49
49
|
|
|
50
50
|
// A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
|
|
51
51
|
//
|
|
@@ -6646,7 +6646,8 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
|
6646
6646
|
const writeAtomic = (file, text) => writeSinkAtomic(file, text);
|
|
6647
6647
|
// ── ⟨0.29⟩ THE PEEK ───────────────────────────────────────────────────────────────────────────────
|
|
6648
6648
|
// Read the files this run deliberately did NOT judge, and say so when they hold an effect the policy
|
|
6649
|
-
// DENIES.
|
|
6649
|
+
// DENIES. ⟨0.30⟩ THE VERDICT DOES MOVE — see the exit site below: a non-empty block makes it
|
|
6650
|
+
// `ok:false, incomplete:true` at exit 2. It is still never a violation, because a file
|
|
6650
6651
|
// the gate declined to judge must not decide an exit code.
|
|
6651
6652
|
//
|
|
6652
6653
|
// A CHILD `scan.mjs`, not a second analysis path. candor-rust buys this by recursing into `scan_one`;
|
|
@@ -6678,7 +6679,11 @@ let peekRead = false;
|
|
|
6678
6679
|
const peekUnread = new Set();
|
|
6679
6680
|
let peekUnattributed = false;
|
|
6680
6681
|
if (policyPath && excludedFiles.length) {
|
|
6681
|
-
|
|
6682
|
+
// ⟨0.30⟩ HOISTED, because the matcher below needs it. It was a `const` inside the try, so the
|
|
6683
|
+
// evaluatePolicy call added in this rung threw ReferenceError straight into the "a peek that cannot
|
|
6684
|
+
// run must not fail the gate" catch — findings silently empty, gate green. The catch is right; a bug
|
|
6685
|
+
// hiding behind it is not, which is why the trigger below is now a POSITIVE test on the rules.
|
|
6686
|
+
let peekPolicy = null;
|
|
6682
6687
|
try {
|
|
6683
6688
|
const pol = parsePolicy(fs.readFileSync(policyPath, "utf8"), {});
|
|
6684
6689
|
// ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
|
|
@@ -6687,10 +6692,14 @@ if (policyPath && excludedFiles.length) {
|
|
|
6687
6692
|
// parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
|
|
6688
6693
|
// candor-java already withheld here; this engine, candor-rust and candor-swift did not.
|
|
6689
6694
|
if (!fatalPolicyErrors(pol.errors).length) {
|
|
6690
|
-
|
|
6695
|
+
peekPolicy = pol;
|
|
6691
6696
|
}
|
|
6692
6697
|
} catch { /* an unreadable policy is the gate's business to refuse, not the peek's */ }
|
|
6693
|
-
|
|
6698
|
+
// ⟨0.30⟩ THE TRIGGER IS "ARE THERE DENY RULES", not "is the flattened effect-name set non-empty". The
|
|
6699
|
+
// old test read the name set, and `pure` is a deny rule with an EMPTY effect list meaning "every effect
|
|
6700
|
+
// except Unknown" — so under the STRICTEST policy the set was empty, the peek never ran, and the tree
|
|
6701
|
+
// passed at exit 0 while the strictly weaker `deny Exec` exited 2 on the same files. MEASURED four-way.
|
|
6702
|
+
if ((peekPolicy?.deny ?? []).length) {
|
|
6694
6703
|
outOfScopeFindings = [];
|
|
6695
6704
|
const peekDir = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-peek-"));
|
|
6696
6705
|
try {
|
|
@@ -6721,8 +6730,62 @@ if (policyPath && excludedFiles.length) {
|
|
|
6721
6730
|
// all of them claiming completeness.
|
|
6722
6731
|
if (hit) peekUnread.add(hit.cls); else peekUnattributed = true;
|
|
6723
6732
|
}
|
|
6733
|
+
// ⟨0.30⟩ THE PEEK ASKS THE GATE'S OWN MATCHER, not a flat set of effect NAMES. §6.2 already
|
|
6734
|
+
// requires it — "THE GATE AND THE DISCLOSURE MUST APPLY THE SAME RULE, AND SHOULD SHARE THE SAME
|
|
6735
|
+
// CODE" — and the name-set approximation was wrong in BOTH directions once ⟨0.30⟩ made this
|
|
6736
|
+
// verdict-bearing (both MEASURED four-way in review):
|
|
6737
|
+
//
|
|
6738
|
+
// OVER-CHARGE: `deny Net[known-partner]` denies only that destination class, but the name set
|
|
6739
|
+
// held bare "Net", so a peeked fn fetching an UNKNOWN host — which the rule does not deny —
|
|
6740
|
+
// turned the verdict red, while the identical code IN scope passed. Rule SCOPES were dropped the
|
|
6741
|
+
// same way: a layer-scoped `deny Exec server` fired on a test file the scope excludes.
|
|
6742
|
+
//
|
|
6743
|
+
// UNDER-REPORT: `pure` is a deny rule with an EMPTY effect list, meaning "every effect except
|
|
6744
|
+
// Unknown". Flattened, it contributed NOTHING, so the denied set was empty and the peek never
|
|
6745
|
+
// ran — the STRICTEST policy silently disarmed the rung while the strictly weaker `deny Exec`
|
|
6746
|
+
// exited 2 on the identical tree. A four-way false all-clear.
|
|
6747
|
+
//
|
|
6748
|
+
// Only the DENY rules are handed over: ⟨0.29⟩ bounds this block to "effects that policy DENIES",
|
|
6749
|
+
// and evaluating `allow`/`forbid`/`only` here would widen it past the bound that keeps it quiet.
|
|
6750
|
+
// ⟨0.30⟩ THE PROJECT'S OWN `net-partner` SET, not an empty one. The child scan runs over a temp
|
|
6751
|
+
// tsconfig and never sees the project's `.candor/config`, and this call passed `new Set()` on top of
|
|
6752
|
+
// that — so a `deny Net[known-partner]` peek asked "is this host a declared partner?" against a set
|
|
6753
|
+
// that was always empty. MEASURED both ways: a test file reaching a DECLARED partner answered exit 0
|
|
6754
|
+
// where the same code in scope exits 1 (a false all-clear), and `deny Net[unknown-host]` answered
|
|
6755
|
+
// exit 2 over that same declared partner where in scope it exits 0 (the mirror over-charge).
|
|
6756
|
+
// candor-rust never had this — its peek runs in-process and inherits the config.
|
|
6757
|
+
//
|
|
6758
|
+
// `netClasses` stays null so the resolver recomputes each entry's class from the `hosts` the child
|
|
6759
|
+
// DID capture, against these partners — the child's own `netClass` was computed partner-blind and
|
|
6760
|
+
// must not be trusted here.
|
|
6761
|
+
// ⟨0.30⟩ MATCH SCOPES AGAINST A PROJECT-RELATIVE QUALIFIER, not the child's. The child scan runs
|
|
6762
|
+
// under a temp-directory tsconfig, so its `fn` is derived from THAT root — an absolute dotted path.
|
|
6763
|
+
// Handing those to the matcher made a rule's SCOPE match segments of the checkout directory: on a
|
|
6764
|
+
// tree at `…/fresh/src/execa`, `deny Net src` armed the peek while binding nothing in scope, so the
|
|
6765
|
+
// verdict depended on where the repo happened to be cloned. CI checkouts live under names nobody
|
|
6766
|
+
// chose (Bitbucket uses `agent/build`), which makes that a verdict decided by infrastructure.
|
|
6767
|
+
//
|
|
6768
|
+
// The project-relative path is already known here — it is what this run disclosed as excluded — and
|
|
6769
|
+
// the finding below is already named from it. The MATCHER must see the same thing the FINDING does.
|
|
6770
|
+
const relQual = (f) => {
|
|
6771
|
+
const childLoc = (f.loc ?? "").split(":")[0] ?? "";
|
|
6772
|
+
const hit = excludedFiles.find((e) => childLoc.endsWith(e.path)
|
|
6773
|
+
|| childLoc.endsWith(path.basename(e.path)));
|
|
6774
|
+
if (!hit) return f.fn;
|
|
6775
|
+
const stem = hit.path.replace(/\.[cm]?[jt]sx?$/, "").split(path.sep).join(".");
|
|
6776
|
+
const leaf = f.fn.split(".").pop() ?? f.fn;
|
|
6777
|
+
return `${stem}.${leaf}`;
|
|
6778
|
+
};
|
|
6779
|
+
const peekEntries = (doc.functions ?? []).map((f) => ({ ...f, fn: relQual(f) }));
|
|
6780
|
+
const peekViolations = evaluatePolicy({ ...peekPolicy, allow: [], forbid: [], only: [] },
|
|
6781
|
+
peekEntries, {}, new Map(), netPartners, null, null);
|
|
6782
|
+
const deniedByFn = new Map();
|
|
6783
|
+
for (const v of peekViolations) {
|
|
6784
|
+
if (!deniedByFn.has(v.fn)) deniedByFn.set(v.fn, new Set());
|
|
6785
|
+
for (const e of v.effects ?? []) deniedByFn.get(v.fn).add(e);
|
|
6786
|
+
}
|
|
6724
6787
|
for (const f of doc.functions ?? []) {
|
|
6725
|
-
const hits = (f
|
|
6788
|
+
const hits = [...(deniedByFn.get(relQual(f)) ?? [])].sort();
|
|
6726
6789
|
if (!hits.length) continue;
|
|
6727
6790
|
// NAME IT FROM THE PROJECT, NOT FROM THE CHILD'S TEMP ROOT. The child's tsconfig lives in a
|
|
6728
6791
|
// temp directory, so it derives module qualifiers from THAT root and the fn came out as a
|
|
@@ -6737,7 +6800,9 @@ if (policyPath && excludedFiles.length) {
|
|
|
6737
6800
|
outOfScopeFindings.push({
|
|
6738
6801
|
fn: f.fn.split(".").pop() ?? f.fn, path: where, effects: hits, class: cls,
|
|
6739
6802
|
reason: `OUTSIDE this scan's scope (${cls}) — the gate did NOT judge it. `
|
|
6740
|
-
+ "
|
|
6803
|
+
+ "candor's ANALYSIS of that file reaches this effect; the gate did not judge it, so "
|
|
6804
|
+
+ "the verdict is INCOMPLETE rather than a pass. (An analysis result, not a claim about "
|
|
6805
|
+
+ "what the code does at runtime — see the release notes' known over-charge.)",
|
|
6741
6806
|
});
|
|
6742
6807
|
}
|
|
6743
6808
|
outOfScopeFindings.sort((a, b) => (a.path + a.fn).localeCompare(b.path + b.fn));
|
|
@@ -6761,7 +6826,7 @@ if (outOfScopeFindings) {
|
|
|
6761
6826
|
if (f.path) console.error(` ${f.path}`);
|
|
6762
6827
|
}
|
|
6763
6828
|
if (outOfScopeFindings.length) {
|
|
6764
|
-
console.error(" The verdict
|
|
6829
|
+
console.error(" The verdict below is INCOMPLETE because of "
|
|
6765
6830
|
+ (outOfScopeFindings.length === 1 ? "it." : `these ${outOfScopeFindings.length}.`));
|
|
6766
6831
|
}
|
|
6767
6832
|
}
|
|
@@ -7356,7 +7421,14 @@ if (gateJsonPath) {
|
|
|
7356
7421
|
// must NOT read green — those effects are invisible, so a `deny`/`pure` that "passes" over them is a
|
|
7357
7422
|
// false-pure. `ok` requires BOTH no violation AND a complete analysis. `analyzed:{count}` (Gap 1) mirrors
|
|
7358
7423
|
// the report envelope so a --gate-json consumer sees the scan's scope from the verdict alone.
|
|
7359
|
-
|
|
7424
|
+
// ⟨0.30⟩ THE SECOND CAUSE OF INCOMPLETENESS — a peeked function performs an effect the policy DENIES.
|
|
7425
|
+
// ⟨0.29⟩ required the verdict NOT to move here, on the assumption the peek surfaces uncertainty a gate
|
|
7426
|
+
// may decline to act on. It does not: measured on published 0.29.1 the peek resolves a CONCRETE denied
|
|
7427
|
+
// effect and names the function (axios 37 × `performs Net`, exit 0, `policy ✓`). Reported through
|
|
7428
|
+
// `incomplete`, never through `violations`, because the gate did not JUDGE these units — see the exit
|
|
7429
|
+
// site below for why that makes the code 2 and not 1.
|
|
7430
|
+
const scopeIncomplete = Array.isArray(outOfScopeFindings) && outOfScopeFindings.length > 0;
|
|
7431
|
+
const incomplete = unanalyzedUnits.length > 0 || scopeIncomplete;
|
|
7360
7432
|
const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0 && !incomplete,
|
|
7361
7433
|
analyzed: { count: fns.size } };
|
|
7362
7434
|
// ⟨0.24⟩ the vocabulary file that moved the verdict, in the SAME position `gate --report` puts it, because
|
|
@@ -7388,8 +7460,14 @@ if (gateJsonPath) {
|
|
|
7388
7460
|
// incomplete:true is honest — never a fabricated pass. OMITTED when complete (byte-compatible verdict).
|
|
7389
7461
|
if (incomplete) {
|
|
7390
7462
|
verdictObj.incomplete = true;
|
|
7391
|
-
|
|
7392
|
-
|
|
7463
|
+
if (unanalyzedUnits.length)
|
|
7464
|
+
verdictObj.unanalyzed = unanalyzedUnits.map((u) => ({ path: u.path, reason: u.reason }));
|
|
7465
|
+
}
|
|
7466
|
+
// ⟨0.30⟩ …and WHICH functions made it incomplete, in the machine channel. The same array the report
|
|
7467
|
+
// carries, so `gate --report` re-emits it from the report and §3.1 byte-equality holds by construction —
|
|
7468
|
+
// this is the anchor the `net-partner` attempt lacked. Omitted when empty, so a clean verdict is
|
|
7469
|
+
// byte-identical to a pre-⟨0.30⟩ one.
|
|
7470
|
+
if (scopeIncomplete) verdictObj.outOfScope = outOfScopeFindings;
|
|
7393
7471
|
// ⟨0.15 staged⟩ coverage ADVISORY (COVERAGE-DESIGN.md §3): when the κ ledger is non-empty, the
|
|
7394
7472
|
// verdict discloses what the gate could NOT see — VERDICT-PRESERVING (the ⟨0.9⟩ provable-purity
|
|
7395
7473
|
// auto-disclosure precedent exactly): ok/violations/exit are computed above and untouched here. A
|
|
@@ -7429,5 +7507,21 @@ if (gateConfigured && unanalyzedUnits.length) {
|
|
|
7429
7507
|
console.error(`candor-ts: gate NOT certified — ${unanalyzedUnits.length} source file(s) could not be analyzed (see above); a gate cannot be green over unanalyzed code`);
|
|
7430
7508
|
process.exit(2);
|
|
7431
7509
|
}
|
|
7510
|
+
// ⟨0.30⟩ THE SCOPE HALF OF THE SAME POSTURE. `unanalyzed` above is "I opened this file and could not read
|
|
7511
|
+
// it"; this is "I never opened it, and when I looked afterwards it performed the effect you denied". Both
|
|
7512
|
+
// mean the same thing to a consumer — the gate could not see enough of this tree to certify it — so both
|
|
7513
|
+
// are exit 2.
|
|
7514
|
+
//
|
|
7515
|
+
// EXIT 2, NOT 1, DELIBERATELY: these functions are not in `violations` and not in `functions`, because the
|
|
7516
|
+
// gate did not judge them. Exit 1 would claim "I judged your code and it breaks the policy", which is
|
|
7517
|
+
// false in the other direction. Exit 2 says "I could not see enough to answer", which is what happened.
|
|
7518
|
+
// A real violation (exit 1, above) still dominates: certain beats unevaluable.
|
|
7519
|
+
if (gateConfigured && Array.isArray(outOfScopeFindings) && outOfScopeFindings.length) {
|
|
7520
|
+
const n = outOfScopeFindings.length;
|
|
7521
|
+
console.error(`candor-ts: gate NOT certified — ${n} function(s) OUTSIDE this scan's scope perform an `
|
|
7522
|
+
+ `effect this policy denies (named above); the gate did not judge them, so the verdict is `
|
|
7523
|
+
+ `incomplete rather than a pass`);
|
|
7524
|
+
process.exit(2);
|
|
7525
|
+
}
|
|
7432
7526
|
if (policyPath !== null) console.error("candor-ts: policy ✓");
|
|
7433
7527
|
if (baselinePath !== null && fs.existsSync(baselinePath)) console.error("candor-ts: baseline ✓"); // absent = inactive (noted above)
|
package/scratch.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// Scratch directories for the harnesses, removed when the run ends.
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS EXISTS. Every harness here made fixture trees with a bare `fs.mkdtempSync` and removed none
|
|
8
|
+
// of them. Measured 2026-08-14: 46,919 `candor-*` directories in $TMPDIR, ~7,300 of them from test.mjs's
|
|
9
|
+
// `project()` alone, which mints one per fixture and is called ~1,300 times a run. It is not only
|
|
10
|
+
// untidy — it made a single `candor-swift privacy-manifest --verify` take 72 seconds, because listing
|
|
11
|
+
// the plist's ancestor meant listing all of $TMPDIR. The engine side of that was fixed in 2026-08-07;
|
|
12
|
+
// this is the side that keeps refilling the directory.
|
|
13
|
+
//
|
|
14
|
+
// KEPT ON FAILURE, DELIBERATELY. A failing assertion prints the path to its fixture tree, and deleting
|
|
15
|
+
// it on the way out would remove the evidence at exactly the moment someone needs it. `keepOnFailure()`
|
|
16
|
+
// lets a harness say "this run failed" and the trees survive, with a line saying where they are.
|
|
17
|
+
// Success is the common case and the one that accumulates.
|
|
18
|
+
//
|
|
19
|
+
// SIGINT/SIGTERM are handled because the killed-mid-run case was the obvious contributor — though 46,919
|
|
20
|
+
// is not all killed runs.
|
|
21
|
+
const made = [];
|
|
22
|
+
let keep = false;
|
|
23
|
+
|
|
24
|
+
export function scratch(prefix) {
|
|
25
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
26
|
+
made.push(d);
|
|
27
|
+
return d;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Call before exiting when the run FAILED — the trees are evidence, so they stay. */
|
|
31
|
+
export function keepOnFailure() { keep = true; }
|
|
32
|
+
|
|
33
|
+
function sweep() {
|
|
34
|
+
if (keep) {
|
|
35
|
+
if (made.length) console.log(` (${made.length} fixture tree(s) kept for inspection under ${os.tmpdir()})`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for (const d of made) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
39
|
+
made.length = 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.on("exit", sweep);
|
|
43
|
+
// An uncaught throw still runs `exit` handlers — with `keep` false, so a harness that dies mid-assertion
|
|
44
|
+
// would sweep away the very trees whose paths the crash just printed. Treat any abnormal end as failure.
|
|
45
|
+
for (const ev of ["uncaughtException", "unhandledRejection"]) {
|
|
46
|
+
process.on(ev, (e) => { keepOnFailure(); console.error(e); process.exit(1); });
|
|
47
|
+
}
|
|
48
|
+
// A signal does NOT run `exit` handlers on its own, which is the killed-mid-run leak. Re-raise after
|
|
49
|
+
// sweeping so the exit status still reflects the signal rather than becoming a clean 0.
|
|
50
|
+
//
|
|
51
|
+
// `removeAllListeners` FIRST, and it is the whole trick: installing a listener REPLACES Node's default
|
|
52
|
+
// disposition for that signal, so `process.kill(process.pid, sig)` re-enters this same handler. The
|
|
53
|
+
// first version of this shipped without it and made the harness unkillable — Ctrl-C swept, re-signalled,
|
|
54
|
+
// swept, forever, and a second Ctrl-C did not help either. Removing the listener restores the default,
|
|
55
|
+
// so the re-raise terminates with the right status.
|
|
56
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
57
|
+
process.on(sig, () => { sweep(); process.removeAllListeners(sig); process.kill(process.pid, sig); });
|
|
58
|
+
}
|