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 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.20)."*
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.20" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
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.20: the analysis core, the gate (`--policy` / `--gate-json` /
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.20.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.20)",
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.20";
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.20";
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
- const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0, violations: gateViolations };
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)