candor-ts 0.38.0 → 0.38.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/scan.mjs +137 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.38.0",
3
+ "version": "0.38.3",
4
4
  "mcpName": "io.github.tombaldwin/candor",
5
5
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.38)",
6
6
  "type": "module",
package/scan.mjs CHANGED
@@ -2781,6 +2781,127 @@ function importPkgOfHead(expr) {
2781
2781
  return null;
2782
2782
  }
2783
2783
 
2784
+ // ⟨R439⟩ SUBPATH-IMPORT CONDITION MAPS — `"imports": { "#impl": { "node": "./src/pure.js",
2785
+ // "browser": "./src/fsarm.js" } }`. TypeScript resolves a `#impl` import to exactly ONE arm, whichever
2786
+ // the tsconfig's conditions select, so the other arm's body is never reached by the call machinery and a
2787
+ // caller whose only effects arrive through it reads SILENT-PURE — absent from `functions[]`, which under
2788
+ // ⟨0.21⟩ is an affirmative purity claim, the cardinal sin.
2789
+ //
2790
+ // MEASURED 2026-09-14 on two trees differing ONLY in which condition NAME carries the Fs file:
2791
+ // node→pure, browser→fsarm caller ABSENT `deny Fs` exit 0
2792
+ // node→fsarm, browser→pure caller ["Fs"] `deny Fs` exit 1
2793
+ // The winning condition is a property of the CONSUMER's build, not of the code under scan, so neither
2794
+ // answer is the whole truth and the tree that reports nothing is the dangerous one.
2795
+ //
2796
+ // WHAT THIS DOES NOT DO: it does not union the arms' effects. Which arm a call "has" is a cross-engine
2797
+ // question — SPEC ⟨0.38⟩ settles it for a conditional BINDING, and a condition map is the packaging-level
2798
+ // analogue, not yet specified. Unioning here would charge this caller for a body that may never run on
2799
+ // the consumer's platform, i.e. fabrication, and would do it in ONE engine ahead of the spec. What can be
2800
+ // said without inventing anything is that the target is AMBIGUOUS: disclose `Unknown`, which withdraws
2801
+ // the purity claim and lets a gate see there is something here, and name the specifier so the reason is
2802
+ // actionable. Both trees above then report the SAME thing, which is the invariant the defect violates.
2803
+ //
2804
+ // THE BOUNDARY IS DELIBERATE AND IS A DENYLIST NARROWING (see `candor-denylist-over-allowlist`): we flag
2805
+ // only when ≥2 arms resolve to DISTINCT files the scan actually ANALYSES. Arms that leave the project are
2806
+ // external calls, already carried by the κ ledger / `invisible` machinery — adding Unknown there would
2807
+ // double-report a covered case. The common dual `{"import":"./dist/x.mjs","require":"./dist/x.cjs"}` is
2808
+ // build OUTPUT, outside `include`, so it resolves to no project file and stays quiet: that pair is one
2809
+ // source compiled twice, and flagging it would put `Unknown` on most dual-package repos for nothing.
2810
+ // The direction this fails in, when it is wrong, is SILENCE on arms we cannot see — which is the
2811
+ // pre-existing external posture, not a new hole.
2812
+ const _condMapCache = new Map();
2813
+ // The nearest package.json OBJECT at or above a file (not just its name, which `nearestPackageName`
2814
+ // already caches) — needed for the `imports` field. Memoized per directory including the misses.
2815
+ function nearestPackageJson(file) {
2816
+ let dir = path.dirname(file);
2817
+ const seen = [];
2818
+ while (dir && dir !== path.dirname(dir)) {
2819
+ if (_condMapCache.has(dir)) { const v = _condMapCache.get(dir); for (const d of seen) _condMapCache.set(d, v); return v; }
2820
+ seen.push(dir);
2821
+ const pj = path.join(dir, "package.json");
2822
+ try {
2823
+ if (fs.existsSync(pj)) {
2824
+ let v = null;
2825
+ try { v = JSON.parse(fs.readFileSync(pj, "utf8")); } catch { v = null; } // malformed: treat as absent
2826
+ const rec = v ? { json: v, dir } : null;
2827
+ for (const d of seen) _condMapCache.set(d, rec);
2828
+ return rec;
2829
+ }
2830
+ } catch { /* unreadable — keep climbing */ }
2831
+ dir = path.dirname(dir);
2832
+ }
2833
+ for (const d of seen) _condMapCache.set(d, null);
2834
+ return null;
2835
+ }
2836
+
2837
+ // Every LEAF path string in a condition value, which nests: `{"node":{"import":"./a.js"}}`. The `types`
2838
+ // condition is skipped on purpose — a `.d.ts` arm is the declaration FOR an implementation arm, not an
2839
+ // alternative implementation, so counting it would make every well-typed single-impl subpath look
2840
+ // ambiguous. `null` is a legal arm value meaning "blocked here" and carries no file.
2841
+ function _condLeaves(v, out) {
2842
+ if (typeof v === "string") { out.add(v); return out; }
2843
+ if (v && typeof v === "object" && !Array.isArray(v)) {
2844
+ for (const [k, sub] of Object.entries(v)) { if (k !== "types") _condLeaves(sub, out); }
2845
+ }
2846
+ return out;
2847
+ }
2848
+
2849
+ // `./src/pure.js` as written in package.json names the EMITTED file; the scan sees the TypeScript source.
2850
+ // Map it back the way tsc does, and require the result to be a file this scan actually analyses —
2851
+ // `projectFiles` is the authority on that, so an arm pointing outside the project yields nothing.
2852
+ function _armProjectFile(dir, rel) {
2853
+ if (typeof rel !== "string" || !rel.startsWith(".")) return null;
2854
+ const abs = path.resolve(dir, rel);
2855
+ const cands = [abs];
2856
+ const m = abs.match(/^(.*)\.(js|mjs|cjs|jsx)$/);
2857
+ if (m) cands.push(m[1] + ".ts", m[1] + ".tsx", m[1] + ".mts", m[1] + ".cts", m[1] + ".d.ts");
2858
+ for (const c of cands) if (projectFiles.has(path.resolve(c))) return path.resolve(c);
2859
+ return null;
2860
+ }
2861
+
2862
+ // The specifier a call's HEAD identifier binds to, when that specifier goes through a multi-arm condition
2863
+ // map over files we analyse. Returns the specifier text for the disclosure reason, else null. Mirrors
2864
+ // `importPkgOfHead`'s declaration walk exactly — same three import forms, same head unwrap.
2865
+ function conditionMapAmbiguity(expr) {
2866
+ let head = expr;
2867
+ while (head && ts.isPropertyAccessExpression(head)) head = head.expression;
2868
+ if (!head || !ts.isIdentifier(head)) return null;
2869
+ const sym = checker.getSymbolAtLocation(head);
2870
+ for (const d of sym?.declarations ?? []) {
2871
+ let spec = null;
2872
+ if (ts.isNamespaceImport(d)) spec = d.parent?.parent?.moduleSpecifier;
2873
+ else if (ts.isImportClause(d)) spec = d.parent?.moduleSpecifier;
2874
+ else if (ts.isImportSpecifier(d)) spec = d.parent?.parent?.parent?.moduleSpecifier;
2875
+ if (!spec || !ts.isStringLiteralLike(spec)) continue;
2876
+ const text = spec.text;
2877
+ if (!text.startsWith("#")) continue; // subpath imports only — see the boundary note above
2878
+ const pkg = nearestPackageJson(path.resolve(spec.getSourceFile().fileName));
2879
+ const imports = pkg?.json?.imports;
2880
+ if (!imports || typeof imports !== "object") continue;
2881
+ // Exact key first, then the `#foo/*` pattern form, longest prefix winning as Node resolves it.
2882
+ let val = Object.prototype.hasOwnProperty.call(imports, text) ? imports[text] : undefined;
2883
+ if (val === undefined) {
2884
+ let best = null;
2885
+ for (const k of Object.keys(imports)) {
2886
+ const star = k.indexOf("*");
2887
+ if (star < 0) continue;
2888
+ const pre = k.slice(0, star), post = k.slice(star + 1);
2889
+ if (text.startsWith(pre) && text.endsWith(post) && text.length >= pre.length + post.length
2890
+ && (!best || pre.length > best.pre.length)) best = { pre, k };
2891
+ }
2892
+ if (best) val = imports[best.k];
2893
+ }
2894
+ if (!val || typeof val !== "object") continue; // a plain string subpath has ONE arm: nothing ambiguous
2895
+ const files = new Set();
2896
+ for (const leaf of _condLeaves(val, new Set())) {
2897
+ const f = _armProjectFile(pkg.dir, leaf);
2898
+ if (f) files.add(f);
2899
+ }
2900
+ if (files.size >= 2) return text;
2901
+ }
2902
+ return null;
2903
+ }
2904
+
2784
2905
  // SPEC §5.1 — the effect manifest. An uncurated package MAY declare its effect surface in its
