candor-ts 0.20.0 → 0.21.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.mjs +1 -1
- package/scan-core.mjs +5 -0
- package/scan.mjs +80 -2
package/AGENTS.md
CHANGED
|
@@ -12,7 +12,7 @@ chains by hand.
|
|
|
12
12
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
13
13
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
14
14
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
15
|
-
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.21)."*
|
|
16
16
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
17
17
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
18
18
|
>
|
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
184
184
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
185
185
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
186
186
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
187
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
187
|
+
| `{ candor: { version, toolchain, spec: "0.21" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
188
188
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
189
189
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
190
190
|
|
|
@@ -202,7 +202,7 @@ read the Rust source".
|
|
|
202
202
|
|
|
203
203
|
## Status
|
|
204
204
|
|
|
205
|
-
0.19.x, speaking candor-spec 0.
|
|
205
|
+
0.19.x, speaking candor-spec 0.21: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
206
206
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
207
207
|
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
208
208
|
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.21)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/query.mjs
CHANGED
|
@@ -202,7 +202,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
202
202
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
203
203
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
204
204
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
205
|
-
const SPEC_VERSION = "0.
|
|
205
|
+
const SPEC_VERSION = "0.21";
|
|
206
206
|
|
|
207
207
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
208
208
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
package/scan-core.mjs
CHANGED
|
@@ -310,6 +310,11 @@ export const TELEMETRY_HOSTS = new Set([
|
|
|
310
310
|
"newrelic.com", "nr-data.net",
|
|
311
311
|
"honeycomb.io",
|
|
312
312
|
"logtail.com",
|
|
313
|
+
// ⟨0.20.1⟩ corpus-grown (a real-repo dogfood): more single-purpose analytics / session-replay / RUM
|
|
314
|
+
// providers — vendor-specific product domains only (no general-purpose host), so no under-gate risk.
|
|
315
|
+
"posthog.com", "plausible.io", "usefathom.com", "heapanalytics.com",
|
|
316
|
+
"fullstory.com", "hotjar.com", "logrocket.com",
|
|
317
|
+
"cloudflareinsights.com",
|
|
313
318
|
]);
|
|
314
319
|
// Normalize a `host[:port]` literal to a bare lowercase hostname (the MODEL_HOSTS stripping), for the
|
|
315
320
|
// destination-class membership tests.
|
package/scan.mjs
CHANGED
|
@@ -41,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
41
41
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
42
42
|
// Reused, never re-littered.
|
|
43
43
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
44
|
-
const SPEC_VERSION = "0.
|
|
44
|
+
const SPEC_VERSION = "0.21";
|
|
45
45
|
|
|
46
46
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
47
47
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -396,6 +396,28 @@ const checker = program.getTypeChecker();
|
|
|
396
396
|
const projectFiles = new Set(fileNames.map((f) => path.resolve(f)));
|
|
397
397
|
const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.resolve(f.fileName)));
|
|
398
398
|
|
|
399
|
+
// ⟨0.21⟩ COMPLETENESS MANIFEST (Gap 2): the TARGET's own source candor could NOT analyze — a .ts that
|
|
400
|
+
// FAILED TO PARSE (a syntax error). getSyntacticDiagnostics() reports lexer/parser failures; the source
|
|
401
|
+
// was only PARTIALLY seen, so its effects are absent because unseen, NOT because pure. We disclose it to a
|
|
402
|
+
// MACHINE (report + gate verdict) so a green gate over it is impossible (a false-pure channel — matches
|
|
403
|
+
// the java reference + rust's had_parse_failure). Restrict to PROJECT files (not node_modules/libs — the
|
|
404
|
+
// scan doesn't analyze those). LinkedHashMap-style: disclosure order = discovery order, deduped by path.
|
|
405
|
+
const unanalyzedUnits = [];
|
|
406
|
+
{
|
|
407
|
+
const seen = new Set();
|
|
408
|
+
for (const diag of program.getSyntacticDiagnostics()) {
|
|
409
|
+
const sf = diag.file;
|
|
410
|
+
if (!sf) continue;
|
|
411
|
+
const abs = path.resolve(sf.fileName);
|
|
412
|
+
if (!projectFiles.has(abs) || seen.has(abs)) continue;
|
|
413
|
+
seen.add(abs);
|
|
414
|
+
unanalyzedUnits.push({ path: path.relative(rootDir, abs), reason: "source failed to parse" });
|
|
415
|
+
}
|
|
416
|
+
// The loud human channel (rust does this too): a green report must not quietly hide the incompleteness.
|
|
417
|
+
if (unanalyzedUnits.length)
|
|
418
|
+
console.error(`candor-ts: ${unanalyzedUnits.length} source file(s) failed to parse — NOT analyzed (see the report's \`unanalyzed\`); a gate cannot be green over unanalyzed code`);
|
|
419
|
+
}
|
|
420
|
+
|
|
399
421
|
|
|
400
422
|
// The module a declaration came from: a project file → "<local>", @types/node → the builtin name,
|
|
401
423
|
// node_modules/<pkg> → the package name, the ES lib → "<es-lib>".
|
|
@@ -2611,6 +2633,26 @@ for (const [name, rec] of fns) {
|
|
|
2611
2633
|
else if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
|
|
2612
2634
|
functions.push(entry);
|
|
2613
2635
|
}
|
|
2636
|
+
// ⟨0.21⟩ An opaque, within-engine-stable fingerprint of a sorted qual set — FNV-1a 64-bit over the
|
|
2637
|
+
// newline-terminated UTF-8 quals, lowercase hex zero-padded to 16. BigInt (JS numbers can't hold 64 bits),
|
|
2638
|
+
// masked to 64 bits each step so it matches the java reference byte-for-byte (one algorithm the spec can
|
|
2639
|
+
// describe). Dependency-free + deterministic: it changes iff the set changes, so a same-engine re-scan of
|
|
2640
|
+
// unchanged input agrees. NOT cryptographic and NOT cross-engine comparable (quals differ `::` vs `.`).
|
|
2641
|
+
function fnv1aHex(sortedQuals) {
|
|
2642
|
+
const MASK = 0xFFFFFFFFFFFFFFFFn;
|
|
2643
|
+
const PRIME = 0x100000001b3n;
|
|
2644
|
+
let h = 0xcbf29ce484222325n; // FNV offset basis
|
|
2645
|
+
for (const q of sortedQuals) {
|
|
2646
|
+
for (const b of Buffer.from(q, "utf8")) {
|
|
2647
|
+
h = (h ^ BigInt(b)) & MASK;
|
|
2648
|
+
h = (h * PRIME) & MASK;
|
|
2649
|
+
}
|
|
2650
|
+
h = (h ^ 0x0an) & MASK; // '\n' terminator (matches the java reference)
|
|
2651
|
+
h = (h * PRIME) & MASK;
|
|
2652
|
+
}
|
|
2653
|
+
return h.toString(16).padStart(16, "0");
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2614
2656
|
// `package` names what this report COVERS — a consumer chaining it registers coverage even when
|
|
2615
2657
|
// `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
|
|
2616
2658
|
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
@@ -2628,6 +2670,18 @@ const uncoveredLedger = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] |
|
|
|
2628
2670
|
if (uncoveredLedger.length) {
|
|
2629
2671
|
envelope.coverage = { uncovered: uncoveredLedger.map(([name, calls]) => ({ name, calls })) };
|
|
2630
2672
|
}
|
|
2673
|
+
// ⟨0.21⟩ COMPLETENESS MANIFEST (Gap 1): the analyzed universe = every fn candor formed an effect judgment
|
|
2674
|
+
// for = the minted `fns` Map (effectful + pure leaves — NOT the effectful-only `functions` array), so a
|
|
2675
|
+
// bare-envelope consumer computes the pure count = analyzed.count − |functions| and tells analyzed-pure
|
|
2676
|
+
// from never-seen. `digest` = an opaque within-engine-stable FNV-1a-64 fingerprint over the SORTED analyzed
|
|
2677
|
+
// quals: it changes iff the set changes, so a same-input re-scan agrees (a re-scan check, NOT cryptographic
|
|
2678
|
+
// and NOT cross-engine comparable — qualifiers differ). ALWAYS present.
|
|
2679
|
+
const analyzedQuals = [...fns.keys()].sort();
|
|
2680
|
+
envelope.analyzed = { count: fns.size, digest: fnv1aHex(analyzedQuals) };
|
|
2681
|
+
// ⟨0.21⟩ COMPLETENESS MANIFEST (Gap 2): the target's own source candor could NOT analyze (unparsed .ts).
|
|
2682
|
+
// OMITTED when empty — a complete scan stays byte-identical to a pre-rung report — so a MACHINE reading
|
|
2683
|
+
// --json sees the incompleteness the stderr warning alone used to hide.
|
|
2684
|
+
if (unanalyzedUnits.length) envelope.unanalyzed = unanalyzedUnits.map((u) => ({ path: u.path, reason: u.reason }));
|
|
2631
2685
|
const cg = {};
|
|
2632
2686
|
for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
2633
2687
|
// Write ATOMICALLY (temp + rename): a concurrent reader — the MCP server or another `query` while
|
|
@@ -2904,7 +2958,21 @@ for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
|
|
|
2904
2958
|
// the SAME gateViolations that set the exit code (so it can't disagree). Written whenever the flag is set —
|
|
2905
2959
|
// ok:true,[] when no gate is configured. Must precede the exit(1) below.
|
|
2906
2960
|
if (gateJsonPath) {
|
|
2907
|
-
|
|
2961
|
+
// ⟨0.21⟩ COMPLETENESS MANIFEST (Gap 2): a gate over code candor could NOT fully analyze (unparsed .ts)
|
|
2962
|
+
// must NOT read green — those effects are invisible, so a `deny`/`pure` that "passes" over them is a
|
|
2963
|
+
// false-pure. `ok` requires BOTH no violation AND a complete analysis. `analyzed:{count}` (Gap 1) mirrors
|
|
2964
|
+
// the report envelope so a --gate-json consumer sees the scan's scope from the verdict alone.
|
|
2965
|
+
const incomplete = unanalyzedUnits.length > 0;
|
|
2966
|
+
const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0 && !incomplete,
|
|
2967
|
+
analyzed: { count: fns.size }, violations: gateViolations };
|
|
2968
|
+
// ⟨0.21⟩ (Gap 2) the machine-legible incompleteness: the units candor couldn't analyze, so a CI/agent
|
|
2969
|
+
// reading the JSON learns WHY the gate can't certify (the stderr warning alone used to hide this from a
|
|
2970
|
+
// machine). `incomplete:true` + the list; the run exits 2 (could-not-fully-evaluate) below. ok:false +
|
|
2971
|
+
// incomplete:true is honest — never a fabricated pass. OMITTED when complete (byte-compatible verdict).
|
|
2972
|
+
if (incomplete) {
|
|
2973
|
+
verdictObj.incomplete = true;
|
|
2974
|
+
verdictObj.unanalyzed = unanalyzedUnits.map((u) => ({ path: u.path, reason: u.reason }));
|
|
2975
|
+
}
|
|
2908
2976
|
// ⟨0.15 staged⟩ coverage ADVISORY (COVERAGE-DESIGN.md §3): when the κ ledger is non-empty, the
|
|
2909
2977
|
// verdict discloses what the gate could NOT see — VERDICT-PRESERVING (the ⟨0.9⟩ provable-purity
|
|
2910
2978
|
// auto-disclosure precedent exactly): ok/violations/exit are computed above and untouched here. A
|
|
@@ -2932,5 +3000,15 @@ if (gateViolations.length) {
|
|
|
2932
3000
|
console.error("→ candor-ts-query fix-gate names the remedy for each");
|
|
2933
3001
|
process.exit(1);
|
|
2934
3002
|
}
|
|
3003
|
+
// ⟨0.21⟩ COMPLETENESS MANIFEST (Gap 2): a CONFIGURED gate over code candor could NOT fully analyze (unparsed
|
|
3004
|
+
// .ts) cannot certify — exit 2 (could-not-evaluate), the fail-closed posture (matches candor-scan's
|
|
3005
|
+
// had_parse_failure + the java reference). A real violation (exit 1, above) dominates. A BARE scan with NO
|
|
3006
|
+
// gate does not exit 2 — it discloses `unanalyzed` in the report and stays exit 0. This is the cardinal-sin
|
|
3007
|
+
// fix: a broken .ts is no longer silently PARTIALLY analyzed and certified green.
|
|
3008
|
+
const gateConfigured = policyPath !== null || baselinePath !== null;
|
|
3009
|
+
if (gateConfigured && unanalyzedUnits.length) {
|
|
3010
|
+
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`);
|
|
3011
|
+
process.exit(2);
|
|
3012
|
+
}
|
|
2935
3013
|
if (policyPath !== null) console.error("candor-ts: policy ✓");
|
|
2936
3014
|
if (baselinePath !== null && fs.existsSync(baselinePath)) console.error("candor-ts: baseline ✓"); // absent = inactive (noted above)
|