candor-ts 0.30.0 → 0.31.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/query-core.mjs +15 -2
- package/query.mjs +9 -1
- package/scan-core.mjs +18 -3
- package/scan.mjs +318 -35
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.31)."*
|
|
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
|
>
|
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.31" }, 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.30.0, speaking candor-spec 0.
|
|
213
|
+
0.30.0, speaking candor-spec 0.31: 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.31.0",
|
|
4
|
+
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.31)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/query-core.mjs
CHANGED
|
@@ -780,7 +780,7 @@ export function loadReport(prefix) {
|
|
|
780
780
|
*/
|
|
781
781
|
export function loadGateReport(prefix) {
|
|
782
782
|
const files = reportFilesAt(prefix);
|
|
783
|
-
const functions = [], unanalyzed = [], cov = new Map(), corrupt = [], outOfScope = [];
|
|
783
|
+
const functions = [], unanalyzed = [], cov = new Map(), corrupt = [], outOfScope = [], netPartners = [];
|
|
784
784
|
let hardFail = false, analyzed = 0;
|
|
785
785
|
// ⟨0.24⟩ did the report handed to the gate judge ANYTHING? Per FILE, then ANDed across the multi-report
|
|
786
786
|
// siblings, because the union of several reports has judged something as soon as ONE of them has — the
|
|
@@ -858,6 +858,19 @@ export function loadGateReport(prefix) {
|
|
|
858
858
|
if (e && typeof e === "object" && Array.isArray(e.effects) && e.effects.length)
|
|
859
859
|
outOfScope.push(e);
|
|
860
860
|
else corrupt.push(`${f}: \`outOfScope\` (an element is not an object carrying a non-empty \`effects\`)`);
|
|
861
|
+
// ⟨0.31⟩ the producer's PARTNER PROVENANCE, carried through verbatim and never recomputed — this
|
|
862
|
+
// route has no target to anchor `net-partner` at, and re-classifying through the consumer's own
|
|
863
|
+
// config is the re-derivation §3.1 forbids. A prefix can match several reports (a workspace writes
|
|
864
|
+
// one per member), each anchoring its own config, so this is collected as a LIST even though a
|
|
865
|
+
// single report carries a single record. Shape-checked like every other key read off the wire: a
|
|
866
|
+
// malformed one is corruption, not something to quietly drop.
|
|
867
|
+
const nps = parsed.netPartners;
|
|
868
|
+
if (nps !== undefined) {
|
|
869
|
+
if (nps && typeof nps === "object" && !Array.isArray(nps)
|
|
870
|
+
&& typeof nps.config === "string" && Array.isArray(nps.hosts))
|
|
871
|
+
netPartners.push({ config: nps.config, hosts: nps.hosts.map(String) });
|
|
872
|
+
else corrupt.push(`${f}: \`netPartners\` is present and is not { config: string, hosts: [string] }`);
|
|
873
|
+
}
|
|
861
874
|
// ⟨0.15⟩ the κ ledger. Merged + re-sorted the PRODUCER's way (count desc, name asc by code point —
|
|
862
875
|
// reportCoverage's rule, and scan.mjs's), so a single-report prefix reproduces the emitted order exactly.
|
|
863
876
|
const unc = parsed.coverage?.uncovered;
|
|
@@ -870,7 +883,7 @@ export function loadGateReport(prefix) {
|
|
|
870
883
|
}
|
|
871
884
|
const coverage = [...cov.entries()].sort((a, b) => b[1] - a[1] || byCodePoint(a[0], b[0]))
|
|
872
885
|
.map(([name, calls]) => ({ name, calls }));
|
|
873
|
-
return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, hardFail: hardFail || corrupt.length > 0, corrupt };
|
|
886
|
+
return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, netPartners, hardFail: hardFail || corrupt.length > 0, corrupt };
|
|
874
887
|
}
|
|
875
888
|
// The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
|
|
876
889
|
// 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.31";
|
|
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]
|
|
@@ -2170,6 +2170,14 @@ switch (cmd) {
|
|
|
2170
2170
|
const gverdictObj = { spec: SPEC_VERSION, ok: gviol.length === 0 && !gincomplete,
|
|
2171
2171
|
analyzed: { count: g.analyzed } };
|
|
2172
2172
|
if (gvocab) gverdictObj.policyVocabulary = gvocab;
|
|
2173
|
+
// ⟨0.31⟩ the partner declaration that moved the producer's classification, READ OFF THE REPORT —
|
|
2174
|
+
// never recomputed here. This route has no target to anchor `net-partner` at, and the note above
|
|
2175
|
+
// says why recomputing would be wrong anyway: `netClass` is read verbatim off the wire, so
|
|
2176
|
+
// re-classifying through THIS machine's config is the re-derivation §3.1 forbids and would make the
|
|
2177
|
+
// verdict depend on the consumer's CWD. The producer recorded which of its declared partners
|
|
2178
|
+
// actually participated; both routes copy that one record, and byte-equality holds by construction.
|
|
2179
|
+
// Same position as the scan route puts it, for the same reason as `policyVocabulary` above.
|
|
2180
|
+
if (g.netPartners?.length) gverdictObj.netPartners = g.netPartners;
|
|
2173
2181
|
gverdictObj.violations = gviol;
|
|
2174
2182
|
if (gunevaluated.length) gverdictObj.unevaluated = gunevaluated;
|
|
2175
2183
|
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines above carry, in the machine channel,
|
package/scan-core.mjs
CHANGED
|
@@ -404,11 +404,26 @@ export function isTelemetryHost(hostLiteral) { return hostInSet(hostLiteral, TEL
|
|
|
404
404
|
// Literals.netDestClass.
|
|
405
405
|
export function netDestClass(hostLiteral, partners) {
|
|
406
406
|
if (isTelemetryHost(hostLiteral)) return "known-telemetry";
|
|
407
|
-
|
|
408
|
-
const partnerMatch = partners && (partners.has(host) || [...partners].some((p) => host.endsWith("." + p)));
|
|
409
|
-
if (partnerMatch || isModelHost(hostLiteral)) return "known-partner";
|
|
407
|
+
if (partnerFor(hostLiteral, partners) !== null || isModelHost(hostLiteral)) return "known-partner";
|
|
410
408
|
return "unknown-host";
|
|
411
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* ⟨0.31⟩ WHICH declared partner a host matched, or null — the SAME match `netDestClass` decides on,
|
|
412
|
+
* extracted so the DISCLOSURE and the DECISION cannot use different rules.
|
|
413
|
+
*
|
|
414
|
+
* That is not a stylistic preference. The first attempt at the `net-partner` disclosure re-implemented
|
|
415
|
+
* this match and normalised differently from the classifier — `partner.example:443` never equalled the
|
|
416
|
+
* declared `partner.example` — so the disclosure came back SILENTLY EMPTY on every real run while the
|
|
417
|
+
* verdict it was reporting on had flipped. A disclosure normalised differently from the decision it
|
|
418
|
+
* reports can only be wrong, and the way to make that impossible is one function with two callers.
|
|
419
|
+
*/
|
|
420
|
+
export function partnerFor(hostLiteral, partners) {
|
|
421
|
+
if (!partners || !partners.size) return null;
|
|
422
|
+
const host = normHost(hostLiteral);
|
|
423
|
+
if (partners.has(host)) return host;
|
|
424
|
+
for (const p of partners) if (host.endsWith("." + p)) return p;
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
412
427
|
// ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
|
|
413
428
|
export const NET_DEST_CLASSES = ["known-telemetry", "known-partner", "unknown-host"];
|
|
414
429
|
// ⟨0.20⟩ The `Net` destination classes an fn reaches — the SINGLE derivation shared by the report's
|
package/scan.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNe
|
|
|
34
34
|
import { unverifiedHoleRule, ruleUpgrade, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
|
|
35
35
|
import { printAgents, writeStdoutSync } from "./contract.mjs";
|
|
36
36
|
import { isTestPath, kappa, kappaKnows, fsKind, commandHeadEffects, hostLiteral, tablesInSql,
|
|
37
|
-
modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
|
|
37
|
+
modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf, partnerFor } from "./scan-core.mjs";
|
|
38
38
|
import { emitSurface } from "./surface.mjs";
|
|
39
39
|
|
|
40
40
|
const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -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.31";
|
|
49
49
|
|
|
50
50
|
// A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
|
|
51
51
|
//
|
|
@@ -489,7 +489,30 @@ const failClosedReportDoc = (reason) => {
|
|
|
489
489
|
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\n\r]/g, " ");
|
|
490
490
|
return `{\n "candor": {\n "version": "candor-ts-${PKG_VERSION}",\n "toolchain": "node-${process.versions.node}",\n "spec": "${SPEC_VERSION}"\n },\n "functions": [],\n "analyzed": { "count": 0 },\n "unanalyzed": [\n { "path": "<run>", "reason": "${esc(reason)}" }\n ]\n}`;
|
|
491
491
|
};
|
|
492
|
-
|
|
492
|
+
// ⟨0.31⟩ The gate sink this run ARMED, and the ONLY path `refuseEarly` may overwrite.
|
|
493
|
+
//
|
|
494
|
+
// The first version of that helper wrote straight to `preGateSink`, which broke the ⟨0.28⟩ rule it sits
|
|
495
|
+
// beside: a `--gate-json` naming a source file under the scan target is REFUSED and nothing is written
|
|
496
|
+
// there, precisely so arming cannot destroy the source it is about to scan. Writing the refusal document
|
|
497
|
+
// to the same path bypassed that guard and truncated the fixture's own source — six ⟨0.28⟩ rows caught
|
|
498
|
+
// it. A refusal may replace this run's OWN placeholder; it may not write anywhere the arming declined to.
|
|
499
|
+
let armedGateSink = null;
|
|
500
|
+
const refuseEarly = (why) => {
|
|
501
|
+
// ⟨0.31⟩ A FILE `--gate-json` SINK GETS THE REASON TOO, and until now it did not: this only ever
|
|
502
|
+
// wrote to STREAM sinks, so a run refusing with `--gate-json g.json` left the ARMING STUB in place —
|
|
503
|
+
// "the gate did not complete — this document was written when the run STARTED and was never replaced
|
|
504
|
+
// … the run failed, crashed or was killed". That describes a crash. This run did none of those things;
|
|
505
|
+
// it deliberately refused, and it knows exactly why.
|
|
506
|
+
//
|
|
507
|
+
// ⟨0.24⟩ pins the refusal document's `reason` as a string NAMING THE CAUSE, and a false description is
|
|
508
|
+
// rated worse here than a missing one — an operator reading "crashed" goes looking for a crash. The
|
|
509
|
+
// stub is the right answer only while the run's fate is genuinely unknown; once it has decided to
|
|
510
|
+
// refuse, leaving the stub is a stale document, which is the thing this whole sink exists to prevent.
|
|
511
|
+
if (armedGateSink) {
|
|
512
|
+
try {
|
|
513
|
+
fs.writeFileSync(armedGateSink, JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1) + "\n");
|
|
514
|
+
} catch { /* an unwritable sink is already refused elsewhere; never mask the real cause with this */ }
|
|
515
|
+
}
|
|
493
516
|
if (preGateSink === "-") console.log(JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1));
|
|
494
517
|
else if (preWantJson && !reportStreamWritten) {
|
|
495
518
|
// Skipped when stdout is already claimed by `--gate-json -` (the two-stream case is refused
|
|
@@ -770,7 +793,7 @@ const disarmUnwrittenOutReports = () => {
|
|
|
770
793
|
console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
|
|
771
794
|
+ `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy `
|
|
772
795
|
+ `and then gate on the wreckage. Nothing was written; give the verdict its own path.`);
|
|
773
|
-
|
|
796
|
+
refuseEarly(`--gate-json ${gate} names the same file as ${flag} ${other}`);
|
|
774
797
|
process.exit(2);
|
|
775
798
|
}
|
|
776
799
|
}
|
|
@@ -782,7 +805,7 @@ const disarmUnwrittenOutReports = () => {
|
|
|
782
805
|
+ `about to read and then scan the wreckage. A non-source sink under the target `
|
|
783
806
|
+ `(${path.join(targetContainmentRoot(preTarget) ?? ".", ".candor", "verdict.json")}, say) is the `
|
|
784
807
|
+ `recommended layout and stays permitted.`);
|
|
785
|
-
|
|
808
|
+
refuseEarly(`--gate-json ${gate} is a source file under the scan target ${preTarget}`);
|
|
786
809
|
process.exit(2);
|
|
787
810
|
}
|
|
788
811
|
// `.candor/config` is never a verdict sink, wherever it is. The per-input checks above can only name
|
|
@@ -795,13 +818,13 @@ const disarmUnwrittenOutReports = () => {
|
|
|
795
818
|
console.error(`candor-ts: --gate-json ${gate} is a .candor/config — refusing (exit 2). The verdict `
|
|
796
819
|
+ `is armed before the config is read, so this would destroy the config that configures this `
|
|
797
820
|
+ `run. Nothing was written; give the verdict its own path.`);
|
|
798
|
-
|
|
821
|
+
refuseEarly(`--gate-json ${gate} is a .candor/config`); // ⟨0.28⟩ report stream too
|
|
799
822
|
process.exit(2);
|
|
800
823
|
}
|
|
801
824
|
}
|
|
802
825
|
if (gate && singleSink && sameArtifact(gate, process.env.CANDOR_CONFIG)) {
|
|
803
826
|
console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
|
|
804
|
-
|
|
827
|
+
refuseEarly(`--gate-json ${gate} names the same file as CANDOR_CONFIG`); // ⟨0.28⟩
|
|
805
828
|
process.exit(2);
|
|
806
829
|
}
|
|
807
830
|
// ⟨0.28⟩ A REPEATED `--gate-json` IS REFUSED, AND EVERY PATH NAMED GETS THE REFUSAL. Placed after the
|
|
@@ -856,7 +879,7 @@ const disarmUnwrittenOutReports = () => {
|
|
|
856
879
|
if (bad) offending.add(g);
|
|
857
880
|
}
|
|
858
881
|
if (offending.size === distinct.length) {
|
|
859
|
-
|
|
882
|
+
refuseEarly(`every named --gate-json path collides with an input`); // ⟨0.28⟩ report stream
|
|
860
883
|
process.exit(2);
|
|
861
884
|
}
|
|
862
885
|
}
|
|
@@ -878,7 +901,7 @@ const disarmUnwrittenOutReports = () => {
|
|
|
878
901
|
// (its verdict arm keys on `preGateSink === "-"` and doesn't know one was just printed). When `-`
|
|
879
902
|
// isn't in the list, refuseEarlyToStream's report arm handles the `--json` case; when it is, the
|
|
880
903
|
// verdict is already there and `--json` was refused earlier (line 333), so nothing more is owed.
|
|
881
|
-
if (!distinct.includes("-"))
|
|
904
|
+
if (!distinct.includes("-")) refuseEarly(`--gate-json was given more than once (${list})`);
|
|
882
905
|
process.exit(2);
|
|
883
906
|
}
|
|
884
907
|
if (gate && gate !== "-") armGateJsonFailClosed(gate);
|
|
@@ -932,7 +955,7 @@ const disarmUnwrittenOutReports = () => {
|
|
|
932
955
|
const inputs = runInputs(preTarget, policy);
|
|
933
956
|
for (const p of namedOuts) armOutPrefixFailClosed(p, inputs);
|
|
934
957
|
}
|
|
935
|
-
|
|
958
|
+
refuseEarly(`--out was given more than once (${list}) — a run writes one report set to one prefix`);
|
|
936
959
|
process.exit(2);
|
|
937
960
|
}
|
|
938
961
|
if (preOut && !preWantJson) armOutPrefixFailClosed(preOut, runInputs(preTarget, policy));
|
|
@@ -957,7 +980,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
957
980
|
const v = argv[i + 1];
|
|
958
981
|
if (v === undefined || v.startsWith("--")) {
|
|
959
982
|
console.error(`candor-ts: ${a} requires a value (${usage})`);
|
|
960
|
-
|
|
983
|
+
refuseEarly(`${a} requires a value`);
|
|
961
984
|
process.exit(2);
|
|
962
985
|
}
|
|
963
986
|
if (a === "--out") outPrefix = v; else if (a === "--policy") policyPath = v; else gateJsonPath = v;
|
|
@@ -971,21 +994,21 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
971
994
|
console.error(`candor-ts: unknown flag ${a} (${usage})`);
|
|
972
995
|
// ⟨0.27⟩ §3.3 names an unknown flag as a broken-gate-config exit-2 cause; the stream sink gets the
|
|
973
996
|
// refusal document too (see refuseEarlyToStream — the file sink is already armed).
|
|
974
|
-
|
|
997
|
+
refuseEarly(`unknown flag ${a}`);
|
|
975
998
|
process.exit(2);
|
|
976
999
|
}
|
|
977
1000
|
else if (target === null) target = a;
|
|
978
1001
|
else if (outPrefix === null) outPrefix = a; // legacy positional prefix
|
|
979
1002
|
else {
|
|
980
1003
|
console.error(`candor-ts: unexpected extra argument ${a} (${usage})`);
|
|
981
|
-
|
|
1004
|
+
refuseEarly(`unexpected extra argument ${a}`);
|
|
982
1005
|
process.exit(2);
|
|
983
1006
|
}
|
|
984
1007
|
}
|
|
985
1008
|
if (wantAgents) { printAgents(); process.exit(0); }
|
|
986
1009
|
if (target === null) {
|
|
987
1010
|
console.error(usage);
|
|
988
|
-
|
|
1011
|
+
refuseEarly("no scan target");
|
|
989
1012
|
process.exit(2);
|
|
990
1013
|
}
|
|
991
1014
|
|
|
@@ -1092,7 +1115,7 @@ function enforceEnginePin(targetPath) {
|
|
|
1092
1115
|
// duplicate put TWO documents on the stream — which parses as neither. The other branch (a build that
|
|
1093
1116
|
// cannot determine its own release) RETURNS rather than exiting: disclosed, not scored, so no refusal
|
|
1094
1117
|
// belongs there.
|
|
1095
|
-
|
|
1118
|
+
refuseEarly(`.candor/config pins engine ${pin}, which this build does not satisfy`);
|
|
1096
1119
|
process.exit(2);
|
|
1097
1120
|
}
|
|
1098
1121
|
|
|
@@ -1136,7 +1159,7 @@ function loadCandorConfig(targetPath, { lenient = false } = {}) {
|
|
|
1136
1159
|
// The config is the EARLIEST exit-2 cause, and the one the stream sink is least likely to be
|
|
1137
1160
|
// armed for — which is exactly why it was the last one still leaving stdout empty. Found by
|
|
1138
1161
|
// PART 36 (b11), a row written before this line was.
|
|
1139
|
-
|
|
1162
|
+
refuseEarly(`CANDOR_CONFIG set but ${file} is not a readable file`);
|
|
1140
1163
|
process.exit(2);
|
|
1141
1164
|
}
|
|
1142
1165
|
} else {
|
|
@@ -1148,7 +1171,7 @@ function loadCandorConfig(targetPath, { lenient = false } = {}) {
|
|
|
1148
1171
|
catch (e) {
|
|
1149
1172
|
if (lenient) throw new Error(`config ${file} exists but could not be read (${e.message})`);
|
|
1150
1173
|
console.error(`candor-ts: config ${file} exists but could not be read (${e.message}) — failing (exit 2)`);
|
|
1151
|
-
|
|
1174
|
+
refuseEarly(`config ${file} exists but could not be read`);
|
|
1152
1175
|
process.exit(2);
|
|
1153
1176
|
}
|
|
1154
1177
|
const cfg = {};
|
|
@@ -1230,6 +1253,7 @@ function writeSinkAtomic(p, text) {
|
|
|
1230
1253
|
}
|
|
1231
1254
|
|
|
1232
1255
|
function armGateJsonFailClosed(p) {
|
|
1256
|
+
armedGateSink = p; // ⟨0.31⟩ this path is now ours to replace if the run refuses
|
|
1233
1257
|
try {
|
|
1234
1258
|
writeSinkAtomic(p, JSON.stringify({
|
|
1235
1259
|
spec: SPEC_VERSION, ok: false, refused: true,
|
|
@@ -1323,18 +1347,37 @@ if (!stat) {
|
|
|
1323
1347
|
// RETURNS — it is not a `Never`, and calling it INSTEAD of the exit lets the run continue past its
|
|
1324
1348
|
// own refusal (measured, while getting this wrong: an unreadable dep wrote its refusal and then
|
|
1325
1349
|
// exited 0). rust's equivalent is typed `-> !`, which is why the same slip could not happen there.
|
|
1326
|
-
|
|
1350
|
+
refuseEarly(`no such path: ${target}`);
|
|
1327
1351
|
process.exit(2);
|
|
1328
1352
|
}
|
|
1329
1353
|
let usedTsconfig = null; // the tsconfig this run actually read, so a later disclosure can name the
|
|
1330
1354
|
// cause that APPLIES rather than the one that usually does.
|
|
1355
|
+
// ⟨0.31⟩ THE one admission rule. The directory walk had it inline and the single-file branch had none,
|
|
1356
|
+
// which is how a target this engine cannot read reached a green verdict. `.d.ts` is excluded because a
|
|
1357
|
+
// declaration carries no body to analyse, and `.min.js` because a minified bundle is not a source.
|
|
1358
|
+
const admitsAsSource = (name) =>
|
|
1359
|
+
(/\.[mc]?tsx?$/.test(name) && !name.endsWith(".d.ts"))
|
|
1360
|
+
|| (allowJs && /\.[mc]?jsx?$/.test(name) && !/\.min\.js$/.test(name));
|
|
1361
|
+
|
|
1331
1362
|
if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
1332
1363
|
rootDir = path.dirname(path.resolve(target));
|
|
1333
1364
|
usedTsconfig = path.resolve(target);
|
|
1334
1365
|
fileNames = fromTsconfig(path.resolve(target), rootDir);
|
|
1335
1366
|
} else if (stat.isFile()) {
|
|
1336
1367
|
rootDir = path.dirname(path.resolve(target));
|
|
1337
|
-
|
|
1368
|
+
// ⟨0.31⟩ A SINGLE-FILE TARGET IS ADMITTED BY THE SAME RULE AS A FILE INSIDE A DIRECTORY, and this
|
|
1369
|
+
// branch used to admit it UNCONDITIONALLY. MEASURED: `candor-ts x.txt --policy p` printed
|
|
1370
|
+
// `policy ✓` and exited 0 with `ok: true` over a file this engine cannot read — the compiler
|
|
1371
|
+
// silently dropped it and `NO_SOURCES` was false because the name was in the list. rust, java and
|
|
1372
|
+
// swift all refuse the same input at exit 2.
|
|
1373
|
+
//
|
|
1374
|
+
// That is precisely the §3.3 ⟨0.31⟩ cause this rung adds — "the walk admitted no file this engine
|
|
1375
|
+
// can read" — answered with a green gate, in the release that introduces the clause. A typo'd path
|
|
1376
|
+
// in CI (`--policy` pointed at a file, a glob that matched a README) is a permanent pass.
|
|
1377
|
+
//
|
|
1378
|
+
// Single-file targets remain supported: a real `.ts` still scans. What changed is that the file has
|
|
1379
|
+
// to be one this engine parses, which is what the directory walk always required.
|
|
1380
|
+
fileNames = admitsAsSource(path.basename(target)) ? [path.resolve(target)] : [];
|
|
1338
1381
|
} else {
|
|
1339
1382
|
rootDir = path.resolve(target);
|
|
1340
1383
|
const tsconfig = path.join(rootDir, "tsconfig.json");
|
|
@@ -1348,8 +1391,7 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
|
1348
1391
|
const p = path.join(d, ent.name);
|
|
1349
1392
|
if (!IS_PEEK && isTestPath(path.relative(rootDir, p))) continue; // ⟨0.29⟩ see IS_PEEK
|
|
1350
1393
|
if (ent.isDirectory()) walk(p);
|
|
1351
|
-
else if (
|
|
1352
|
-
else if (allowJs && /\.[mc]?jsx?$/.test(ent.name) && !/\.min\.js$/.test(ent.name)) fileNames.push(p);
|
|
1394
|
+
else if (admitsAsSource(ent.name)) fileNames.push(p);
|
|
1353
1395
|
}
|
|
1354
1396
|
})(rootDir);
|
|
1355
1397
|
}
|
|
@@ -1397,13 +1439,43 @@ const EXCLUDED_REASON = {
|
|
|
1397
1439
|
+ "analyzed — a file your build excludes may still run in CI or at install time",
|
|
1398
1440
|
"not-a-parsed-source": "not in this run's parse set",
|
|
1399
1441
|
};
|
|
1400
|
-
|
|
1442
|
+
// ⟨0.30⟩ NO ANALYZABLE SOURCE IS STILL A REASON TO LOOK AT WHAT WAS EXCLUDED.
|
|
1443
|
+
//
|
|
1444
|
+
// This refusal used to exit here, before the peek existed in the run at all. Measured on the PUBLISHED
|
|
1445
|
+
// 0.30.0: a declarations-only package whose `.js` performs the denied effect answered `no TypeScript
|
|
1446
|
+
// sources`, exit 2, and named NOTHING — while candor-rust, over the analogous shape (no `.rs` sources, a
|
|
1447
|
+
// `build.rs` running `curl`), reached its peek and named `build::main performs Exec`. Both fail closed,
|
|
1448
|
+
// so no gate went green; but naming the function IS this rung's premise, and whether a user got names
|
|
1449
|
+
// turned on where a package happens to keep its declarations — `axios` ships `index.d.ts` at the root and
|
|
1450
|
+
// got 13 findings, `ky` ships them under `distribution/` and got a bare refusal.
|
|
1451
|
+
//
|
|
1452
|
+
// So: when a policy is configured and there IS something excluded to look at, the run continues to the
|
|
1453
|
+
// peek and the findings are named. The refusal itself does not move — see the NO_SOURCES arm beside the
|
|
1454
|
+
// other exit-2 causes at the end of this file. THAT ORDER IS THE WHOLE CARE POINT: the first version of
|
|
1455
|
+
// this change let the run fall through to a normal ending, and a clean-sibling tree — zero files
|
|
1456
|
+
// analyzed, nothing wrong in the excluded `.js` — answered `policy ✓` at EXIT 0. A green over a tree the
|
|
1457
|
+
// engine never read is the cardinal sin, introduced by a disclosure fix, and it was caught by writing
|
|
1458
|
+
// that control fixture BEFORE the fix rather than after.
|
|
1459
|
+
const NO_SOURCES = fileNames.length === 0;
|
|
1460
|
+
if (NO_SOURCES && !(policyPath && excludedFiles.length)) {
|
|
1401
1461
|
console.error(`candor-ts: no TypeScript sources under ${target}`);
|
|
1462
|
+
// §3.3(d) MUST: the refusal names what a target of this engine's kind looks like. Without it the
|
|
1463
|
+
// message says only that something is wrong, and the commonest cause — a path that moved, or one
|
|
1464
|
+
// pointing at a file this engine does not parse — has an obvious fix the reader is left to guess.
|
|
1465
|
+
console.error(` candor-ts reads .ts/.tsx/.mts/.cts (and .js with --allow-js) — point it at a `
|
|
1466
|
+
+ `project directory or a TypeScript file. Exit 2 (unevaluable): a target this engine `
|
|
1467
|
+
+ `cannot read is not a clean scan.`);
|
|
1402
1468
|
// An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
|
|
1403
1469
|
// get nothing. §3.1 exempts no cause, and this one is easy to hit in CI (a path that moved).
|
|
1404
|
-
|
|
1470
|
+
refuseEarly(`no TypeScript sources under ${target} — candor-ts reads .ts/.tsx/.mts/.cts `
|
|
1471
|
+
+ `(and .js with --allow-js); point it at a project directory or a TypeScript file. `
|
|
1472
|
+
+ `Exit 2 (unevaluable): a target this engine cannot read is not a clean scan.`);
|
|
1405
1473
|
process.exit(2);
|
|
1406
1474
|
}
|
|
1475
|
+
if (NO_SOURCES) {
|
|
1476
|
+
console.error(`candor-ts: no TypeScript sources under ${target} — reading what this scan excluded, `
|
|
1477
|
+
+ `because a policy is configured and there are excluded files to look at`);
|
|
1478
|
+
}
|
|
1407
1479
|
// Builtin typings FALLBACK: the engine ships @types/node as its own dependency, so a target that
|
|
1408
1480
|
// hasn't installed it still resolves node:fs/node:net/… (found by the first npx-distribution
|
|
1409
1481
|
// probe: a bare fixture read Unknown for fs.readFileSync because nothing supplied the builtin
|
|
@@ -1436,7 +1508,7 @@ if (!wantJson) {
|
|
|
1436
1508
|
console.error(`candor-ts: the report set at --out ${outPrefix} includes ${w}, which is the SAME `
|
|
1437
1509
|
+ `FILE as ${label} ${other} — refusing (exit 2). Writing the report there would destroy an `
|
|
1438
1510
|
+ `input of this run. Nothing was scanned; give the report set its own prefix.`);
|
|
1439
|
-
|
|
1511
|
+
refuseEarly(`the report set at ${outPrefix} would overwrite ${label} ${other}`);
|
|
1440
1512
|
process.exit(2);
|
|
1441
1513
|
}
|
|
1442
1514
|
}
|
|
@@ -1944,7 +2016,7 @@ const corruptDepPkgs = new Set();
|
|
|
1944
2016
|
// The TOKEN arm needed this too. The read and parse arms below were routed to the stream and this
|
|
1945
2017
|
// one was not — three exits for one rule, two of them answered on the machine channel and one
|
|
1946
2018
|
// silent, which a conformance row (PART 36 b8) caught immediately once the cause was posed at all.
|
|
1947
|
-
|
|
2019
|
+
refuseEarly(`configured dependency ${tok} is not a readable file or directory`);
|
|
1948
2020
|
process.exit(2);
|
|
1949
2021
|
}
|
|
1950
2022
|
}
|
|
@@ -1968,7 +2040,7 @@ const corruptDepPkgs = new Set();
|
|
|
1968
2040
|
console.error(` failing (exit 2, unevaluable). A configured dep this scan cannot read is not`);
|
|
1969
2041
|
console.error(` reduced coverage: its callers would serialise \`inferred: []\`, a purity claim`);
|
|
1970
2042
|
console.error(` about code this scan never saw.`);
|
|
1971
|
-
|
|
2043
|
+
refuseEarly(`configured dependency report ${f} could not be read`);
|
|
1972
2044
|
process.exit(2);
|
|
1973
2045
|
}
|
|
1974
2046
|
let parsed;
|
|
@@ -1978,7 +2050,7 @@ const corruptDepPkgs = new Set();
|
|
|
1978
2050
|
console.error(`candor-ts: CANDOR_DEPS report ${f} is not valid JSON —`);
|
|
1979
2051
|
console.error(` failing (exit 2, unevaluable). Same reason as an unreadable one: a report`);
|
|
1980
2052
|
console.error(` that cannot be parsed makes no claim, and continuing would publish one.`);
|
|
1981
|
-
|
|
2053
|
+
refuseEarly(`configured dependency report ${f} is not valid JSON`);
|
|
1982
2054
|
process.exit(2);
|
|
1983
2055
|
}
|
|
1984
2056
|
try {
|
|
@@ -3463,6 +3535,74 @@ function enclosing(node) {
|
|
|
3463
3535
|
// and the whole-module Net rule paints them — but console fd 0/1/2 I/O is not Net (§1 has no Console effect).
|
|
3464
3536
|
// `net.Socket.on`/`.write` return the stream (`this`), so a chained call's receiver is still the std stream;
|
|
3465
3537
|
// the exact-string check missed it (the receiver is the inner CallExpression). Walk the chain to its head.
|
|
3538
|
+
// R54 — THE STD-STREAM CARVE-OUT, DECIDED FROM THE RECEIVER'S TYPE RATHER THAN ITS SPELLING.
|
|
3539
|
+
//
|
|
3540
|
+
// `rootsAtStdStream` above matches the TEXT of the receiver chain, so it only ever recognised
|
|
3541
|
+
// `process.stdout.write(...)` written literally. MEASURED on `execa` in the ⟨0.30⟩ blast-radius sweep:
|
|
3542
|
+
// one level of indirection defeats it —
|
|
3543
|
+
//
|
|
3544
|
+
// const pick = fd => (fd === 1 ? process.stdout : createWriteStream("", {fd}));
|
|
3545
|
+
// pick(3).write("hello"); // charged Net, netClass unknown-host. There is no socket.
|
|
3546
|
+
//
|
|
3547
|
+
// The receiver types as `tty.WriteStream | fs.WriteStream`, `tty.WriteStream` EXTENDS `net.Socket`, so
|
|
3548
|
+
// resolving `.write` lands in the net cluster's typings and the whole-module Net rule paints it. It
|
|
3549
|
+
// accounted for 2 of the 31 ⟨0.30⟩ verdict flips — the only two of that sweep that were not genuine.
|
|
3550
|
+
//
|
|
3551
|
+
// DENYLIST, NOT ALLOWLIST, and the direction is stated so it can be checked: this suppresses Net ONLY
|
|
3552
|
+
// when EVERY constituent of the receiver's type is a PROVEN non-network stream class. An unknown
|
|
3553
|
+
// constituent, an `any`, a project type, or a real `net.Socket` all fail the test and KEEP the charge.
|
|
3554
|
+
// The failure mode is therefore an over-report, never a false all-clear.
|
|
3555
|
+
//
|
|
3556
|
+
// THE SAFE SET IS CONCRETE CLASSES ONLY. `stream.Writable`/`Readable` are deliberately ABSENT: a real
|
|
3557
|
+
// `net.Socket` IS a `stream.Duplex` and therefore satisfies them, so admitting the base types would
|
|
3558
|
+
// suppress Net on genuine sockets — the exact silent under-report this family has measured arriving in
|
|
3559
|
+
// 4 of 5 over-charge fixes. Only `tty`/`fs` stream classes, and only when declared in @types/node's own
|
|
3560
|
+
// `tty.d.ts`/`fs.d.ts`, so a project class named `WriteStream` cannot buy the carve-out.
|
|
3561
|
+
const SAFE_STREAM_CLASSES = new Set(["WriteStream", "ReadStream"]);
|
|
3562
|
+
// `process` is in the set because that is where @types/node DECLARES the std streams' own
|
|
3563
|
+
// `WriteStream`/`ReadStream` (process.stdout is `WriteStream & { fd: 1 }`, declared in process.d.ts and
|
|
3564
|
+
// extending tty's). Measured: without it the predicate rejected the very case it exists for.
|
|
3565
|
+
const SAFE_STREAM_MODULES = new Set(["tty", "fs", "process"]);
|
|
3566
|
+
function receiverIsProvenNonNetworkStream(expr) {
|
|
3567
|
+
if (!expr) return false;
|
|
3568
|
+
let t;
|
|
3569
|
+
try { t = checker.getTypeAtLocation(expr); } catch { return false; }
|
|
3570
|
+
if (!t) return false;
|
|
3571
|
+
const parts = (t.isUnion && t.isUnion()) ? t.types : [t];
|
|
3572
|
+
if (!parts.length) return false;
|
|
3573
|
+
// INTERSECTIONS CARRY NO SINGLE SYMBOL, and every real case here is one: `process.stdout` types as
|
|
3574
|
+
// `tty.WriteStream & { fd: 1 }`, and the helper's union is `(WriteStream & {fd:1}) | WriteStream`.
|
|
3575
|
+
// The first attempt asked each union part for its symbol, got none for the intersection, and
|
|
3576
|
+
// suppressed nothing — measured, before this comment existed.
|
|
3577
|
+
const flatten = (ty) => (ty.isIntersection && ty.isIntersection()) ? ty.types : [ty];
|
|
3578
|
+
const declaredSafely = (sym) => {
|
|
3579
|
+
const decls = sym.getDeclarations?.() ?? [];
|
|
3580
|
+
if (!decls.length) return false;
|
|
3581
|
+
return decls.every((d) => {
|
|
3582
|
+
const f = path.resolve(d.getSourceFile().fileName);
|
|
3583
|
+
const m = f.match(/@types\/node\/(.+?)\.d\.ts$/);
|
|
3584
|
+
return !!m && SAFE_STREAM_MODULES.has(m[1]);
|
|
3585
|
+
});
|
|
3586
|
+
};
|
|
3587
|
+
return parts.every((part) => {
|
|
3588
|
+
let sawSafe = false;
|
|
3589
|
+
for (const member of flatten(part)) {
|
|
3590
|
+
const sym = member.getSymbol?.() ?? member.symbol;
|
|
3591
|
+
const name = sym?.getName?.();
|
|
3592
|
+
// An ANONYMOUS shape (`{ fd: 1 }`) carries no class identity: it neither proves nor disproves,
|
|
3593
|
+
// so it is skipped. If EVERY member is anonymous, `sawSafe` stays false and the charge is kept.
|
|
3594
|
+
// TypeScript names anonymous shapes SYNTHETICALLY — `__type` for a type literal,
|
|
3595
|
+
// `__object` for an object literal — so `!name` alone does not skip them and the
|
|
3596
|
+
// `{ fd: 1 }` half of `process.stdout`'s intersection fell through to the rejection
|
|
3597
|
+
// below. Measured: this is what still suppressed nothing after the intersection fix.
|
|
3598
|
+
if (!name || name.startsWith("__")) continue;
|
|
3599
|
+
if (!SAFE_STREAM_CLASSES.has(name) || !declaredSafely(sym)) return false;
|
|
3600
|
+
sawSafe = true;
|
|
3601
|
+
}
|
|
3602
|
+
return sawSafe;
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3466
3606
|
function rootsAtStdStream(expr) {
|
|
3467
3607
|
let e = expr;
|
|
3468
3608
|
for (;;) {
|
|
@@ -4848,6 +4988,14 @@ function visitCalls(node) {
|
|
|
4848
4988
|
if (eff && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
|
|
4849
4989
|
&& rootsAtStdStream(node.expression.expression))
|
|
4850
4990
|
eff = null;
|
|
4991
|
+
// R54 — the same carve-out through a helper, decided from the receiver's TYPE. SCOPED TO Net
|
|
4992
|
+
// DELIBERATELY: an `fs.WriteStream` receiver legitimately carries Fs, and suppressing whatever
|
|
4993
|
+
// effect happened to resolve would swallow that — the same under-report one door along. Only
|
|
4994
|
+
// the fabricated network charge is removed.
|
|
4995
|
+
if (eff === "Net"
|
|
4996
|
+
&& (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
|
|
4997
|
+
&& receiverIsProvenNonNetworkStream(node.expression.expression))
|
|
4998
|
+
eff = null;
|
|
4851
4999
|
if (eff) {
|
|
4852
5000
|
rec.direct.add(eff);
|
|
4853
5001
|
// SPEC §2 `fs` — refine an Fs we just PROVED with the direction its verb implies. DIRECT only
|
|
@@ -6092,6 +6240,8 @@ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsK
|
|
|
6092
6240
|
// nothing for the rest of the run. Printed once, here, beside the other config diagnostics.
|
|
6093
6241
|
const netPartnerErrs = [];
|
|
6094
6242
|
const netPartners = parseNetPartners(discoverConfigText(target), netPartnerErrs);
|
|
6243
|
+
// ⟨0.31⟩ the declared partners that actually MOVED a classification in this run — see the envelope key.
|
|
6244
|
+
const partnersUsed = new Set();
|
|
6095
6245
|
for (const e of netPartnerErrs) console.error(`candor-ts: ${e.why} — ${e.raw}`);
|
|
6096
6246
|
const functions = [];
|
|
6097
6247
|
for (const [name, rec] of fns) {
|
|
@@ -6123,7 +6273,16 @@ for (const [name, rec] of fns) {
|
|
|
6123
6273
|
// ⟨0.20⟩ Net destination-class (NET-DESTINATION-CLASS-DESIGN.md): the classes present in this fn's
|
|
6124
6274
|
// transitive Net surface — exact host-literal match, fail-closed unknown-host on a masked surface (rec
|
|
6125
6275
|
// .incomplete has Net) OR a Net with no visible host. The class travels the call graph like the effect.
|
|
6126
|
-
if (inf.includes("Net"))
|
|
6276
|
+
if (inf.includes("Net")) {
|
|
6277
|
+
entry.netClass = netClassesOf([...rec.hosts], rec.incomplete.has("Net"), netPartners);
|
|
6278
|
+
// ⟨0.31⟩ RECORD WHICH DECLARED PARTNER PARTICIPATED, at the point the class is decided. Asking
|
|
6279
|
+
// `partnerFor` — the function `netDestClass` itself asks — is what keeps the disclosure and the
|
|
6280
|
+
// decision on one rule; the reverted first attempt re-matched and normalised differently.
|
|
6281
|
+
for (const h of rec.hosts) {
|
|
6282
|
+
const pm = partnerFor(h, netPartners);
|
|
6283
|
+
if (pm) partnersUsed.add(pm);
|
|
6284
|
+
}
|
|
6285
|
+
}
|
|
6127
6286
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
6128
6287
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
6129
6288
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
@@ -6566,7 +6725,7 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
6566
6725
|
// ⟨0.28⟩ report stream: this exit-2 fires BEFORE the envelope is printed, so a `--json` run would
|
|
6567
6726
|
// otherwise leave stdout empty for the whole class of trust-marker contradictions. The latch is
|
|
6568
6727
|
// still unset at this point; the helper writes the fail-closed doc as stdout's only content.
|
|
6569
|
-
|
|
6728
|
+
refuseEarly(`${contradictions.length} report entr${contradictions.length === 1 ? "y contradicts its" : "ies contradict their"} own trust markers`);
|
|
6570
6729
|
process.exit(2);
|
|
6571
6730
|
}
|
|
6572
6731
|
}
|
|
@@ -6631,6 +6790,26 @@ if (uncoveredLedger.length) {
|
|
|
6631
6790
|
// and NOT cross-engine comparable — qualifiers differ). ALWAYS present.
|
|
6632
6791
|
const analyzedQuals = [...fns.keys()].sort();
|
|
6633
6792
|
envelope.analyzed = { count: fns.size, digest: fnv1aHex(analyzedQuals) };
|
|
6793
|
+
// ⟨0.31⟩ `netPartners` — WHICH ambient `.candor/config` declared a partner, and which of its hosts
|
|
6794
|
+
// actually participated. MEASURED: `deny Net[unknown-host]` over a call to `partner.example` exits 1;
|
|
6795
|
+
// adding `net-partner partner.example` to `.candor/config` exits 0 with `ok:true`, and nothing in the
|
|
6796
|
+
// verdict named the file, its path, or the host. An operator reading that green could not tell an
|
|
6797
|
+
// ambient file turned a red into it — the same failure §3.1's `policyVocabulary` exists to refuse for
|
|
6798
|
+
// `unknown-alias`, whose argument reaches this key and whose MUST did not.
|
|
6799
|
+
//
|
|
6800
|
+
// IT LIVES IN THE REPORT, and that is what makes it emittable at all. `net-partner` anchors at the
|
|
6801
|
+
// TARGET, and `gate --report` has no target — it reads the producer's already-computed `netClass`. A
|
|
6802
|
+
// verdict-only disclosure is therefore computable on one route and not the other, which breaks §3.1's
|
|
6803
|
+
// byte-equality and is exactly why the first attempt was reverted (candor-ts's own suite: "pure: NOT
|
|
6804
|
+
// byte-equal"). Recorded by the PRODUCER, both routes read the same source, and they agree by
|
|
6805
|
+
// construction — the shape ⟨0.30⟩'s `outOfScope` already uses for the same structural reason.
|
|
6806
|
+
//
|
|
6807
|
+
// Omitted when empty, so a project declaring no partners — or declaring some that never matched — is
|
|
6808
|
+
// byte-identical to a pre-rung report. A declaration that changed nothing is not provenance.
|
|
6809
|
+
if (partnersUsed.size) {
|
|
6810
|
+
const cfg = discoverConfigPath(target);
|
|
6811
|
+
if (cfg) envelope.netPartners = { config: cfg, hosts: [...partnersUsed].sort() };
|
|
6812
|
+
}
|
|
6634
6813
|
// ⟨0.21⟩ COMPLETENESS MANIFEST (Gap 2): the target's own source candor could NOT analyze (unparsed .ts).
|
|
6635
6814
|
// OMITTED when empty — a complete scan stays byte-identical to a pre-rung report — so a MACHINE reading
|
|
6636
6815
|
// --json sees the incompleteness the stderr warning alone used to hide.
|
|
@@ -6701,6 +6880,30 @@ if (policyPath && excludedFiles.length) {
|
|
|
6701
6880
|
// passed at exit 0 while the strictly weaker `deny Exec` exited 2 on the same files. MEASURED four-way.
|
|
6702
6881
|
if ((peekPolicy?.deny ?? []).length) {
|
|
6703
6882
|
outOfScopeFindings = [];
|
|
6883
|
+
// ⟨0.31⟩ A FILE THIS ENGINE CANNOT READ CANNOT BE PEEKED, AND THE CLASS MUST SAY SO.
|
|
6884
|
+
//
|
|
6885
|
+
// MEASURED: an excluded file performing a denied `Fs`, with the policy `deny Fs`. Readable, the peek
|
|
6886
|
+
// finds it and the gate exits 2 naming the function. `chmod 000` on that ONE file and the same run
|
|
6887
|
+
// exits 0 with `ok: true`, `outOfScope: []` and `excluded: [{class: …, peeked: true}]` — byte-
|
|
6888
|
+
// identical to a genuinely clean peek. The only difference between a red gate and a green one is
|
|
6889
|
+
// whether the engine could open the file, and nothing in the report says so.
|
|
6890
|
+
//
|
|
6891
|
+
// The child's TypeScript program drops an unreadable file SILENTLY: no diagnostic, and no entry in
|
|
6892
|
+
// `unanalyzed`, so the `peekUnread` accounting below never fires. candor-rust answers `peeked: false`
|
|
6893
|
+
// on the same shape, so this was ts alone.
|
|
6894
|
+
//
|
|
6895
|
+
// Asked HERE, in the parent, because the parent is the process that decided these files are in the
|
|
6896
|
+
// peek's scope and can check the premise directly. A file the parent cannot read is one the child
|
|
6897
|
+
// cannot read either. This is ⟨0.26⟩'s manifest rule applied to the peek: a claim of completeness
|
|
6898
|
+
// over a set requires having actually covered the set, and a PARTIAL peek publishing `peeked: true`
|
|
6899
|
+
// answers worse than one that admits it read nothing.
|
|
6900
|
+
for (const e of excludedFiles) {
|
|
6901
|
+
try {
|
|
6902
|
+
fs.accessSync(path.resolve(rootDir, e.path), fs.constants.R_OK);
|
|
6903
|
+
} catch {
|
|
6904
|
+
peekUnread.add(e.cls); // its class withdraws the completeness claim; the gate stays cautious
|
|
6905
|
+
}
|
|
6906
|
+
}
|
|
6704
6907
|
const peekDir = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-peek-"));
|
|
6705
6908
|
try {
|
|
6706
6909
|
fs.writeFileSync(path.join(peekDir, "tsconfig.json"), JSON.stringify({
|
|
@@ -6721,10 +6924,62 @@ if (policyPath && excludedFiles.length) {
|
|
|
6721
6924
|
stdio: ["ignore", "pipe", "ignore"] });
|
|
6722
6925
|
const doc = JSON.parse(out);
|
|
6723
6926
|
peekRead = true; // the child returned a report this run could read — see `peekRead`
|
|
6927
|
+
// ⟨0.31⟩ ONE LOOKUP FOR ALL THREE CALLERS. This was written THREE times — the matcher's
|
|
6928
|
+
// qualifier, the finding's path, and the `unanalyzed`→`peekUnread` attribution below — and every
|
|
6929
|
+
// copy carried the same defect:
|
|
6930
|
+
// find(e => loc.endsWith(e.path) || loc.endsWith(basename(e.path)))
|
|
6931
|
+
// The `||` sits INSIDE one `.find`, so a BASENAME match on an earlier entry beats a FULL PATH
|
|
6932
|
+
// match on a later one. MEASURED: with `src/one/dup.test.ts` and `src/two/dup.test.ts`, a
|
|
6933
|
+
// function from `two` was disclosed at `one`. A previous fix collapsed two of the three copies
|
|
6934
|
+
// and its commit message said so; the third survived here, where the failure mode is not a
|
|
6935
|
+
// fabricated locator but CLASS MIS-ATTRIBUTION feeding `peeked` — worse in kind.
|
|
6936
|
+
//
|
|
6937
|
+
// AND SUFFIX MATCHING IS ITSELF THE BUG. The replacement tried "longest suffix wins" and argued
|
|
6938
|
+
// no counterexample existed; here is one: a project directory literally NAMED `app`, holding
|
|
6939
|
+
// `x.test.ts` and `app/x.test.ts`. The root file's absolute path `/…/app/x.test.ts` genuinely
|
|
6940
|
+
// ends with the NESTED file's relative path `app/x.test.ts`, on a proper segment boundary, and
|
|
6941
|
+
// it is the longer match — so the root function is disclosed at the nested file. A
|
|
6942
|
+
// segment-boundary test would not have caught it either.
|
|
6943
|
+
//
|
|
6944
|
+
// So: RESOLVE, don't match. The child is handed absolute paths under `rootDir`, so its reported
|
|
6945
|
+
// location relativises back to exactly the `e.path` this run recorded. Suffix and basename
|
|
6946
|
+
// remain only as fallbacks for a child that reported something unrelativisable, and the basename
|
|
6947
|
+
// one is used only when it names exactly ONE excluded file. When nothing resolves this returns
|
|
6948
|
+
// null and the caller reports the child's own path — an ugly true locator beats a tidy false one.
|
|
6949
|
+
const locateExcluded = (childLoc) => {
|
|
6950
|
+
if (!childLoc) return null;
|
|
6951
|
+
// RESOLVE AGAINST THE CHILD'S OWN ROOT. The child scan runs under a tsconfig in `peekDir`, and
|
|
6952
|
+
// reports locations RELATIVE TO THAT — `../../../../../private/tmp/…/app/x.test.ts`. Resolving
|
|
6953
|
+
// such a path against the parent's cwd yields nonsense, which is why the first attempt at an
|
|
6954
|
+
// exact match never matched anything and quietly fell through to the suffix arm that carries
|
|
6955
|
+
// the bug. Resolve against `peekDir`, then relativise to `rootDir`, and the result is exactly
|
|
6956
|
+
// the `e.path` this run recorded.
|
|
6957
|
+
const abs = path.resolve(peekDir, childLoc);
|
|
6958
|
+
let rel = path.relative(rootDir, abs);
|
|
6959
|
+
let exact = excludedFiles.find((e) => e.path === rel);
|
|
6960
|
+
// …and once more through realpath, because macOS resolves `/tmp` to `/private/tmp` and a run
|
|
6961
|
+
// invoked through the symlinked spelling would otherwise miss its own files.
|
|
6962
|
+
if (!exact) {
|
|
6963
|
+
try {
|
|
6964
|
+
rel = path.relative(fs.realpathSync(rootDir), fs.realpathSync(abs));
|
|
6965
|
+
exact = excludedFiles.find((e) => e.path === rel);
|
|
6966
|
+
} catch { /* the file may not exist from here; fall through to the suffix arms */ }
|
|
6967
|
+
}
|
|
6968
|
+
if (exact) return exact;
|
|
6969
|
+
let best = null;
|
|
6970
|
+
for (const e of excludedFiles) {
|
|
6971
|
+
const sfx = path.sep + e.path;
|
|
6972
|
+
if ((childLoc === e.path || childLoc.endsWith(sfx) || childLoc.endsWith("/" + e.path))
|
|
6973
|
+
&& (!best || e.path.length > best.path.length)) best = e;
|
|
6974
|
+
}
|
|
6975
|
+
if (best) return best;
|
|
6976
|
+
const base = path.basename(childLoc);
|
|
6977
|
+
const byBase = excludedFiles.filter((e) => path.basename(e.path) === base);
|
|
6978
|
+
return byBase.length === 1 ? byBase[0] : null;
|
|
6979
|
+
};
|
|
6724
6980
|
for (const u of Array.isArray(doc.unanalyzed) ? doc.unanalyzed : []) {
|
|
6725
6981
|
const where = String(u?.path ?? "");
|
|
6726
|
-
const hit =
|
|
6727
|
-
|| where.endsWith(path.basename(e.path)));
|
|
6982
|
+
const hit = locateExcluded(where);
|
|
6728
6983
|
// The peek walks ONLY excluded files, so an unread path matching no exclusion is one this code
|
|
6729
6984
|
// cannot attribute — fail closed across every class rather than let one unattributable file leave
|
|
6730
6985
|
// all of them claiming completeness.
|
|
@@ -6769,8 +7024,7 @@ if (policyPath && excludedFiles.length) {
|
|
|
6769
7024
|
// the finding below is already named from it. The MATCHER must see the same thing the FINDING does.
|
|
6770
7025
|
const relQual = (f) => {
|
|
6771
7026
|
const childLoc = (f.loc ?? "").split(":")[0] ?? "";
|
|
6772
|
-
const hit =
|
|
6773
|
-
|| childLoc.endsWith(path.basename(e.path)));
|
|
7027
|
+
const hit = locateExcluded(childLoc);
|
|
6774
7028
|
if (!hit) return f.fn;
|
|
6775
7029
|
const stem = hit.path.replace(/\.[cm]?[jt]sx?$/, "").split(path.sep).join(".");
|
|
6776
7030
|
const leaf = f.fn.split(".").pop() ?? f.fn;
|
|
@@ -6793,8 +7047,7 @@ if (policyPath && excludedFiles.length) {
|
|
|
6793
7047
|
// exists by the time anyone reads it. The project-relative path is already known here (it is
|
|
6794
7048
|
// what was excluded), so match on basename and report the path we already trust.
|
|
6795
7049
|
const childLoc = (f.loc ?? "").split(":")[0] ?? "";
|
|
6796
|
-
const hit =
|
|
6797
|
-
|| childLoc.endsWith(path.basename(e.path)));
|
|
7050
|
+
const hit = locateExcluded(childLoc);
|
|
6798
7051
|
const where = hit?.path ?? childLoc;
|
|
6799
7052
|
const cls = hit?.cls ?? "excluded";
|
|
6800
7053
|
outOfScopeFindings.push({
|
|
@@ -6816,6 +7069,31 @@ if (policyPath && excludedFiles.length) {
|
|
|
6816
7069
|
}
|
|
6817
7070
|
}
|
|
6818
7071
|
}
|
|
7072
|
+
// ⟨0.30⟩ NOTHING ANALYZABLE, AND THE PEEK FOUND NOTHING EITHER: REFUSE — AND REFUSE BEFORE AN ENVELOPE
|
|
7073
|
+
// EXISTS. The peek above is the whole reason this run got past the early refusal; if it named something,
|
|
7074
|
+
// the ⟨0.30⟩ arm at the end reports it and exits 2 with `outOfScope` IN the report, so `gate --report` over
|
|
7075
|
+
// that report reaches the same 2 and §3.1 holds. If it named nothing there is nothing to report, and the
|
|
7076
|
+
// run must go back to being a refusal.
|
|
7077
|
+
//
|
|
7078
|
+
// WHY HERE AND NOT AT THE END, measured: the first version of this change let the clean case fall through
|
|
7079
|
+
// to a normal ending and exit 2 from an arm after the verdict was written — so the process exited 2 while
|
|
7080
|
+
// `--gate-json` said `ok: true`, and `gate --report` over the report it left behind exited 0. The exit code
|
|
7081
|
+
// was right and the DOCUMENT was green; a SARIF consumer reads the document. §3.1's byte-equality is
|
|
7082
|
+
// quantified over "any report a scan produced", so the only safe refusal is one that produces no report at
|
|
7083
|
+
// all — once an envelope exists, the scan route owns the gate route's answer.
|
|
7084
|
+
if (NO_SOURCES && !(Array.isArray(outOfScopeFindings) && outOfScopeFindings.length)) {
|
|
7085
|
+
console.error(`candor-ts: gate NOT certified — no analyzable TypeScript source under ${target}, and the `
|
|
7086
|
+
+ `files this scan excluded perform no effect this policy denies; a gate cannot be green over a tree `
|
|
7087
|
+
+ `it did not read`);
|
|
7088
|
+
// §3.3(d) MUST — the same remedy the no-policy branch names. This arm explains WHY the gate is not
|
|
7089
|
+
// certified and, without this, still leaves the reader to guess what a valid target looks like.
|
|
7090
|
+
console.error(` candor-ts reads .ts/.tsx/.mts/.cts (and .js with --allow-js) — point it at a `
|
|
7091
|
+
+ `project directory or a TypeScript file.`);
|
|
7092
|
+
refuseEarly(`no analyzable TypeScript source under ${target} — candor-ts reads `
|
|
7093
|
+
+ `.ts/.tsx/.mts/.cts (and .js with --allow-js); point it at a project directory or a `
|
|
7094
|
+
+ `TypeScript file.`);
|
|
7095
|
+
process.exit(2);
|
|
7096
|
+
}
|
|
6819
7097
|
if (outOfScopeFindings) {
|
|
6820
7098
|
envelope.outOfScope = outOfScopeFindings;
|
|
6821
7099
|
// SAY IT ON STDERR TOO. The report block is for machines; an operator reading `policy ✓` needs to know
|
|
@@ -7434,6 +7712,11 @@ if (gateJsonPath) {
|
|
|
7434
7712
|
// ⟨0.24⟩ the vocabulary file that moved the verdict, in the SAME position `gate --report` puts it, because
|
|
7435
7713
|
// §3.1 makes byte-equality between the two routes the acceptance test. Omitted when no alias was used.
|
|
7436
7714
|
if (policyVocabulary) verdictObj.policyVocabulary = policyVocabulary;
|
|
7715
|
+
// ⟨0.31⟩ …and the partner declaration that moved it, COPIED FROM THE REPORT rather than recomputed.
|
|
7716
|
+
// Copying is the point: `gate --report` cannot compute this (no target), so if the two routes derived
|
|
7717
|
+
// it independently they could not agree. Reading the producer's record on both routes is what makes
|
|
7718
|
+
// the byte-equality hold instead of break. Same position in both, for the same reason as the line above.
|
|
7719
|
+
if (envelope.netPartners) verdictObj.netPartners = [envelope.netPartners];
|
|
7437
7720
|
verdictObj.violations = gateViolations;
|
|
7438
7721
|
// ⟨0.24⟩ …and when a certain violation DOMINATED a policy refusal, the refusal is disclosed here rather
|
|
7439
7722
|
// than deleted (SPEC §3.1: "the RAW policy line, verbatim", one entry per rule, omitted when empty). A
|