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.
- package/package.json +1 -1
- package/scan.mjs +137 -0
package/package.json
CHANGED
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-
|