2785
2906
  // package.json (`"candorEffects": ["Net"]`), read as the declared-not-verified tier: it kills the
2786
2907
  // silent pure/blind-spot the package would otherwise carry, exactly like a cap type (and unlike
@@ -6507,6 +6628,22 @@ function visitCalls(node) {
6507
6628
  const owner = enclosing(node);
6508
6629
  if (owner) {
6509
6630
  const rec = fns.get(owner);
6631
+ // ⟨R439⟩ BEFORE the dispatch chain, not inside one of its branches. The checker has already
6632
+ // collapsed a condition-map import to a single arm by the time any branch below runs, and every
6633
+ // branch would have to repeat the test; R429 was the same mistake one engine over (the swift union
6634
+ // sat after the dispatch chain and missed the arm it had picked). Additive — this only ever ADDS
6635
+ // `Unknown`, so no effect the resolution finds is displaced and no firing gate can go green.
6636
+ if (rec && node.expression) {
6637
+ const cmSpec = conditionMapAmbiguity(node.expression);
6638
+ if (cmSpec) {
6639
+ rec.direct.add("Unknown"); rec.why.add(`ambiguous:condition-map ${cmSpec}`);
6640
+ // REACH instrument for the corpus A/B (`bin/corpus-ab.py --mark`). "CHANGED 0" and "the code
6641
+ // never ran" print identically; this is what tells them apart. Env-gated, stderr, never in the
6642
+ // report — see `candor-locator-spelling-vein`, where 17,944 units read "inert" and the probe
6643
+ // showed the edited line was never reached.
6644
+ if (process.env.CANDOR_R439_MARK) process.stderr.write(`CANDOR_R439_HIT ${cmSpec}\n`);
6645
+ }
6646
+ }
6510
6647
  const sig = checker.getResolvedSignature(node);
6511
6648
  let decl = sig && sig.declaration;
6512
6649
  // ⟨R103⟩ A WRITABLE SLOT IS AN INCOMPLETE CANDIDATE SET — see `openCallSlot`, and see the class-