candor-ts 0.38.3 → 0.39.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/mcp.mjs +12 -0
- package/package.json +2 -2
- package/policy.mjs +47 -0
- package/query-core.mjs +34 -2
- package/query.mjs +53 -3
- package/scan.mjs +510 -50
package/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@ the TypeScript-specific production + query surface.
|
|
|
23
23
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
24
24
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
25
25
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
26
|
-
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.
|
|
26
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.39)."*
|
|
27
27
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
28
28
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
29
29
|
>
|
package/README.md
CHANGED
|
@@ -201,7 +201,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
201
201
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
202
202
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
203
203
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
204
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
204
|
+
| `{ candor: { version, toolchain, spec: "0.39" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
205
205
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
206
206
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
207
207
|
|
|
@@ -219,7 +219,7 @@ read the Rust source".
|
|
|
219
219
|
|
|
220
220
|
## Status
|
|
221
221
|
|
|
222
|
-
0.30.0, speaking candor-spec 0.
|
|
222
|
+
0.30.0, speaking candor-spec 0.39: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
223
223
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
224
224
|
`--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
|
|
225
225
|
report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
|
package/mcp.mjs
CHANGED
|
@@ -344,6 +344,9 @@ const TOOLS = {
|
|
|
344
344
|
// available to it. Fixed key sets, so the caveat spreads at the root and every pinned tool shape is
|
|
345
345
|
// unchanged on a complete report (`completenessFields` → `{}`). These three certify nothing, so they
|
|
346
346
|
// are on the descriptive side of the ⟨0.32⟩ boundary stated on `nestWithCaveat` below.
|
|
347
|
+
// SOUNDNESS R507 — see the dispatcher's `oneSubject` guard. `impact` answers about ONE function, so
|
|
348
|
+
// a selector naming several is REFUSED rather than silently resolved to an arbitrary one.
|
|
349
|
+
oneSubject: true,
|
|
347
350
|
run: (a, p) => { const fns = loadReportLoud(p); return withCompleteness(p, capImpact(Q.impact(fns, graphOrReportEdges(p, fns), a.fn))); },
|
|
348
351
|
},
|
|
349
352
|
candor_where: {
|
|
@@ -361,6 +364,7 @@ const TOOLS = {
|
|
|
361
364
|
schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, ...reportArg }, required: ["fn", "effect"] },
|
|
362
365
|
// ⟨0.32⟩ see `candor_impact` above: `path: []` to an agent is *this function does not reach that
|
|
363
366
|
// effect*, and a hop through an unread unit breaks the chain.
|
|
367
|
+
oneSubject: true, // SOUNDNESS R507 — see `candor_impact` and the dispatcher's guard
|
|
364
368
|
run: (a, p) => { const fns = loadReportLoud(p); return withCompleteness(p, Q.path(fns, graphOrReportEdges(p, fns), a.fn, a.effect)); },
|
|
365
369
|
},
|
|
366
370
|
candor_callers: {
|
|
@@ -914,6 +918,14 @@ function handle(msg) {
|
|
|
914
918
|
const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...loadReportLoud(prefix).map((e) => e.fn)])];
|
|
915
919
|
if (Q.matches(names, args.fn).length === 0)
|
|
916
920
|
return result(id, { content: [{ type: "text", text: `candor: no function matching \`${clip(args.fn)}\` in this report` }], isError: true });
|
|
921
|
+
// SOUNDNESS R507 — the OTHER half of the same resolution, and scoped to the tools that answer
|
|
922
|
+
// about ONE subject. `candor_show`/`candor_callers`/`candor_whatif` answer over the WHOLE
|
|
923
|
+
// best-tier set, so several matches WIDEN their answer rather than substituting a subject;
|
|
924
|
+
// `candor_path`/`candor_impact` take one and print a confident verdict about it. Refusing here
|
|
925
|
+
// rather than inside the verbs keeps the decision beside the zero-match guard it is the twin of.
|
|
926
|
+
const amb = t.oneSubject ? Q.ambiguousSelector(names, args.fn) : null;
|
|
927
|
+
if (amb)
|
|
928
|
+
return result(id, { content: [{ type: "text", text: `candor: \`${clip(args.fn)}\` names ${amb.length} functions in this report — refusing to answer about one of them. Re-run with the full name: ${amb.map((n) => clip(n)).join(", ")}` }], isError: true });
|
|
917
929
|
}
|
|
918
930
|
const out = t.run(args, prefix);
|
|
919
931
|
// Minified, not pretty-printed: the consumer is an AGENT (it parses the JSON), so the indentation
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"mcpName": "io.github.tombaldwin/candor",
|
|
5
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
5
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.39)",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@types/node": "^25.9.2",
|
package/policy.mjs
CHANGED
|
@@ -670,6 +670,20 @@ export function refusalVerdict(spec, reason, unevaluated = null) {
|
|
|
670
670
|
* ⟨0.24⟩ §3.3.1 requires every ordering to be locale-INDEPENDENT. `Array.prototype.sort` has been
|
|
671
671
|
* stable since ES2019, so rows with an equal key keep their arrival order.
|
|
672
672
|
*/
|
|
673
|
+
// ⟨0.23⟩/⟨0.39⟩ Say out loud, WITHOUT moving the verdict, which synthetic `interfaceUnion` entries a
|
|
674
|
+
// rule matched. One printer, called from the two VERDICT routes (`scan --policy` and `gate --report`),
|
|
675
|
+
// so §3.1's byte-equality between them covers the note as well as the rows.
|
|
676
|
+
export function noteSyntheticHits(violations, err = console.error) {
|
|
677
|
+
const hits = violations?.syntheticHits ?? [];
|
|
678
|
+
if (!hits.length) return;
|
|
679
|
+
err(`candor-ts: note — ${hits.length} ⟨0.23⟩ interfaceUnion entr`
|
|
680
|
+
+ (hits.length === 1 ? "y matches a policy rule and is NOT gated as a function"
|
|
681
|
+
: "ies match a policy rule and are NOT gated as functions")
|
|
682
|
+
+ ": the key names a BODILESS declaration, and the effects under it are the CHA union over "
|
|
683
|
+
+ "implementors, each of which IS gated under its own entry.");
|
|
684
|
+
for (const h of hits) err(` \`${h.fn}\` { ${h.effects.join(", ")} } would have matched \`${h.rule}\``);
|
|
685
|
+
}
|
|
686
|
+
|
|
673
687
|
export function sortViolations(violations) {
|
|
674
688
|
const c = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
|
|
675
689
|
return violations.sort((a, b) =>
|
|
@@ -1066,6 +1080,11 @@ export function classFilterExcludes(r, entry, eff, reasonAcc, netClassOf, units
|
|
|
1066
1080
|
|
|
1067
1081
|
export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set(), netClasses = null, withhold = null, units = null, hashByName = null) {
|
|
1068
1082
|
const out = [];
|
|
1083
|
+
// ⟨0.39⟩ the synthetic `interfaceUnion` entries a rule MATCHED — carried on the returned array (a
|
|
1084
|
+
// non-index property, so `length`, iteration and `JSON.stringify` are all untouched) rather than
|
|
1085
|
+
// printed from here, because this function is also called for sub-evaluations that must stay silent.
|
|
1086
|
+
const syntheticHits = [];
|
|
1087
|
+
out.syntheticHits = syntheticHits;
|
|
1069
1088
|
// `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
|
|
1070
1089
|
const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
|
|
1071
1090
|
// §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation; ⟨0.20⟩ `netClass`
|
|
@@ -1130,6 +1149,34 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
1130
1149
|
// The gate's TEST and the class list it REPORTS both read it, so the two can't disagree about one function.
|
|
1131
1150
|
const netClassOf = netClassResolver(incomplete, partners, netClasses, units);
|
|
1132
1151
|
for (const f of functions) {
|
|
1152
|
+
// ⟨0.23⟩/⟨0.39⟩ A SYNTHETIC `interfaceUnion` ENTRY IS NOT A UNIT, so it cannot PERFORM anything and
|
|
1153
|
+
// must not become a violation ROW. Its `fn` names a BODILESS declaration (`Store.save` on an
|
|
1154
|
+
// interface); the effects under it are the CHA union over implementors, published under that hash so
|
|
1155
|
+
// a CHAINED CONSUMER's dispatch resolves across the scan boundary. Every effect it carries is
|
|
1156
|
+
// already on an implementor's OWN entry in the same report — which IS a unit, and IS gated one loop
|
|
1157
|
+
// iteration away.
|
|
1158
|
+
//
|
|
1159
|
+
// MEASURED HERE, not inherited from the reference engine's argument: over `interface Entry { state()
|
|
1160
|
+
// }` with a single STRUCTURAL implementor, `deny Unknown` went exit 0 -> 1 the moment ⟨0.39⟩ un-gated
|
|
1161
|
+
// the union — while the package's own `stringify` resolved the dispatch PRECISELY (a real `calls`
|
|
1162
|
+
// edge to the structural member) and carried no Unknown at all. The union's Unknown is an artifact of
|
|
1163
|
+
// what the WIRE can name (a structural implementor has no class name to key on), never a fact about
|
|
1164
|
+
// a body this scan read, so gating on it is a fabricated violation in the producer's own verdict.
|
|
1165
|
+
// candor-java measured the identical flip (`Policy.java:778`) and takes the identical course.
|
|
1166
|
+
//
|
|
1167
|
+
// DISCLOSED, NOT DROPPED: the hits are recorded on the returned array and printed by the verdict
|
|
1168
|
+
// routes, because the one thing a reader of a chained dependency's report wants to know here is that
|
|
1169
|
+
// the dependency publishes a dispatch surface reaching a denied effect.
|
|
1170
|
+
if (f?.interfaceUnion === true) {
|
|
1171
|
+
for (const r of pol.deny) {
|
|
1172
|
+
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
1173
|
+
const inf = Array.isArray(f.inferred) ? f.inferred : [];
|
|
1174
|
+
const hits = r.effects.length === 0 ? inf.filter((e) => e !== "Unknown")
|
|
1175
|
+
: inf.filter((e) => r.effects.includes(e));
|
|
1176
|
+
if (hits.length) syntheticHits.push({ fn: f.fn, effects: hits.slice().sort(), rule: r.raw });
|
|
1177
|
+
}
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1133
1180
|
// ⟨0.32⟩ the KEY identifies the unit; `f.fn` is the NAME, and the name is what a policy SCOPE matches
|
|
1134
1181
|
// and what the verdict row prints (§3.3.1 byte-equality with `scan --policy` rests on it).
|
|
1135
1182
|
const uk = unitKey(units, f);
|
package/query-core.mjs
CHANGED
|
@@ -196,7 +196,7 @@ function normFn(e) {
|
|
|
196
196
|
// non-string in `unknownWhy` reaches `reasonClass()` and one in `hosts`/`netClass` reaches the ⟨0.20⟩
|
|
197
197
|
// destination-class matcher.
|
|
198
198
|
for (const k of ["unknownWhy", "netClass", "hosts", "cmds", "paths", "tables",
|
|
199
|
-
"declared", "undeclared", "overdeclared"]) if (k in e) o[k] = arr(e[k]);
|
|
199
|
+
"declared", "undeclared", "overdeclared", "dispatchesOn"]) if (k in e) o[k] = arr(e[k]);
|
|
200
200
|
return o;
|
|
201
201
|
}
|
|
202
202
|
|
|
@@ -215,8 +215,12 @@ function normFn(e) {
|
|
|
215
215
|
// on, the ⟨0.19⟩/⟨0.20⟩ class fields it scopes with, and the `calls` edges the reason-class fixpoint runs
|
|
216
216
|
// over. `loc`/`hash`/`unitKind`/`invisible`/`unresolved` are deliberately NOT here: no verdict reads them,
|
|
217
217
|
// so refusing on them would be a spurious refusal on a report whose gate-relevant content is intact.
|
|
218
|
+
// ⟨0.39⟩ `dispatchesOn` is a VERDICT key, not a diagnostic one: a chained consumer unions the effects
|
|
219
|
+
// of every implementor published under each key it names, so a present-but-unparseable one coerced to
|
|
220
|
+
// `[]` silently drops that union — an effect the consumer really reaches, gone, with no hedge. Exactly
|
|
221
|
+
// the fail-OPEN direction this list exists to refuse.
|
|
218
222
|
const VERDICT_STR_ARRAY_KEYS = ["inferred", "direct", "calls", "unknownWhy", "netClass", "hosts",
|
|
219
|
-
"declared", "undeclared", "overdeclared"];
|
|
223
|
+
"declared", "undeclared", "overdeclared", "dispatchesOn"];
|
|
220
224
|
const isStrArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
221
225
|
export function entryCorruptKeys(e) {
|
|
222
226
|
if (!e || typeof e !== "object" || Array.isArray(e)) return ["<entry is not an object>"];
|
|
@@ -1382,6 +1386,29 @@ export function matches(names, q) {
|
|
|
1382
1386
|
return best === 0 ? [] : names.filter((n) => matchTier(n, q) >= best);
|
|
1383
1387
|
}
|
|
1384
1388
|
|
|
1389
|
+
/** SOUNDNESS R507/R497 — THE SELECTOR THAT NAMES SEVERAL FUNCTIONS, for the verbs that answer about ONE.
|
|
1390
|
+
*
|
|
1391
|
+
* Returns the distinct best-tier candidates when MORE THAN ONE survives, else null. The asymmetry it
|
|
1392
|
+
* closes is the whole defect: `path` and `impact` ALREADY refuse at exit 2 when ZERO functions match —
|
|
1393
|
+
* only MANY was answered silently, by taking `targets[0]` and printing a confident verdict about a
|
|
1394
|
+
* function the caller did not ask about. Measured in candor-java on `auth-2.25.60`: `path
|
|
1395
|
+
* resolveCredentials Exec` had FOURTEEN dot-anchored candidates and answered a confident negative about
|
|
1396
|
+
* `AnonymousCredentialsProvider` while three of the fourteen perform `Exec`. A negative is a claim in
|
|
1397
|
+
* this family; a negative about a substituted subject is a fabricated one.
|
|
1398
|
+
*
|
|
1399
|
+
* Anchoring is NOT this function's job and is already done: `matchTier` requires a `[.$#]` boundary
|
|
1400
|
+
* before a suffix match (tier 2), so `ProfileCredentialsProvider` never matches inside
|
|
1401
|
+
* `InstanceProfileCredentialsProvider` — that half of R497 is what candor-ts already had, and it is why
|
|
1402
|
+
* an EXACT match (tier 3) still resolves alone and is never counted as ambiguity.
|
|
1403
|
+
*
|
|
1404
|
+
* DISTINCT names, because the verbs resolve over the union of the callgraph's keys and the report's
|
|
1405
|
+
* `fn`s and one function is routinely in both — a duplicate is one subject, not two.
|
|
1406
|
+
*/
|
|
1407
|
+
export function ambiguousSelector(names, q) {
|
|
1408
|
+
const m = [...new Set(matches(names, q))].sort(byCodePoint);
|
|
1409
|
+
return m.length > 1 ? m : null;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1385
1412
|
// Exported for consumers that answer MANY caller-count questions over one loaded graph (the LSP
|
|
1386
1413
|
// codeLens): building the inversion once per request instead of once per `callers()` call.
|
|
1387
1414
|
export function reverseGraph(cg) {
|
|
@@ -1599,6 +1626,11 @@ export function callersFrontier(cg, fns, hierarchy, q) {
|
|
|
1599
1626
|
const possible = [];
|
|
1600
1627
|
for (const f of fns) {
|
|
1601
1628
|
if (confirmed.has(f.fn)) continue;
|
|
1629
|
+
// ⟨0.39⟩: a synthetic `interfaceUnion` entry is the union over an abstraction member's implementors,
|
|
1630
|
+
// not a function with a body — it cannot CALL anything, so it is not a possible caller. Un-gating
|
|
1631
|
+
// ⟨0.23⟩ made these default rather than opt-in, and this arm then named the bodiless DECLARATION
|
|
1632
|
+
// beside the dispatcher. Found by the four-way frontier differential on (producer=java, consumer=ts).
|
|
1633
|
+
if (f.interfaceUnion) continue;
|
|
1602
1634
|
const hits = new Set();
|
|
1603
1635
|
for (const w of f.unknownWhy ?? []) {
|
|
1604
1636
|
if (!w.startsWith("dispatch:")) continue;
|
package/query.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, d
|
|
|
27
27
|
evaluatePolicy, reportNetClasses, resolveReasonClasses, discoverConfigPath,
|
|
28
28
|
policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules,
|
|
29
29
|
fatalPolicyErrors, refusalVerdict, sortViolations,
|
|
30
|
-
unanswerableScoped, wholePolicyUnanswerable, reportUnits } from "./policy.mjs";
|
|
30
|
+
unanswerableScoped, wholePolicyUnanswerable, reportUnits, noteSyntheticHits } from "./policy.mjs";
|
|
31
31
|
import { hasReport, refusalMarkerFor, refusalSentence } from "./query-core.mjs";
|
|
32
32
|
import { printAgents, writeStdoutSync, writeSinkAtomic, isCandorConfigSink } from "./contract.mjs";
|
|
33
33
|
import { bestFinds } from "./surface.mjs";
|
|
@@ -42,7 +42,7 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
42
42
|
containment as coreContainment, diff as coreDiff,
|
|
43
43
|
where as coreWhere, map as coreMap, whatif as coreWhatif,
|
|
44
44
|
fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
|
|
45
|
-
matches as coreMatches, gainsCoverage, gainsCompletenessFields, parseClassFilter, ClassFilterError,
|
|
45
|
+
matches as coreMatches, ambiguousSelector, gainsCoverage, gainsCompletenessFields, parseClassFilter, ClassFilterError,
|
|
46
46
|
loadReport, loadCallgraph, reportCallsGraph, loadGateReport, gateReportInputFiles,
|
|
47
47
|
reportVersion, reportPackage,
|
|
48
48
|
advisoryAnswer,
|
|
@@ -679,6 +679,19 @@ function renderPathHuman(fns, cg, fnQ, eff, hedge = false) {
|
|
|
679
679
|
console.error(`candor-query path: no function matching '${fnQ}'`);
|
|
680
680
|
process.exit(2);
|
|
681
681
|
}
|
|
682
|
+
// SOUNDNESS R507 — the human renderer's OWN resolution, refused on the same condition. The gate in the
|
|
683
|
+
// `path` case above resolves over the UNION of the callgraph keys and the report's `fn`s; this one
|
|
684
|
+
// resolves over the report alone, and the two name sets can put their best tier in different places
|
|
685
|
+
// (an exact callgraph key makes the union unambiguous while the report-only set still holds two
|
|
686
|
+
// suffix matches). Same helper, so the two cannot answer the question differently.
|
|
687
|
+
{
|
|
688
|
+
const ambH = ambiguousSelector(fns.map((e) => e.fn), fnQ);
|
|
689
|
+
if (ambH) {
|
|
690
|
+
console.error(`candor-ts-query path: '${fnQ}' names ${ambH.length} functions — refusing to answer `
|
|
691
|
+
+ `about one of them. Re-run with the full name:\n ${ambH.join("\n ")}`);
|
|
692
|
+
process.exit(2);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
682
695
|
const startEntry = fns.find((e) => e.fn === start);
|
|
683
696
|
const inferred = startEntry?.inferred ?? [];
|
|
684
697
|
if (!inferred.includes(eff)) {
|
|
@@ -717,7 +730,7 @@ function renderPathHuman(fns, cg, fnQ, eff, hedge = false) {
|
|
|
717
730
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
718
731
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
719
732
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
720
|
-
const SPEC_VERSION = "0.
|
|
733
|
+
const SPEC_VERSION = "0.39";
|
|
721
734
|
|
|
722
735
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
723
736
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -1725,6 +1738,20 @@ switch (cmd) {
|
|
|
1725
1738
|
if (coreMatches(knownFnNames(impCg, impFns), q).length === 0) {
|
|
1726
1739
|
console.error(`candor-ts-query impact: no function matching '${q}'`); process.exit(2);
|
|
1727
1740
|
}
|
|
1741
|
+
// SOUNDNESS R507, the same refusal on the verb that answers the OTHER direction — and here the
|
|
1742
|
+
// substitution was HALF-hidden, which is worse than `path`'s. `coreImpact` walks the reverse graph
|
|
1743
|
+
// from EVERY target (so `affectedCount` is a union over all of them) and then prints
|
|
1744
|
+
// `fn: targets[0]`: a blast radius computed for several functions, attributed to one. A reader
|
|
1745
|
+
// checking whether `save` is safe to change gets a count that may belong to a different `save`.
|
|
1746
|
+
// See the `path` gate above for the asymmetry and for why one resolution decides both outcomes.
|
|
1747
|
+
{
|
|
1748
|
+
const amb = ambiguousSelector(knownFnNames(impCg, impFns), q);
|
|
1749
|
+
if (amb) {
|
|
1750
|
+
console.error(`candor-ts-query impact: '${q}' names ${amb.length} functions — refusing to answer `
|
|
1751
|
+
+ `about one of them. Re-run with the full name:\n ${amb.join("\n ")}`);
|
|
1752
|
+
process.exit(2);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1728
1755
|
// ⟨0.32⟩ `putAnswer`, not `put` — this verb had NO completeness reader at all (see `CALLERS_SOWHAT`
|
|
1729
1756
|
// above for the measurement and the boundary). `affectedCount: 0` is the strongest claim in this
|
|
1730
1757
|
// verb's vocabulary, and over a report whose own `excluded` names a class nothing opened it rests on
|
|
@@ -1995,6 +2022,27 @@ switch (cmd) {
|
|
|
1995
2022
|
if (coreMatches(knownFnNames(cg, fns), fn).length === 0) {
|
|
1996
2023
|
console.error(`candor-ts-query path: no function matching '${fn}'`); process.exit(2);
|
|
1997
2024
|
}
|
|
2025
|
+
// SOUNDNESS R507 — …AND THE OTHER HALF OF THE SAME RESOLUTION: MANY. Note the asymmetry that IS the
|
|
2026
|
+
// defect: this verb has refused at exit 2 for ZERO matches for a long time (the gate directly above),
|
|
2027
|
+
// while SEVERAL matches were answered silently — `targets[0]`, an arbitrary one, printed as a
|
|
2028
|
+
// confident verdict about a function the caller never named. Measured in candor-java on
|
|
2029
|
+
// `auth-2.25.60`: `path resolveCredentials Exec` had FOURTEEN anchored candidates and returned a
|
|
2030
|
+
// confident negative about `AnonymousCredentialsProvider` while three of the fourteen perform `Exec`.
|
|
2031
|
+
// A negative is a claim in this family, so a negative about a substituted subject is a fabricated one
|
|
2032
|
+
// — strictly worse than unhelpful.
|
|
2033
|
+
//
|
|
2034
|
+
// ONE RESOLUTION, TWO OUTCOMES: the same `knownFnNames` set and the same `matches` ladder decide
|
|
2035
|
+
// zero and many, so the two cannot drift apart the way a second, parallel check would. Anchoring is
|
|
2036
|
+
// already in `matchTier` (this engine has always had R497's first half), so an EXACT match resolves
|
|
2037
|
+
// alone and is never refused.
|
|
2038
|
+
{
|
|
2039
|
+
const amb = ambiguousSelector(knownFnNames(cg, fns), fn);
|
|
2040
|
+
if (amb) {
|
|
2041
|
+
console.error(`candor-ts-query path: '${fn}' names ${amb.length} functions — refusing to answer `
|
|
2042
|
+
+ `about one of them. Re-run with the full name:\n ${amb.join("\n ")}`);
|
|
2043
|
+
process.exit(2);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
1998
2046
|
// ⟨0.32⟩ THE COMPLETENESS READER THIS VERB DID NOT HAVE (see `PATH_SOWHAT` above). `path: []` is the
|
|
1999
2047
|
// determined negative here — *this function does not reach that effect* — and a hop through an unread
|
|
2000
2048
|
// unit BREAKS the chain, so a hedging report can produce that answer for a function that really does
|
|
@@ -2500,6 +2548,8 @@ switch (cmd) {
|
|
|
2500
2548
|
// fire AS-EFF-008 "no visible literal" on every report entry whose surface the wire does not carry.
|
|
2501
2549
|
const gviol = evaluatePolicy(gwp.answerable,
|
|
2502
2550
|
g.functions, {}, new Map(), new Set(), gnet, gwithhold, gunits);
|
|
2551
|
+
// ⟨0.39⟩ see scan.mjs's twin — one printer, both verdict routes.
|
|
2552
|
+
noteSyntheticHits(gviol);
|
|
2503
2553
|
// Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
|
|
2504
2554
|
// document, so `candor-ts-query gate … --json | jq` sees pure JSON.
|
|
2505
2555
|
const gsay = (json || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
package/scan.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { execFileSync } from "node:child_process";
|
|
|
30
30
|
import os from "node:os";
|
|
31
31
|
import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText,
|
|
32
32
|
reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules, fatalPolicyErrors, refusalVerdict, sortViolations,
|
|
33
|
-
netClassResolver, resolveReasonClasses } from "./policy.mjs";
|
|
33
|
+
netClassResolver, resolveReasonClasses, noteSyntheticHits } from "./policy.mjs";
|
|
34
34
|
import { unverifiedHoleRule, ruleUpgrade, canonicalDenySet, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
|
|
35
35
|
import { printAgents, writeStdoutSync, writeSinkAtomic, resolveSinkArtifact, isCandorConfigSink } from "./contract.mjs";
|
|
36
36
|
import { isTestPath, kappa, kappaKnows, nodeCoreUnreviewed, fsKind, commandHeadEffects, hostLiteral,
|
|
@@ -49,7 +49,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
49
49
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
50
50
|
// Reused, never re-littered.
|
|
51
51
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
52
|
-
const SPEC_VERSION = "0.
|
|
52
|
+
const SPEC_VERSION = "0.39";
|
|
53
53
|
|
|
54
54
|
// A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
|
|
55
55
|
//
|
|
@@ -1718,7 +1718,8 @@ const declaredButUninstalled = new Set();
|
|
|
1718
1718
|
}
|
|
1719
1719
|
// ⟨workspace chain⟩ --workspace: auto-discover the target's symlinked monorepo deps (a workspace link
|
|
1720
1720
|
// points OUT of node_modules to the package's real source), scan each into `.candor/deps/` with
|
|
1721
|
-
// interface-CHA union entries (
|
|
1721
|
+
// interface-CHA union entries (⟨0.39⟩: always, no longer behind an env flag), and feed that dir into the
|
|
1722
|
+
// CANDOR_DEPS spec below —
|
|
1722
1723
|
// so a cross-package call (`client.get()` into `@ukri-tfs/common`) discloses the sibling's effects instead
|
|
1723
1724
|
// of reading pure. The candor-ts analog of rust `--deps`. The child scan is spawned WITHOUT --workspace, so
|
|
1724
1725
|
// there is no re-discovery recursion. TRANSITIVE: a dep's calls into ITS OWN workspace deps must also
|
|
@@ -1817,7 +1818,7 @@ if (wantWorkspace) {
|
|
|
1817
1818
|
for (const real of depPaths) {
|
|
1818
1819
|
try {
|
|
1819
1820
|
const out = execFileSync(process.execPath, [selfPath, real, "--json"],
|
|
1820
|
-
{ env: { ...process.env,
|
|
1821
|
+
{ env: { ...process.env, CANDOR_DEPS: workspaceDepsDir },
|
|
1821
1822
|
maxBuffer: 512 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
1822
1823
|
// ⟨ownership, half 1⟩ ONE derivation, shared with `failedDepName`, and TOTAL. The sweep's rule is
|
|
1823
1824
|
// "a file candor would have OVERWRITTEN on success is the file it removes on failure", which is
|
|
@@ -1914,7 +1915,7 @@ if (wantDepInits) {
|
|
|
1914
1915
|
const file = depCacheFileName(depInitsDir, pkg);
|
|
1915
1916
|
try {
|
|
1916
1917
|
const out = execFileSync(process.execPath, [selfPath2, dir, "--json", "--allow-js"],
|
|
1917
|
-
{ env: { ...process.env
|
|
1918
|
+
{ env: { ...process.env },
|
|
1918
1919
|
maxBuffer: 512 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
1919
1920
|
fs.writeFileSync(file, out);
|
|
1920
1921
|
answered.add(pkg); ownFiles.add(file);
|
|
@@ -2271,9 +2272,24 @@ const corruptDepPkgs = new Set();
|
|
|
2271
2272
|
// package chained twice — is never clobbered by the bad one.
|
|
2272
2273
|
if (!stale && entryCorruptKeys(e).length) continue;
|
|
2273
2274
|
const hashPkg = e.hash.split("#")[0];
|
|
2274
|
-
|
|
2275
|
-
|
|
2275
|
+
// ⟨0.39⟩ A FOREIGN UNION ENTRY IS NOT COVERAGE OF THE PACKAGE IT NAMES. Obligation 2 makes a
|
|
2276
|
+
// package publish an `interfaceUnion` entry keyed under the abstraction's OWNER, so `effimpl`'s
|
|
2277
|
+
// report now carries `iface#Backend.size` — and reading that as "iface was analyzed" would delete
|
|
2278
|
+
// `invisible: [iface]` from every call into iface that nobody analysed. That is R475's OWN shape
|
|
2279
|
+
// manufactured by R475's own fix: a disclosure removed by a mechanism added to stop disclosures
|
|
2280
|
+
// being removed. One synthetic row about one member of somebody else's abstraction is a claim
|
|
2281
|
+
// about that MEMBER, never about that package.
|
|
2282
|
+
// Fail-CLOSED on a report with no `package`: a union entry then contributes no coverage at all,
|
|
2283
|
+
// because nothing in the file proves the key is the producer's own. Withholding coverage costs a
|
|
2284
|
+
// disclosure the consumer keeps (`invisible`, or half 1's `Unknown[dispatch:…]`); granting it
|
|
2285
|
+
// wrongly costs one it loses, and only one of those two errors is recoverable.
|
|
2286
|
+
if (hashPkg && !(e.interfaceUnion === true && hashPkg !== d.package)) covers.add(hashPkg);
|
|
2287
|
+
const cell = crossDeps.get(e.hash) ?? { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false, dispatch: new Set() };
|
|
2276
2288
|
for (const x of stale ? ["Unknown"] : strs(e.inferred)) cell.inferred.add(x);
|
|
2289
|
+
// ⟨0.39⟩ obligation 3's contributor list travels in the cell: which abstraction members this
|
|
2290
|
+
// dependency unit dispatches on. A STALE report's are not read — its assertions are from a build
|
|
2291
|
+
// this scan does not trust, and it is already downgraded to a bare Unknown above.
|
|
2292
|
+
if (!stale) for (const k of strs(e.dispatchesOn)) cell.dispatch.add(k);
|
|
2277
2293
|
// ⟨0.19⟩ THE REASON CLASS TRAVELS WITH THE UNKNOWN. Without this the join copied `inferred` and
|
|
2278
2294
|
// `invisible` only, so a dependency's `Unknown[reflect:eval]` arrived at the consumer as a bare
|
|
2279
2295
|
// Unknown and fell back to the generic `unresolved` — and `deny Net Unknown[reflect]`, a rule
|
|
@@ -2347,6 +2363,43 @@ const corruptDepPkgs = new Set();
|
|
|
2347
2363
|
}
|
|
2348
2364
|
} catch { console.error(`candor-ts: CANDOR_DEPS report could not be processed, skipped: ${f}`); }
|
|
2349
2365
|
}
|
|
2366
|
+
// ⟨0.39⟩ OBLIGATION 3, THE CHAINED HALF: union, PER KEY, every chained entry carrying it. Resolved
|
|
2367
|
+
// once here, over the whole loaded set, rather than at each call site — the contributors are exactly
|
|
2368
|
+
// the reports already read, and a key's own cell may itself dispatch (`app` -> `middle::mid_size` ->
|
|
2369
|
+
// `iface#Backend.size`), so this is a least fixpoint over `crossDeps` and not a single lookup.
|
|
2370
|
+
//
|
|
2371
|
+
// This adds a CONTRIBUTOR, not a resolution rule: ⟨0.25⟩'s ambiguous-key union already specifies how
|
|
2372
|
+
// several reports' entries under one hash combine, and the loader above has been unioning them into
|
|
2373
|
+
// one cell all along. What was missing is that `iface#termSize`'s cell had no way to reach
|
|
2374
|
+
// `iface#Backend.size`'s, so an effectful implementor published by a THIRD package sat in the map
|
|
2375
|
+
// unread while the consumer certified the dispatch pure.
|
|
2376
|
+
{
|
|
2377
|
+
const order = [...crossDeps.keys()];
|
|
2378
|
+
let changed = true;
|
|
2379
|
+
for (let round = 0; changed && round < 64; round++) { // bounded: a chain deeper than this is a cycle
|
|
2380
|
+
changed = false;
|
|
2381
|
+
for (const k of order) {
|
|
2382
|
+
const cell = crossDeps.get(k);
|
|
2383
|
+
for (const target of [...cell.dispatch]) {
|
|
2384
|
+
if (target === k) continue; // a member that dispatches on itself adds nothing
|
|
2385
|
+
const src = crossDeps.get(target);
|
|
2386
|
+
if (!src) continue; // nobody published a union under that key — silence IS purity
|
|
2387
|
+
const before = cell.inferred.size + cell.invisible.size + cell.why.size
|
|
2388
|
+
+ cell.incomplete.size + cell.dispatch.size;
|
|
2389
|
+
for (const x of src.inferred) cell.inferred.add(x);
|
|
2390
|
+
for (const b of src.invisible) cell.invisible.add(b);
|
|
2391
|
+
for (const w of src.why) cell.why.add(w);
|
|
2392
|
+
for (const v of src.incomplete) cell.incomplete.add(v);
|
|
2393
|
+
for (const d2 of src.dispatch) cell.dispatch.add(d2);
|
|
2394
|
+
for (const m of ["hosts", "cmds", "paths", "tables"])
|
|
2395
|
+
for (const v of src[m]) if (!cell[m].includes(v)) cell[m].push(v);
|
|
2396
|
+
if (src.netIncomplete && !cell.netIncomplete) { cell.netIncomplete = true; changed = true; }
|
|
2397
|
+
if (cell.inferred.size + cell.invisible.size + cell.why.size
|
|
2398
|
+
+ cell.incomplete.size + cell.dispatch.size !== before) changed = true;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2350
2403
|
// A package chained TWICE — once fresh, once stale — is covered by the fresh report, so it is not a
|
|
2351
2404
|
// stale-only package and must not pick up the disclosure below on top of a real answer.
|
|
2352
2405
|
for (const p of depCoveredPkgs) staleDepPkgs.delete(p);
|
|
@@ -2620,6 +2673,32 @@ function nearestPackageName(file) {
|
|
|
2620
2673
|
for (const d of seen) pkgNameCache.set(d, null);
|
|
2621
2674
|
return null;
|
|
2622
2675
|
}
|
|
2676
|
+
// ⟨0.39⟩ The nearest directory holding a `package.json`, at or above a file — the root `nearestPackageName`
|
|
2677
|
+
// found the name in. Used to spell a FOREIGN abstraction's `loc` in a report.
|
|
2678
|
+
function nearestPackageDir(file) {
|
|
2679
|
+
let dir = path.dirname(file);
|
|
2680
|
+
while (dir && dir !== path.dirname(dir)) {
|
|
2681
|
+
try { if (fs.existsSync(path.join(dir, "package.json"))) return dir; } catch { /* keep climbing */ }
|
|
2682
|
+
dir = path.dirname(dir);
|
|
2683
|
+
}
|
|
2684
|
+
return null;
|
|
2685
|
+
}
|
|
2686
|
+
// ⟨0.39⟩ WHERE a union entry's abstraction is declared. For a LOCAL one that is the ordinary
|
|
2687
|
+
// project-relative path. For a FOREIGN one `path.relative(rootDir, …)` produces a `../../..` chain out of
|
|
2688
|
+
// the tree — unreadable, and, worse, NOT REPRODUCIBLE: its length is a function of how deep the checkout
|
|
2689
|
+
// happens to sit, so two machines scanning the same sources emit different bytes and every
|
|
2690
|
+
// byte-comparison A/B and the chain-idempotence part read a difference that is not one. Spelled from the
|
|
2691
|
+
// OWNING package instead (`ifz/src/index.ts`), which is stable, readable, and says which package the
|
|
2692
|
+
// declaration belongs to — the same fact the hash already carries.
|
|
2693
|
+
function abstractionLoc(sf, line, character) {
|
|
2694
|
+
const abs = path.resolve(sf.fileName);
|
|
2695
|
+
const rel = path.relative(rootDir, abs);
|
|
2696
|
+
if (!rel.startsWith("..")) return `${rel}:${line + 1}:${character + 1}`;
|
|
2697
|
+
const owner = nearestPackageDir(abs);
|
|
2698
|
+
const name = owner && nearestPackageName(abs);
|
|
2699
|
+
const inside = owner ? path.relative(owner, abs) : path.basename(abs);
|
|
2700
|
+
return `${name ? `${name}/` : ""}${inside}:${line + 1}:${character + 1}`;
|
|
2701
|
+
}
|
|
2623
2702
|
// Does reaching this declaration CROSS A PACKAGE BOUNDARY the scan cannot see into? This is the gate on
|
|
2624
2703
|
// every κ-ledger / `invisible` disclosure arm (the unmodeled-external-call, the `new ExternalClass()`, the
|
|
2625
2704
|
// external tagged template). It used to be spelled `/node_modules\//.test(file)` — true only of an
|
|
@@ -3381,6 +3460,33 @@ function isDynamicExportsDescriptor(call) {
|
|
|
3381
3460
|
// `@Entity()` (naming-strategy-dependent) contributes nothing — never a guess.
|
|
3382
3461
|
const entityTables = new Map(); // ClassDeclaration node -> table name
|
|
3383
3462
|
const interfaceImpls = new Map(); // InterfaceDeclaration node -> implementing ClassDeclarations (CHA universe)
|
|
3463
|
+
// ⟨0.39⟩ obligation 2 — the SAME relation for an abstraction owned by a DEPENDENCY. Deliberately a
|
|
3464
|
+
// SECOND map rather than a widening of the one above: `interfaceImpls` is the in-scan dispatch site's CHA
|
|
3465
|
+
// universe, and a foreign declaration in it would put a `node_modules` interface into the resolution of
|
|
3466
|
+
// every local dispatch. These entries are published (keyed under the OWNER's package) and joined; they
|
|
3467
|
+
// are not a local resolution universe.
|
|
3468
|
+
const foreignInterfaceImpls = new Map(); // foreign InterfaceDeclaration -> local implementing classes
|
|
3469
|
+
// ⟨SOUNDNESS R512⟩ Is this interface DECLARATION one a foreign union entry may be published under? ONE
|
|
3470
|
+
// predicate, shared by the nominal `implements` arm and the STRUCTURAL arm below, because two copies of
|
|
3471
|
+
// one key rule is how a second spelling gets invented (§4 forbids exactly that for this key).
|
|
3472
|
+
//
|
|
3473
|
+
// The PLATFORM typing test is `declIsNodeTypes`, not a name check, for the reason `recordDispatch`'s own
|
|
3474
|
+
// comment gives: `declModule` derives `events`, `stream` and `process` out of `@types/node` and npm ships
|
|
3475
|
+
// real packages under all three names, so a name-keyed test hands node's review to an unrelated package.
|
|
3476
|
+
// It is included HERE and not only at `recordDispatch` because the two sides must agree: `recordDispatch`
|
|
3477
|
+
// refuses to mint `events#EventEmitter.on` at a consumer, so an entry published under that key is one no
|
|
3478
|
+
// consumer can ever form — unjoinable wire noise, and noise in a soundness field is how a real value
|
|
3479
|
+
// stops being read.
|
|
3480
|
+
const isPublishableForeignIface = (d) => {
|
|
3481
|
+
if (!d || !ts.isInterfaceDeclaration(d) || !d.name) return false;
|
|
3482
|
+
if (projectFiles.has(path.resolve(d.getSourceFile().fileName))) return false; // the LOCAL arm owns this one
|
|
3483
|
+
if (declIsNodeTypes(d)) return false;
|
|
3484
|
+
const m = declModule(d);
|
|
3485
|
+
// A nameable PACKAGE, and not our own under another spelling. `<es-lib>`/`<local>` mint no key; an
|
|
3486
|
+
// absolute path fallback (a file no package.json claims) is not a namespace any consumer can form a
|
|
3487
|
+
// hash in, and publishing under it would be the second spelling §4 forbids.
|
|
3488
|
+
return !!m && !m.startsWith("<") && !m.startsWith("/") && m !== pkgName && m !== rootOwnerPkg;
|
|
3489
|
+
};
|
|
3384
3490
|
// ⟨CARDINAL SIN FIX, caller-path scope⟩ ifaceQual ("mod.Iface") -> Set<callerQual> that GENUINELY
|
|
3385
3491
|
// dispatched through that interface's own signature and had it resolved by CHA below — populated AT THE
|
|
3386
3492
|
// RESOLUTION SITE (pass 2's interface-CHA arm), not reconstructed afterward from the flat callgraph.
|
|
@@ -3823,19 +3929,43 @@ for (const sf of sources) {
|
|
|
3823
3929
|
for (const st of eh.types) for (const sdecl of localInterfaceDecls(st.expression)) climb(sdecl);
|
|
3824
3930
|
}
|
|
3825
3931
|
};
|
|
3932
|
+
// ⟨0.39⟩ obligation 2: the same relation for an abstraction this package does NOT own. `class
|
|
3933
|
+
// Crossterm implements iface.Backend` is the measured instance's shape — the effectful implementor
|
|
3934
|
+
// lives in a THIRD package, neither the dispatching dependency nor the consumer, so a producer that
|
|
3935
|
+
// covers only LOCAL abstractions misses it entirely and no consumer can ever learn of it. Super-
|
|
3936
|
+
// interfaces are climbed on the same argument as the local arm: a dispatch resolves `base` to
|
|
3937
|
+
// whichever interface DECLARES it, which may be a foreign super of a foreign sub.
|
|
3938
|
+
const foreignInterfaceDecls = (typeExpr) => {
|
|
3939
|
+
const sym = checker.getSymbolAtLocation(typeExpr);
|
|
3940
|
+
const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
|
|
3941
|
+
return (tgt?.declarations ?? []).filter(isPublishableForeignIface);
|
|
3942
|
+
};
|
|
3943
|
+
const fClimbSeen = new Set();
|
|
3944
|
+
const fClimb = (iface) => {
|
|
3945
|
+
if (fClimbSeen.has(iface)) return;
|
|
3946
|
+
fClimbSeen.add(iface);
|
|
3947
|
+
if (!foreignInterfaceImpls.has(iface)) foreignInterfaceImpls.set(iface, []);
|
|
3948
|
+
const arr = foreignInterfaceImpls.get(iface);
|
|
3949
|
+
if (!arr.includes(node)) arr.push(node);
|
|
3950
|
+
for (const eh of iface.heritageClauses ?? []) {
|
|
3951
|
+
if (eh.token !== ts.SyntaxKind.ExtendsKeyword) continue;
|
|
3952
|
+
for (const st of eh.types) for (const sdecl of foreignInterfaceDecls(st.expression)) fClimb(sdecl);
|
|
3953
|
+
}
|
|
3954
|
+
};
|
|
3826
3955
|
for (const h of node.heritageClauses ?? []) {
|
|
3827
3956
|
if (h.token !== ts.SyntaxKind.ImplementsKeyword) continue;
|
|
3828
3957
|
// Register under EVERY declaration of the interface symbol: a merged interface (two `interface
|
|
3829
3958
|
// Store` blocks / module augmentation) resolves a method to whichever block declares it, and keying
|
|
3830
3959
|
// only declarations[0] silently missed the others (/code-review).
|
|
3831
3960
|
for (const t of h.types) for (const idecl of localInterfaceDecls(t.expression)) climb(idecl);
|
|
3961
|
+
for (const t of h.types) for (const idecl of foreignInterfaceDecls(t.expression)) fClimb(idecl);
|
|
3832
3962
|
}
|
|
3833
3963
|
const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
|
|
3834
3964
|
if (!fns.has(ctorQual)) {
|
|
3835
3965
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
3836
3966
|
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
|
|
3837
3967
|
hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
|
|
3838
|
-
blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
|
|
3968
|
+
blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(), entry: false,
|
|
3839
3969
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
3840
3970
|
endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
|
|
3841
3971
|
}
|
|
@@ -3910,7 +4040,7 @@ for (const sf of sources) {
|
|
|
3910
4040
|
const nsp = namespacePrefixOf(node);
|
|
3911
4041
|
const qual = isFunctionScoped(node) ? `${mod}.${nsp}${n}#${line + 1}:${character + 1}` : `${mod}.${nsp}${n}`;
|
|
3912
4042
|
fns.set(qual, { local: n, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
3913
|
-
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
4043
|
+
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
3914
4044
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
3915
4045
|
endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
|
|
3916
4046
|
nodeName.set(node, qual);
|
|
@@ -4332,18 +4462,29 @@ function unwrapBind(node, depth = 0) {
|
|
|
4332
4462
|
// check. Local-only, mirroring the nominal branch's own bound: a structural value arriving from outside
|
|
4333
4463
|
// this scan's own source tree (an argument passed by an external caller, a dependency's own callback)
|
|
4334
4464
|
// is no more visible here than an external nominal implementor already was.
|
|
4335
|
-
|
|
4336
|
-
|
|
4465
|
+
//
|
|
4466
|
+
// ⟨SOUNDNESS R512⟩ …and the FOREIGN half of the same climb. "Local-only, mirroring the nominal branch's
|
|
4467
|
+
// own bound" was true of the nominal branch when that sentence was written and stopped being true when
|
|
4468
|
+
// ⟨0.39⟩ gave the nominal branch a foreign arm: `class Crossterm implements dep.Backend` publishes a
|
|
4469
|
+
// union entry under `dep`, and `const x: dep.Backend = { size() { …net… } }` — the SAME package supplying
|
|
4470
|
+
// the SAME effectful implementor of the SAME foreign abstraction — published nothing at all, so a chained
|
|
4471
|
+
// consumer read `inferred: []` with no `invisible`, which under ⟨0.21⟩ is a purity claim. That is R475's
|
|
4472
|
+
// own shape one SPELLING over, and a structural object literal is idiomatic TypeScript rather than an
|
|
4473
|
+
// exotic case. The bound that survives is the one that was doing the work: an implementor arriving from
|
|
4474
|
+
// OUTSIDE this scan's own source tree is still invisible here. A foreign implementor DECLARED here is not.
|
|
4475
|
+
function interfaceDeclsOfType(t) {
|
|
4476
|
+
const local = [], foreign = [];
|
|
4337
4477
|
const consider = (ct) => {
|
|
4338
4478
|
const sym = ct?.getSymbol?.() ?? ct?.symbol;
|
|
4339
4479
|
for (const d of sym?.declarations ?? []) {
|
|
4340
|
-
if (ts.isInterfaceDeclaration(d)
|
|
4341
|
-
|
|
4480
|
+
if (!ts.isInterfaceDeclaration(d)) continue;
|
|
4481
|
+
if (projectFiles.has(path.resolve(d.getSourceFile().fileName))) { if (!local.includes(d)) local.push(d); }
|
|
4482
|
+
else if (isPublishableForeignIface(d)) { if (!foreign.includes(d)) foreign.push(d); }
|
|
4342
4483
|
}
|
|
4343
4484
|
};
|
|
4344
|
-
if (!t) return
|
|
4485
|
+
if (!t) return { local, foreign };
|
|
4345
4486
|
if (t.isUnion?.()) { for (const ct of t.types) consider(ct); } else consider(t);
|
|
4346
|
-
return
|
|
4487
|
+
return { local, foreign };
|
|
4347
4488
|
}
|
|
4348
4489
|
function registerStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
|
|
4349
4490
|
if (seen.has(ifaceDecl)) return;
|
|
@@ -4364,6 +4505,28 @@ function registerStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
|
|
|
4364
4505
|
}
|
|
4365
4506
|
}
|
|
4366
4507
|
}
|
|
4508
|
+
// ⟨SOUNDNESS R512⟩ The same registration for an abstraction this package does NOT own — into
|
|
4509
|
+
// `foreignInterfaceImpls`, NOT `interfaceImpls`, on that map's own stated grounds: these entries are
|
|
4510
|
+
// published under the OWNER's package and joined, they are not a local resolution universe, and a
|
|
4511
|
+
// `node_modules` declaration in the in-scan CHA universe would change what every local dispatch resolves
|
|
4512
|
+
// to. Super-interfaces are climbed on the nominal arm's argument: a dispatch resolves a member to
|
|
4513
|
+
// whichever interface DECLARES it, which may be a foreign super of a foreign sub.
|
|
4514
|
+
function registerForeignStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
|
|
4515
|
+
if (seen.has(ifaceDecl)) return;
|
|
4516
|
+
seen.add(ifaceDecl);
|
|
4517
|
+
if (!foreignInterfaceImpls.has(ifaceDecl)) foreignInterfaceImpls.set(ifaceDecl, []);
|
|
4518
|
+
const arr = foreignInterfaceImpls.get(ifaceDecl);
|
|
4519
|
+
if (!arr.includes(implNode)) arr.push(implNode);
|
|
4520
|
+
for (const eh of ifaceDecl.heritageClauses ?? []) {
|
|
4521
|
+
if (eh.token !== ts.SyntaxKind.ExtendsKeyword) continue;
|
|
4522
|
+
for (const st of eh.types) {
|
|
4523
|
+
let sym; try { sym = checker.getSymbolAtLocation(st.expression); } catch { sym = undefined; }
|
|
4524
|
+
const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
|
|
4525
|
+
for (const d of tgt?.declarations ?? [])
|
|
4526
|
+
if (isPublishableForeignIface(d)) registerForeignStructuralImpl(d, implNode, seen);
|
|
4527
|
+
}
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4367
4530
|
// Is `param -> body` a PROVEN identity return — the function's ENTIRE body is exactly `return <param>;`
|
|
4368
4531
|
// (block form) or exactly `<param>` (arrow concise-body form), with no destructuring, no default, no
|
|
4369
4532
|
// rest, no cast, no conditional, nothing at all between the parameter and the value returned — and the
|
|
@@ -4399,8 +4562,11 @@ function isProvenIdentityReturn(decl, paramIdx) {
|
|
|
4399
4562
|
// chain of calls.
|
|
4400
4563
|
function contextualInterfaceDeclsFor(node, depth = 0) {
|
|
4401
4564
|
let ct; try { ct = checker.getContextualType(node); } catch { ct = undefined; }
|
|
4402
|
-
const direct =
|
|
4403
|
-
|
|
4565
|
+
const direct = interfaceDeclsOfType(ct);
|
|
4566
|
+
// ⟨SOUNDNESS R512⟩ The climb continues while NEITHER partition answered. Keying it on the LOCAL list
|
|
4567
|
+
// alone would stop the `Object.assign` / proven-identity-wrapper climb dead for a foreign abstraction,
|
|
4568
|
+
// which is the same miss one level up.
|
|
4569
|
+
if (direct.local.length || direct.foreign.length || depth >= 4) return direct;
|
|
4404
4570
|
const p = node.parent;
|
|
4405
4571
|
if (!p || !ts.isCallExpression(p) || !(p.arguments ?? []).includes(node)) return direct;
|
|
4406
4572
|
const calleeText = p.expression.getText().replace(/\s+/g, "");
|
|
@@ -4461,7 +4627,7 @@ function mintPositionalStructuralUnit(mod, sf, prop, name) {
|
|
|
4461
4627
|
const { line, character } = sf.getLineAndCharacterOfPosition(prop.getStart());
|
|
4462
4628
|
fns.set(qual, { local: `<structural>.${name}`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
|
|
4463
4629
|
hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(), blind: new Set(),
|
|
4464
|
-
incomplete: new Set(), why: new Set(), entry: false,
|
|
4630
|
+
incomplete: new Set(), dispatch: new Set(), why: new Set(), entry: false,
|
|
4465
4631
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
|
|
4466
4632
|
endLine: sf.getLineAndCharacterOfPosition(prop.getEnd()).line + 1 });
|
|
4467
4633
|
}
|
|
@@ -4476,8 +4642,9 @@ for (const sf of sources) {
|
|
|
4476
4642
|
// never structurally satisfy an interface with any member on its own.
|
|
4477
4643
|
if (ts.isObjectLiteralExpression(node) && node.properties.length > 0) {
|
|
4478
4644
|
const decls = contextualInterfaceDeclsFor(node);
|
|
4479
|
-
if (decls.length) {
|
|
4480
|
-
for (const d of decls) registerStructuralImpl(d, node);
|
|
4645
|
+
if (decls.local.length || decls.foreign.length) {
|
|
4646
|
+
for (const d of decls.local) registerStructuralImpl(d, node);
|
|
4647
|
+
for (const d of decls.foreign) registerForeignStructuralImpl(d, node);
|
|
4481
4648
|
mintStructuralMembers(node);
|
|
4482
4649
|
}
|
|
4483
4650
|
} else if (ts.isClassExpression(node)) {
|
|
@@ -4486,17 +4653,25 @@ for (const sf of sources) {
|
|
|
4486
4653
|
// registered nowhere at all (not even as a candidate, unlike the object-literal shape) and its
|
|
4487
4654
|
// methods were never minted units either (`localName`'s method branch requires a ClassDeclaration
|
|
4488
4655
|
// parent). Reuse the SAME climb/registration and member-minting as the object-literal shape.
|
|
4656
|
+
// ⟨SOUNDNESS R512⟩ …and a FOREIGN `implements` on a class EXPRESSION registers under the OWNER, the
|
|
4657
|
+
// same as the class DECLARATION arm already does. The audit boundary is deliberately drawn past the
|
|
4658
|
+
// trigger: the row is about the object-literal spelling, and this branch is the identical hole one
|
|
4659
|
+
// construct over — an anonymous class expression supplying a dependency's abstraction effectfully.
|
|
4489
4660
|
let registeredAny = false;
|
|
4490
4661
|
for (const h of node.heritageClauses ?? []) {
|
|
4491
4662
|
if (h.token !== ts.SyntaxKind.ImplementsKeyword) continue;
|
|
4492
4663
|
for (const t of h.types) {
|
|
4493
4664
|
let sym; try { sym = checker.getSymbolAtLocation(t.expression); } catch { sym = undefined; }
|
|
4494
4665
|
const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
|
|
4495
|
-
for (const d of tgt?.declarations ?? [])
|
|
4666
|
+
for (const d of tgt?.declarations ?? []) {
|
|
4496
4667
|
if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName))) {
|
|
4497
4668
|
registerStructuralImpl(d, node);
|
|
4498
4669
|
registeredAny = true;
|
|
4670
|
+
} else if (isPublishableForeignIface(d)) {
|
|
4671
|
+
registerForeignStructuralImpl(d, node);
|
|
4672
|
+
registeredAny = true;
|
|
4499
4673
|
}
|
|
4674
|
+
}
|
|
4500
4675
|
}
|
|
4501
4676
|
}
|
|
4502
4677
|
if (registeredAny) mintStructuralMembers(node);
|
|
@@ -5122,7 +5297,7 @@ function moduleUnit(sf) {
|
|
|
5122
5297
|
let rec = fns.get(qual);
|
|
5123
5298
|
if (!rec) {
|
|
5124
5299
|
rec = { local: qual, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
5125
|
-
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
5300
|
+
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(),
|
|
5126
5301
|
entry: false, unitKind: "initializer",
|
|
5127
5302
|
loc: `${path.relative(rootDir, sf.fileName)}:1:1`,
|
|
5128
5303
|
endLine: sf.getLineAndCharacterOfPosition(sf.getEnd()).line + 1 };
|
|
@@ -5145,7 +5320,7 @@ function staticBlockUnit(node) {
|
|
|
5145
5320
|
let rec = fns.get(qual);
|
|
5146
5321
|
if (!rec) {
|
|
5147
5322
|
rec = { local: "<static-init>", direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
5148
|
-
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
5323
|
+
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(),
|
|
5149
5324
|
entry: false, unitKind: "initializer",
|
|
5150
5325
|
loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1`,
|
|
5151
5326
|
endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 };
|
|
@@ -5170,7 +5345,7 @@ function decoratorArgUnit(callNode) {
|
|
|
5170
5345
|
let rec = fns.get(qual);
|
|
5171
5346
|
if (!rec) {
|
|
5172
5347
|
rec = { local, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
5173
|
-
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
5348
|
+
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(),
|
|
5174
5349
|
entry: false, unitKind: "initializer",
|
|
5175
5350
|
loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(callNode.getStart()).line + 1}:1`,
|
|
5176
5351
|
endLine: sf.getLineAndCharacterOfPosition(callNode.getEnd()).line + 1 };
|
|
@@ -5386,6 +5561,70 @@ function memberSigOf(decl) {
|
|
|
5386
5561
|
return decl && (ts.isFunctionTypeNode(decl) || ts.isCallSignatureDeclaration(decl)) && decl.parent
|
|
5387
5562
|
&& (ts.isPropertySignature(decl.parent) || ts.isMethodSignature(decl.parent)) ? decl.parent : decl;
|
|
5388
5563
|
}
|
|
5564
|
+
// ⟨0.39⟩ obligation 1, THE KEY. One derivation, used by every site that records a dispatch and by the
|
|
5565
|
+
// union emitter that publishes under it — because two spellings of one wire key is exactly the ⟨0.34⟩
|
|
5566
|
+
// drift the clause spends a paragraph forbidding, and §4 says an engine MUST NOT invent a second one.
|
|
5567
|
+
// The shape is the one ⟨0.23⟩ already fixes for this engine's entry hashes: `<owning package>#<Iface>.<member>`.
|
|
5568
|
+
const dispatchKey = (pkg, ifaceName, member) => `${pkg}#${ifaceName}.${member}`;
|
|
5569
|
+
// The interface member a resolved declaration dispatches THROUGH, or null. A method/property signature
|
|
5570
|
+
// on an INTERFACE only: that is precisely the universe `interfaceUnion` publishes under, so a key
|
|
5571
|
+
// recorded here is always a key a producer could answer. A call signature, a type-literal member and an
|
|
5572
|
+
// abstract class member deliberately mint nothing — a `dispatchesOn` value no union entry can ever carry
|
|
5573
|
+
// is wire noise, and noise in a soundness field is how a real one stops being read.
|
|
5574
|
+
function dispatchedInterfaceMember(decl) {
|
|
5575
|
+
const sig = memberSigOf(decl);
|
|
5576
|
+
if (!sig || !(ts.isMethodSignature(sig) || ts.isPropertySignature(sig))) return null;
|
|
5577
|
+
if (!sig.parent || !ts.isInterfaceDeclaration(sig.parent) || !sig.parent.name) return null;
|
|
5578
|
+
const m = sig.name?.getText?.();
|
|
5579
|
+
return m ? { ifaceName: sig.parent.name.text, member: m } : null;
|
|
5580
|
+
}
|
|
5581
|
+
// Record a dispatch on an interface owned by `pkg`; returns the parts, or null. Called from the in-scan
|
|
5582
|
+
// bounded-CHA site (`pkgName`) and from both external-call arms (the dependency's name) — NOT gated on
|
|
5583
|
+
// what the CHA answered, because the toggle this rung closes runs between ZERO implementors and ONE, so
|
|
5584
|
+
// a field recorded only on the indeterminate branch is absent in exactly the arm that needs it.
|
|
5585
|
+
//
|
|
5586
|
+
// THE PLATFORM TYPE SURFACE IS EXCLUDED, and the test is on the FILE rather than the module name — the
|
|
5587
|
+
// same rule `nodeCoreUnreviewed`'s own comment states, and for the same reason: `declModule` derives
|
|
5588
|
+
// `events`, `buffer.buffer` and `process` out of `@types/node`, and npm ships real packages under those
|
|
5589
|
+
// names. The exclusion is not a precision preference: `@types/node` and the TypeScript lib are types
|
|
5590
|
+
// with no implementation any scan can analyse, so NO producer can ever publish a union under such a
|
|
5591
|
+
// key. A `dispatchesOn` value nothing can answer is noise, and noise in a soundness field is how a real
|
|
5592
|
+
// value stops being read. Measured before the exclusion: an over-charge control's genuinely-pure
|
|
5593
|
+
// function carried `dispatchesOn: [buffer.buffer#BufferConstructor.from, events#EventEmitter.emit,
|
|
5594
|
+
// events#EventEmitter.on, process#Process.cwd]` — four keys, zero of them answerable.
|
|
5595
|
+
function recordDispatch(rec, decl, pkg) {
|
|
5596
|
+
if (!rec || !decl || !pkg) return null;
|
|
5597
|
+
if (declIsNodeTypes(decl)) return null;
|
|
5598
|
+
const d = dispatchedInterfaceMember(decl);
|
|
5599
|
+
if (!d) return null;
|
|
5600
|
+
const key = dispatchKey(pkg, d.ifaceName, d.member);
|
|
5601
|
+
rec.dispatch.add(key);
|
|
5602
|
+
return { key, pkg, ...d };
|
|
5603
|
+
}
|
|
5604
|
+
// ⟨0.39⟩ obligation 3, THE LOCAL HALF AT THE JOIN: "its own visible implementors". A key resolved
|
|
5605
|
+
// through a dependency may also be answered by a class in THIS scan — an application supplying its own
|
|
5606
|
+
// backend to a library is the ordinary shape of it, and it is the shape the clause's measured case has.
|
|
5607
|
+
// Bounded by the SAME `CHA_FANOUT_LIMIT` the in-scan dispatch site and the union emitter apply, and
|
|
5608
|
+
// hedged on the SAME completeness condition: an implementor whose member resolves to no unit leaves the
|
|
5609
|
+
// candidate set incomplete, and edging the rest while staying silent about it would drop its effects.
|
|
5610
|
+
function joinLocalImpls(rec, d) {
|
|
5611
|
+
if (!rec || !d) return;
|
|
5612
|
+
const { targets, allResolved, decls } = localImplTargets(d.key);
|
|
5613
|
+
if (!targets.length) return;
|
|
5614
|
+
// The name means two things here, so no implementor set can be attributed to this key — §4 ⟨0.24⟩'s
|
|
5615
|
+
// `ambiguous:`, not `dispatch:`: the owner type is nameable, but WHICH declaration it names is not.
|
|
5616
|
+
if (decls.size > 1) {
|
|
5617
|
+
rec.direct.add("Unknown");
|
|
5618
|
+
rec.why.add(`ambiguous:${d.pkg}.${d.ifaceName}.${d.member}`);
|
|
5619
|
+
return;
|
|
5620
|
+
}
|
|
5621
|
+
if (targets.length > CHA_FANOUT_LIMIT || !allResolved) {
|
|
5622
|
+
rec.direct.add("Unknown");
|
|
5623
|
+
rec.why.add(dispatchWhy(`${d.pkg}.${d.ifaceName}`, d.member));
|
|
5624
|
+
return;
|
|
5625
|
+
}
|
|
5626
|
+
for (const t of targets) rec.edges.add(t);
|
|
5627
|
+
}
|
|
5389
5628
|
// Apply ONE chained-dependency entry to the calling unit. There is exactly one of these because there used
|
|
5390
5629
|
// to be two, drifted: the CallExpression arm and the desugared-declaration arm each spelled the copy out,
|
|
5391
5630
|
// and the ⟨0.19⟩ reason class was added to neither. That is the same root cause candor-java's `6ab26e4`
|
|
@@ -5393,7 +5632,70 @@ function memberSigOf(decl) {
|
|
|
5393
5632
|
// is most of the fix there too. The dep's `invisible` travels as this call's own — the transitive
|
|
5394
5633
|
// disclosure has to cross the package edge, or a sibling's SNS reach reads pure here — and so does its
|
|
5395
5634
|
// `unknownWhy`, so `deny E Unknown[<class>]` keeps its scope one boundary along.
|
|
5635
|
+
// ⟨0.39⟩ OBLIGATION 3, THE LOCAL HALF: "its own visible implementors". A key the dependency dispatches
|
|
5636
|
+
// on may be answered by a class in THIS scan — the ordinary shape of an application supplying its own
|
|
5637
|
+
// backend to a library. Built once, lazily, because it needs pass 1's `nodeName` minting; keyed by the
|
|
5638
|
+
// same `pkg#Iface.member` string the wire uses, over BOTH registries (our own abstractions, in case a
|
|
5639
|
+
// dependency dispatches on one of ours, and the foreign ones obligation 2 publishes under).
|
|
5640
|
+
//
|
|
5641
|
+
// The contribution is an EDGE, not an effect set, and that is load-bearing: at the moment a dep hit is
|
|
5642
|
+
// applied the implementor's own transitive effects have not been computed yet (pass 3 does that), so
|
|
5643
|
+
// copying them here would copy whatever happened to be known and silently under-report. An edge flows
|
|
5644
|
+
// through the SAME least fixpoint every other call does.
|
|
5645
|
+
let localImplTargetsByKey = null;
|
|
5646
|
+
function localImplTargets(key) {
|
|
5647
|
+
if (!localImplTargetsByKey) {
|
|
5648
|
+
localImplTargetsByKey = new Map();
|
|
5649
|
+
const add = (ownerPkg, ifaceDecl, implClasses) => {
|
|
5650
|
+
const ifaceName = ifaceDecl.name?.text;
|
|
5651
|
+
if (!ifaceName) return;
|
|
5652
|
+
for (const member of ifaceDecl.members ?? []) {
|
|
5653
|
+
const m = member.name?.getText?.();
|
|
5654
|
+
if (!m) continue;
|
|
5655
|
+
const k = dispatchKey(ownerPkg, ifaceName, m);
|
|
5656
|
+
if (!localImplTargetsByKey.has(k))
|
|
5657
|
+
localImplTargetsByKey.set(k, { targets: [], allResolved: true, decls: new Set() });
|
|
5658
|
+
const cell = localImplTargetsByKey.get(k);
|
|
5659
|
+
// NEVER GUESS WHICH `I` A NAME MEANS — the same guard the union EMITTER applies (`ifaceNameCounts`),
|
|
5660
|
+
// applied to the JOIN, because the two must not answer one question differently. Two declarations
|
|
5661
|
+
// of `Store` in one package both key `pkg#Store.save`, so unioning their implementors charges a
|
|
5662
|
+
// caller that dispatches on one with effects only the OTHER's implementor performs. That is the
|
|
5663
|
+
// cross-declaration fabrication this vein has produced confirmed instances of, and the emitter
|
|
5664
|
+
// refuses it — a join that did not would resolve, in the consumer, exactly what the producer
|
|
5665
|
+
// declined to publish. FOUND BY THE CORPUS A/B, not by a fixture: `apollo-server-core` is built to
|
|
5666
|
+
// both `src` and `dist`, so `ApolloServerPlugin` has two declarations, the emitter published
|
|
5667
|
+
// nothing for it, and this join was edging across both.
|
|
5668
|
+
cell.decls.add(ifaceDecl);
|
|
5669
|
+
for (const cls of implClasses) {
|
|
5670
|
+
const memberNodes = cls.members ?? cls.properties ?? [];
|
|
5671
|
+
const node = memberNodes.find((x) =>
|
|
5672
|
+
(ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x) || ts.isPropertyAssignment(x))
|
|
5673
|
+
&& x.name?.getText?.() === m);
|
|
5674
|
+
const t = node && nodeName.get(node);
|
|
5675
|
+
// An implementor whose member is INHERITED from a base class, or otherwise not a minted unit,
|
|
5676
|
+
// is genuinely unresolved — the same condition the in-scan CHA site calls `allResolved`.
|
|
5677
|
+
if (!t) { cell.allResolved = false; continue; }
|
|
5678
|
+
if (!cell.targets.includes(t)) cell.targets.push(t);
|
|
5679
|
+
}
|
|
5680
|
+
}
|
|
5681
|
+
};
|
|
5682
|
+
for (const [ifaceDecl, impls] of interfaceImpls) add(pkgName, ifaceDecl, impls);
|
|
5683
|
+
for (const [ifaceDecl, impls] of foreignInterfaceImpls) {
|
|
5684
|
+
const ownerPkg = declModule(ifaceDecl);
|
|
5685
|
+
if (ownerPkg && !ownerPkg.startsWith("<") && !ownerPkg.startsWith("/")) add(ownerPkg, ifaceDecl, impls);
|
|
5686
|
+
}
|
|
5687
|
+
}
|
|
5688
|
+
return localImplTargetsByKey.get(key) ?? { targets: [], allResolved: true, decls: new Set() };
|
|
5689
|
+
}
|
|
5396
5690
|
function applyDepHit(rec, hit) {
|
|
5691
|
+
// ⟨0.39⟩ The dispatched members travel with the hit — transitively, so a consumer of THIS report
|
|
5692
|
+
// learns of a dispatch two packages down — and each one is joined against what this scan can see.
|
|
5693
|
+
for (const k of hit.dispatch ?? []) {
|
|
5694
|
+
rec.dispatch.add(k);
|
|
5695
|
+
const [kp, rest] = [k.slice(0, k.indexOf("#")), k.slice(k.indexOf("#") + 1)];
|
|
5696
|
+
const dot = rest.lastIndexOf(".");
|
|
5697
|
+
if (dot > 0) joinLocalImpls(rec, { key: k, pkg: kp, ifaceName: rest.slice(0, dot), member: rest.slice(dot + 1) });
|
|
5698
|
+
}
|
|
5397
5699
|
for (const x of hit.inferred) rec.direct.add(x);
|
|
5398
5700
|
for (const b of hit.invisible ?? []) rec.blind.add(b);
|
|
5399
5701
|
for (const w of hit.why ?? []) rec.why.add(w);
|
|
@@ -5674,6 +5976,11 @@ function chargeExternalDecl(rec, decl, tailOverride) {
|
|
|
5674
5976
|
// Owner-prefixed first (`Owner.member` — how the dep's own scan hashes a method), bare member as the
|
|
5675
5977
|
// fallback (a CJS dist scan hashes a top-level export under its bare name). Identical to the call arm.
|
|
5676
5978
|
const owner = nameDecl.parent?.name?.getText?.();
|
|
5979
|
+
// ⟨0.39⟩ obligation 1, the DESUGARED half of the foreign arm — same call, same position, same reason as
|
|
5980
|
+
// the CallExpression arm's. This function exists because that arm and this one drifted once already.
|
|
5981
|
+
// …and obligation 3's local half on the SAME key, because a call that lands DIRECTLY on the
|
|
5982
|
+
// abstraction member has that member as its key and this scan's own implementors of it are visible.
|
|
5983
|
+
joinLocalImpls(rec, recordDispatch(rec, decl, pkg));
|
|
5677
5984
|
const hit = tailOverride ? crossDeps.get(`${pkg}#${tailOverride}`)
|
|
5678
5985
|
: member && ((owner ? crossDeps.get(`${pkg}#${owner}.${member}`) : undefined)
|
|
5679
5986
|
?? crossDeps.get(`${pkg}#${member}`));
|
|
@@ -7047,6 +7354,11 @@ function visitCalls(node) {
|
|
|
7047
7354
|
let edged = false;
|
|
7048
7355
|
if ((ts.isMethodSignature(sigDecl) || ts.isPropertySignature(sigDecl))
|
|
7049
7356
|
&& sigDecl.parent && ts.isInterfaceDeclaration(sigDecl.parent)) {
|
|
7357
|
+
// ⟨0.39⟩ obligation 1, LOCAL half. Recorded HERE — before the CHA is consulted — for the
|
|
7358
|
+
// reason the clause gives: the sin's toggle runs between zero implementors and one, so
|
|
7359
|
+
// recording only on the branch that could not resolve leaves the field absent in exactly
|
|
7360
|
+
// the arm where absence is the purity claim that deletes the consumer's disclosure.
|
|
7361
|
+
recordDispatch(rec, sigDecl, pkgName);
|
|
7050
7362
|
const impls = interfaceImpls.get(sigDecl.parent) ?? [];
|
|
7051
7363
|
if (impls.length > 0 && impls.length <= CHA_FANOUT_LIMIT) {
|
|
7052
7364
|
const member = sigDecl.name?.getText?.();
|
|
@@ -7648,6 +7960,15 @@ function visitCalls(node) {
|
|
|
7648
7960
|
rec.why.add(`native:${kMod.replace(/^node:/, "")}.`
|
|
7649
7961
|
+ `${isConstruction ? `new ${ctorClassName || ""}` : member}`);
|
|
7650
7962
|
}
|
|
7963
|
+
// ⟨0.39⟩ obligation 1, FOREIGN half — the MIDDLE-PACKAGE case (SOUNDNESS R504). A package that
|
|
7964
|
+
// dispatches over a DEPENDENCY's abstraction owns neither the abstraction nor any implementor
|
|
7965
|
+
// of it, so an obligation-1 pass scoped to interfaces the producer DECLARES leaves the chain
|
|
7966
|
+
// one hop short and the consumer's row ABSENT — a purity claim. Recorded BEFORE the chained
|
|
7967
|
+
// lookup below and independently of whether it hits: whether this run happened to be chained
|
|
7968
|
+
// says nothing about what a consumer of THIS report will be able to see.
|
|
7969
|
+
if (!eff && !mod.startsWith("<"))
|
|
7970
|
+
joinLocalImpls(rec, recordDispatch(rec, decl,
|
|
7971
|
+
mod.startsWith("@types/") ? mod.slice("@types/".length) : mod));
|
|
7651
7972
|
// CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
|
|
7652
7973
|
// that function's recorded transitive effects (+ literal surfaces) by `hash`.
|
|
7653
7974
|
let inheritedFromDep = false;
|
|
@@ -9010,7 +9331,14 @@ const inferred = new Map([...fns.keys()].map((k) => [k, new Set(fns.get(k).direc
|
|
|
9010
9331
|
// `fsKinds` joins the propagated surfaces: kinds TRAVEL the call graph (a caller that transitively only
|
|
9011
9332
|
// writes IS a writer), and the "?" poison travels with them so a caller of an undetermined-kind function
|
|
9012
9333
|
// inherits the SUPPRESSION rather than a half-answer. Pinned by conformance PART 31.
|
|
9013
|
-
|
|
9334
|
+
// ⟨0.39⟩ `dispatch` rides this same sweep, and that is what satisfies obligation 1's "the member must
|
|
9335
|
+
// REACH the caller transitively". The clause permits either spelling — DIRECT members on the wire with
|
|
9336
|
+
// the consumer closing over the producer's `calls`, or the closure taken here — and taking it here is
|
|
9337
|
+
// free: the relation is a union over the same call graph the effects already traverse. (java had to take
|
|
9338
|
+
// the other route because JVM interfaces ARE how that platform dispatches and the closure could not be
|
|
9339
|
+
// serialised at all — `jooq` dead with 8 GB of heap. A TS package's interface surface is nothing like
|
|
9340
|
+
// that, and the corpus A/B is what says so here rather than the analogy.)
|
|
9341
|
+
for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsKinds", "dispatch"]) {
|
|
9014
9342
|
const queue = [...fns.keys()];
|
|
9015
9343
|
const queued = new Set(queue);
|
|
9016
9344
|
for (let head = 0; head < queue.length; head++) {
|
|
@@ -9053,7 +9381,12 @@ for (const [name, rec] of fns) {
|
|
|
9053
9381
|
const inf = [...inferred.get(name)].sort();
|
|
9054
9382
|
// entry points stay visible even when pure; a BLIND fn stays too, so the honesty disclosure survives
|
|
9055
9383
|
// on exactly the `inferred: []` fns that need it.
|
|
9056
|
-
|
|
9384
|
+
// ⟨0.39⟩ …AND A DISPATCHING ROW STAYS EVEN WHEN IT IS OTHERWISE PURE. This is the clause's deliberate
|
|
9385
|
+
// exception to §2 rule 3, and it is the whole left-hand side of the toggle: the row's ABSENCE was the
|
|
9386
|
+
// purity claim that deleted a consumer's disclosure the moment the library acquired one pure
|
|
9387
|
+
// implementor. A pure function that DISPATCHES is no longer a function about which there is nothing to
|
|
9388
|
+
// say — absence keeps its meaning, but this row is no longer absent.
|
|
9389
|
+
if (inf.length === 0 && !rec.entry && rec.blind.size === 0 && rec.dispatch.size === 0) continue;
|
|
9057
9390
|
const entry = {
|
|
9058
9391
|
fn: name,
|
|
9059
9392
|
loc: rec.loc,
|
|
@@ -9074,6 +9407,11 @@ for (const [name, rec] of fns) {
|
|
|
9074
9407
|
// `tour` falls back to these when the sidecar is empty (surface robustness — mirrors the Rust report, whose
|
|
9075
9408
|
// entries carry `calls`); omitted when a fn has no outgoing edges to keep pure leaves lean.
|
|
9076
9409
|
if (rec.edges.size) entry.calls = [...rec.edges].sort();
|
|
9410
|
+
// ⟨0.39⟩ obligation 1: the abstraction members this unit dispatches on, transitively, in the OWNING
|
|
9411
|
+
// package's entry-hash namespace (`pkg#Iface.member`) — the same key obligation 2 publishes a union
|
|
9412
|
+
// under, so a consumer joins it with its ORDINARY `crossDeps` lookup and no per-engine rule. Sorted,
|
|
9413
|
+
// because the wire is compared byte-for-byte by the chain-idempotence part and by every A/B.
|
|
9414
|
+
if (rec.dispatch.size) entry.dispatchesOn = [...rec.dispatch].sort();
|
|
9077
9415
|
if (inf.includes("Net") && rec.hosts.size) entry.hosts = [...rec.hosts].sort();
|
|
9078
9416
|
// ⟨0.20⟩ Net destination-class (NET-DESTINATION-CLASS-DESIGN.md): the classes present in this fn's
|
|
9079
9417
|
// transitive Net surface — exact host-literal match, fail-closed unknown-host on a masked surface (rec
|
|
@@ -9127,7 +9465,9 @@ for (const [name, rec] of fns) {
|
|
|
9127
9465
|
else if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
|
|
9128
9466
|
functions.push(entry);
|
|
9129
9467
|
}
|
|
9130
|
-
// ⟨
|
|
9468
|
+
// ⟨0.23⟩, REQUIRED since ⟨0.39⟩ (it was opt-in via CANDOR_WORKSPACE_CHAIN until that rung; the env var
|
|
9469
|
+
// is gone, not kept as an alias for "on" — a flag that no longer changes anything is a flag the next
|
|
9470
|
+
// reader has to prove inert). INTERFACE-CHA union entries for
|
|
9131
9471
|
// cross-package dispatch. A consumer of THIS package that calls an interface method (`ch.publish()` on an
|
|
9132
9472
|
// imported `OutboundChannel`) resolves the call to the interface METHOD SIGNATURE — which has no body, so
|
|
9133
9473
|
// no report entry, so the chain reads it pure even though every implementation reaches an effect. Emit a
|
|
@@ -9269,8 +9609,25 @@ function typingsInterfaceImpls() {
|
|
|
9269
9609
|
const arr = byIface.get(idecl);
|
|
9270
9610
|
if (!arr.includes(clsName)) arr.push(clsName);
|
|
9271
9611
|
};
|
|
9612
|
+
// ⟨0.39⟩ obligation 2, THE PUBLISHED-PACKAGE SHAPE. This used to keep only `inPkg` declarations, and
|
|
9613
|
+
// the comment beside it read "an interface owned by another package is dropped rather than re-keyed
|
|
9614
|
+
// under ours (its union belongs under ITS `pkg#` prefix)". The first half was right and the second half
|
|
9615
|
+
// is now a REQUIREMENT rather than an aside: the entry belongs under the owner's prefix, so publish it
|
|
9616
|
+
// there instead of dropping it. Dropping is what makes a `dist` package that implements a dependency's
|
|
9617
|
+
// interface effectfully invisible to every consumer of that dependency — the measured R475 shape, in
|
|
9618
|
+
// the packaging every npm dependency actually ships.
|
|
9619
|
+
//
|
|
9620
|
+
// The owner is derived from the FILE (`declModule`), never from the module specifier a typing imports:
|
|
9621
|
+
// a name-based derivation is the cross-package leaf join this vein has produced confirmed fabrications
|
|
9622
|
+
// with, and `declModule` answers from the path.
|
|
9623
|
+
const ownerOfIface = (d) => {
|
|
9624
|
+
const f = d.getSourceFile().fileName;
|
|
9625
|
+
if (inPkg(f)) return pkgName;
|
|
9626
|
+
const m = declModule(d);
|
|
9627
|
+
return m && !m.startsWith("<") && !m.startsWith("/") && m !== pkgName && m !== rootOwnerPkg ? m : null;
|
|
9628
|
+
};
|
|
9272
9629
|
const ifaceDeclsOf = (typeExpr) => (deAlias(tck.getSymbolAtLocation(typeExpr))?.declarations ?? [])
|
|
9273
|
-
.filter((d) => ts.isInterfaceDeclaration(d) &&
|
|
9630
|
+
.filter((d) => ts.isInterfaceDeclaration(d) && !!ownerOfIface(d));
|
|
9274
9631
|
for (const root of roots) {
|
|
9275
9632
|
const tsf = tprog.getSourceFile(root);
|
|
9276
9633
|
const mod = tsf && tck.getSymbolAtLocation(tsf);
|
|
@@ -9301,10 +9658,16 @@ function typingsInterfaceImpls() {
|
|
|
9301
9658
|
}
|
|
9302
9659
|
}
|
|
9303
9660
|
}
|
|
9304
|
-
for (const [idecl, clsNames] of byIface) if (clsNames.length) out.push([idecl, clsNames]);
|
|
9661
|
+
for (const [idecl, clsNames] of byIface) if (clsNames.length) out.push([idecl, clsNames, ownerOfIface(idecl)]);
|
|
9305
9662
|
return { arms: out, truncated: false };
|
|
9306
9663
|
}
|
|
9307
|
-
|
|
9664
|
+
// ⟨0.39⟩ NO LONGER GATED. `interfaceUnion` rode behind CANDOR_WORKSPACE_CHAIN while §2 ⟨0.23⟩ read
|
|
9665
|
+
// "gated/opt-in until a floor rung pins it". This is that rung, it is REQUIRED, and its absence is a
|
|
9666
|
+
// non-conformance — and the gate is precisely WHY the silent-purity toggle survived default scans: a
|
|
9667
|
+
// default scan of a library published no union entry, so a default scan of its consumer had nothing to
|
|
9668
|
+
// join. Keeping the env var as an alias for "on" was considered and refused: a flag that no longer
|
|
9669
|
+
// changes anything is a flag the next reader has to prove inert.
|
|
9670
|
+
{
|
|
9308
9671
|
// rec.local -> {inferred:Set, blind:Set}, UNIONED over every unit sharing the tail rather than last-wins.
|
|
9309
9672
|
// A dist package is routinely built twice (`dist/cjs/…/CucumberExpression.js` and `dist/esm/…` are the
|
|
9310
9673
|
// same class), so a class name maps to several units; last-wins silently published ONE build's effects
|
|
@@ -9339,10 +9702,55 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
9339
9702
|
// not be — record whether any implementor was dropped, so the emission loop below can force the SAME
|
|
9340
9703
|
// honest-Unknown widening it already applies when CHA_FANOUT_LIMIT is exceeded, instead of a narrower
|
|
9341
9704
|
// claim than the evidence supports.
|
|
9705
|
+
//
|
|
9706
|
+
// ⟨SOUNDNESS R512⟩ THE NAME IS NOT THE ONLY HANDLE, AND TREATING IT AS ONE OVER-HEDGED. The paragraph
|
|
9707
|
+
// above is right that `localEffs` cannot answer for an implementor with no name; it is wrong that
|
|
9708
|
+
// nothing can. `localImplTargets` — obligation 3's LOCAL half, in this same rung — already resolves a
|
|
9709
|
+
// structural implementor's member PRECISELY, by finding the member node and reading `nodeName`, and the
|
|
9710
|
+
// in-scan dispatch site has always done the same. So the emitter now splits implementors by what can
|
|
9711
|
+
// ANSWER for them rather than by whether they have a name: a ClassDeclaration goes through `localEffs`
|
|
9712
|
+
// as before, and everything else (object literal, class expression — named or not) through its minted
|
|
9713
|
+
// member unit. `hadUnnamed`'s blanket Unknown survives, but only where the member really is
|
|
9714
|
+
// unaccountable, which is the same `allResolved` condition the join and the dispatch site use.
|
|
9715
|
+
//
|
|
9716
|
+
// This is NOT a cosmetic tightening. Under the blanket rule, R512's fix would have made every package
|
|
9717
|
+
// that writes `const plugin: SomeDepIface = { … }` — the idiomatic TS spelling, and the reason this row
|
|
9718
|
+
// exists — publish `dep#Iface.member -> ['Unknown']` whether or not the implementor does anything,
|
|
9719
|
+
// handing an inherited hedge to every consumer of it. ⟨0.39⟩'s cost model is explicit that nothing may
|
|
9720
|
+
// move from disclosed to silent AND that no implementation may start hedging; closing the silence by
|
|
9721
|
+
// flooding the other channel would have traded the sin for the thing the c3_pure_only control exists to
|
|
9722
|
+
// catch. It also unblocks the NAMED class EXPRESSION, which `.name?.text` classified as named while its
|
|
9723
|
+
// members are minted `<structural>.m` — so `localEffs` missed it and it contributed nothing at all.
|
|
9724
|
+
// ⟨0.39⟩ An arm is [ifaceDecl, implementing class NAMES, hadUnnamed, OWNING PACKAGE]. The fourth field
|
|
9725
|
+
// is obligation 2: a local abstraction's union is published under OUR package, a FOREIGN one's under
|
|
9726
|
+
// the package that declares it — fully qualified in that package's own entry-hash namespace, which is
|
|
9727
|
+
// the ⟨0.23⟩ `typeSurface` rule and NOT a new spelling. Everything downstream reads the owner off the
|
|
9728
|
+
// arm rather than assuming `pkgName`, because assuming it is what re-keys a dependency's abstraction
|
|
9729
|
+
// under ours and fabricates a key no consumer can resolve.
|
|
9730
|
+
// ⟨SOUNDNESS R512⟩ A ClassDeclaration is the ONLY implementor kind `localEffs` can answer for: its
|
|
9731
|
+
// members are minted under `${ClassName}.${member}`. Everything else `interfaceImpls` /
|
|
9732
|
+
// `foreignInterfaceImpls` hold — object literals and class expressions, named or anonymous — has its
|
|
9733
|
+
// members minted by `mintStructuralMembers` under `<structural>.${member}`, so a name lookup reads
|
|
9734
|
+
// nothing. Split on the MECHANISM, not on `.name`.
|
|
9735
|
+
const nominalName = (c) => (ts.isClassDeclaration(c) ? c.name?.text : undefined);
|
|
9736
|
+
const splitImpls = (impls) => ({
|
|
9737
|
+
names: impls.map(nominalName).filter(Boolean),
|
|
9738
|
+
nodes: impls.filter((c) => !nominalName(c)),
|
|
9739
|
+
});
|
|
9740
|
+
// The minted unit for `member` on a structural implementor, or undefined when nothing was minted for it
|
|
9741
|
+
// (a `.bind()` whose receiver cannot be pinned, a call result, a getter, an absent optional member).
|
|
9742
|
+
// Same shape as `localImplTargets`, deliberately — one question, one answer.
|
|
9743
|
+
const structuralMemberUnit = (container, m) => {
|
|
9744
|
+
const memberNodes = container.members ?? container.properties ?? [];
|
|
9745
|
+
const mn = memberNodes.find((x) =>
|
|
9746
|
+
(ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x) || ts.isPropertyAssignment(x))
|
|
9747
|
+
&& x.name?.getText?.() === m);
|
|
9748
|
+
return mn ? nodeName.get(mn) : undefined;
|
|
9749
|
+
};
|
|
9342
9750
|
const unionArms = [];
|
|
9343
9751
|
for (const [ifaceDecl, implClasses] of interfaceImpls) {
|
|
9344
|
-
const names
|
|
9345
|
-
unionArms.push([ifaceDecl, names,
|
|
9752
|
+
const { names, nodes } = splitImpls(implClasses);
|
|
9753
|
+
unionArms.push([ifaceDecl, names, nodes.length > 0, pkgName, nodes]);
|
|
9346
9754
|
}
|
|
9347
9755
|
const inScanClassesByName = new Map(); // iface NAME -> every class the in-scan arms register under it
|
|
9348
9756
|
for (const [d, cls] of unionArms) {
|
|
@@ -9379,9 +9787,26 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
9379
9787
|
for (const arm of typings.arms) {
|
|
9380
9788
|
const n = arm[0].name?.text;
|
|
9381
9789
|
if (!n) continue;
|
|
9382
|
-
|
|
9790
|
+
// ⟨0.39⟩ the SHADOW drop applies only to a LOCALLY-owned typings arm: the in-scan arm whose union it
|
|
9791
|
+
// would duplicate is keyed under `pkgName`, so it can be a superset of nothing under another prefix.
|
|
9792
|
+
const owner = arm[2] ?? pkgName;
|
|
9793
|
+
const inScan = owner === pkgName ? inScanClassesByName.get(n) : null;
|
|
9383
9794
|
if (inScan && arm[1].every((c) => inScan.has(c))) continue;
|
|
9384
|
-
unionArms.push(arm);
|
|
9795
|
+
unionArms.push([arm[0], arm[1], false, owner, []]);
|
|
9796
|
+
}
|
|
9797
|
+
// ⟨0.39⟩ obligation 2's arms, pushed LAST and deliberately AFTER `inScanClassesByName` was taken: a
|
|
9798
|
+
// foreign `Backend` and a local one are different keys under different prefixes, so neither may
|
|
9799
|
+
// suppress the other as "redundant" and neither may make the other's NAME ambiguous. Both mistakes run
|
|
9800
|
+
// in the withdrawal direction — a dropped union entry is a purity claim nobody made.
|
|
9801
|
+
for (const [ifaceDecl, implClasses] of foreignInterfaceImpls) {
|
|
9802
|
+
// The registration sites already refuse every declaration `isPublishableForeignIface` rejects, so
|
|
9803
|
+
// this is a belt-and-braces re-derivation of the SAME predicate rather than a second rule: an entry
|
|
9804
|
+
// keyed under a namespace no package owns is the invented second spelling §4 forbids, and this
|
|
9805
|
+
// family has now shipped that key twice (rust `io#Write::write_all`, swift `DepLib#String.lowercased`).
|
|
9806
|
+
if (!isPublishableForeignIface(ifaceDecl)) continue;
|
|
9807
|
+
const ownerPkg = declModule(ifaceDecl);
|
|
9808
|
+
const { names, nodes } = splitImpls(implClasses);
|
|
9809
|
+
unionArms.push([ifaceDecl, names, nodes.length > 0, ownerPkg, nodes]);
|
|
9385
9810
|
}
|
|
9386
9811
|
// A TRUNCATED typings census refuses the PUBLICATION, and it has to be here rather than at the census.
|
|
9387
9812
|
// Dropping the typings arm on its own lands the refusal on the EVIDENCE side — and the evidence is the
|
|
@@ -9402,23 +9827,36 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
9402
9827
|
if (typings.truncated)
|
|
9403
9828
|
console.error(`candor-ts: this package's typings census exceeded ${TYPINGS_CENSUS_CAP} declaration files — `
|
|
9404
9829
|
+ `publishing NO interface-CHA union entries (an incomplete census cannot tell a colliding interface name from a unique one)`);
|
|
9830
|
+
// ⟨0.39⟩ COUNTED PER KEY, not per bare NAME. The guard's question is "could a consumer forming this
|
|
9831
|
+
// hash mean a different declaration" — and a hash carries the owning package, so `iface#Backend` and
|
|
9832
|
+
// `effimpl#Backend` cannot be confused with one another however alike they read. Counting bare names
|
|
9833
|
+
// once obligation 2 exists would refuse BOTH over a collision that the key itself already resolves,
|
|
9834
|
+
// and a refusal is a withdrawn disclosure.
|
|
9405
9835
|
const ifaceNameCounts = new Map();
|
|
9406
|
-
for (const [ifaceDecl] of unionArms) {
|
|
9836
|
+
for (const [ifaceDecl, , , ownerPkg] of unionArms) {
|
|
9407
9837
|
const n = ifaceDecl.name?.text;
|
|
9408
|
-
if (n) ifaceNameCounts.set(n
|
|
9838
|
+
if (n) ifaceNameCounts.set(`${ownerPkg}#${n}`, (ifaceNameCounts.get(`${ownerPkg}#${n}`) ?? 0) + 1);
|
|
9409
9839
|
}
|
|
9410
|
-
for (const [ifaceDecl, implClasses, hadUnnamed] of unionArms) {
|
|
9840
|
+
for (const [ifaceDecl, implClasses, hadUnnamed, ownerPkg, implNodes = []] of unionArms) {
|
|
9411
9841
|
const ifaceName = ifaceDecl.name?.text;
|
|
9412
9842
|
// ⟨CARDINAL SIN FIX, structural-implementor gap⟩ `!implClasses.length` used to skip the arm outright
|
|
9413
9843
|
// — correct when there are genuinely zero implementors, but an interface implemented ONLY
|
|
9414
9844
|
// structurally (every implementor unnamed, `implClasses` empty, `hadUnnamed` true) would silently
|
|
9415
9845
|
// publish NO union entry at all, which is a purity claim (SPEC §2 rule 3) this evidence does not
|
|
9416
|
-
// support. `hadUnnamed` keeps the arm alive for that case so the
|
|
9417
|
-
//
|
|
9846
|
+
// support. `hadUnnamed` keeps the arm alive for that case so the emission loop below can answer for
|
|
9847
|
+
// it at all, instead of the arm vanishing before anything is computed. ⟨SOUNDNESS R512⟩ what it then
|
|
9848
|
+
// answers is the member's real effects where they can be read and a disclosed Unknown where they
|
|
9849
|
+
// cannot — this predicate is unchanged, only what happens after it.
|
|
9418
9850
|
if (!ifaceName || (!implClasses.length && !hadUnnamed)) continue;
|
|
9419
9851
|
// Never guess which `I` a name means: two declarations of it, or a census that cannot prove there is
|
|
9420
9852
|
// only one, are the same evidential position and take the same answer.
|
|
9421
|
-
|
|
9853
|
+
// ⟨0.39⟩ …and a TRUNCATED census refuses only the keys it is evidence about. The census walks OUR
|
|
9854
|
+
// package's `.d.ts` tree, so the declarations it failed to reach could each be a second local
|
|
9855
|
+
// `Store`; none of them can be a second declaration inside a DEPENDENCY's namespace, which is where
|
|
9856
|
+
// a foreign key lives. Letting our own truncation withdraw a foreign union entry would refuse on
|
|
9857
|
+
// evidence that says nothing about it — and the owning package's own report publishes under that
|
|
9858
|
+
// same key anyway, where ⟨0.25⟩'s per-key union combines the two.
|
|
9859
|
+
if (ifaceNameCounts.get(`${ownerPkg}#${ifaceName}`) > 1 || (typings.truncated && ownerPkg === pkgName)) continue;
|
|
9422
9860
|
// BOUNDED CHA — the same `CHA_FANOUT_LIMIT` the in-scan dispatch site applies. The union emitter
|
|
9423
9861
|
// shipped without it, so the producer PUBLISHED what its own dispatch refuses to resolve: rxjs's
|
|
9424
9862
|
// `Operator` has 70 implementers, sixteen of which reach Net, and rxjs's own `Observable.subscribe`
|
|
@@ -9434,9 +9872,14 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
9434
9872
|
// key. What silence would cost is this report's own honesty — the producer's `deny E
|
|
9435
9873
|
// Unknown[dispatch]`, any consumer without half 1's conjuncts, and the entry that is read as data
|
|
9436
9874
|
// rather than joined. The named tests that fail on that mutation are the producer-side three.
|
|
9437
|
-
// `hadUnnamed` widens the same way
|
|
9438
|
-
//
|
|
9439
|
-
|
|
9875
|
+
// `hadUnnamed` widens the same way — but only where the implementor really is unaccountable. See
|
|
9876
|
+
// ⟨SOUNDNESS R512⟩ above: a structural implementor's member that resolves to a MINTED UNIT is
|
|
9877
|
+
// accounted for exactly as precisely as a named class's, by the same `nodeName` lookup obligation 3's
|
|
9878
|
+
// `localImplTargets` and the in-scan dispatch site both use, so the widening is decided per MEMBER
|
|
9879
|
+
// (`unaccounted` below) and not per arm. The fan-out bound counts every implementor, named or not:
|
|
9880
|
+
// once an unnamed one contributes real effects it has to be inside the bound that decides whether
|
|
9881
|
+
// this hierarchy is open.
|
|
9882
|
+
const overFanout = implClasses.length + implNodes.length > CHA_FANOUT_LIMIT;
|
|
9440
9883
|
|
|
9441
9884
|
for (const member of ifaceDecl.members ?? []) {
|
|
9442
9885
|
// Both spellings of an interface method (see the in-scan site): `run(): void` and
|
|
@@ -9448,13 +9891,27 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
9448
9891
|
if (!member.name || !fnMember) continue;
|
|
9449
9892
|
const m = member.name.getText();
|
|
9450
9893
|
const infU = new Set(), blindU = new Set();
|
|
9451
|
-
|
|
9452
|
-
|
|
9453
|
-
|
|
9454
|
-
|
|
9894
|
+
// An implementor this arm holds but cannot read a member unit for — the `allResolved` condition,
|
|
9895
|
+
// per member. A structural implementor with no minted unit for `m` (a `.bind()` whose receiver
|
|
9896
|
+
// cannot be pinned, a call result, a getter, an absent optional member) is exactly as unaccountable
|
|
9897
|
+
// as the (CHA_FANOUT_LIMIT + 1)th named one, and takes the same disclosed Unknown.
|
|
9898
|
+
let unaccounted = false;
|
|
9899
|
+
if (overFanout) infU.add("Unknown");
|
|
9900
|
+
else {
|
|
9901
|
+
for (const clsName of implClasses) {
|
|
9902
|
+
const e = localEffs.get(`${clsName}.${m}`);
|
|
9903
|
+
if (e) { for (const x of e.inferred) infU.add(x); for (const b of e.blind) blindU.add(b); }
|
|
9904
|
+
}
|
|
9905
|
+
for (const impl of implNodes) {
|
|
9906
|
+
const u = structuralMemberUnit(impl, m);
|
|
9907
|
+
if (!u) { unaccounted = true; infU.add("Unknown"); continue; }
|
|
9908
|
+
for (const x of inferred.get(u) ?? []) infU.add(x);
|
|
9909
|
+
for (const b of fns.get(u)?.blind ?? []) blindU.add(b);
|
|
9910
|
+
}
|
|
9455
9911
|
}
|
|
9912
|
+
const broad = overFanout || unaccounted;
|
|
9456
9913
|
if (infU.size === 0 && blindU.size === 0) continue; // pure across all impls — silence = purity
|
|
9457
|
-
const hash =
|
|
9914
|
+
const hash = dispatchKey(ownerPkg, ifaceName, m); // ⟨0.39⟩ the OWNER's namespace, not always ours
|
|
9458
9915
|
if (emittedUnionHashes.has(hash)) continue;
|
|
9459
9916
|
// A REAL entry already claiming this hash used to SUPPRESS the union, and that was a silent
|
|
9460
9917
|
// under-report — the candor-java sibling is `48a5f18`, whose argument transfers whole: publishing
|
|
@@ -9492,7 +9949,7 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
9492
9949
|
emittedUnionHashes.add(hash);
|
|
9493
9950
|
const sfIface = ifaceDecl.getSourceFile();
|
|
9494
9951
|
const { line, character } = sfIface.getLineAndCharacterOfPosition(ifaceDecl.getStart());
|
|
9495
|
-
const un = { fn: `${ifaceName}.${m}`, loc:
|
|
9952
|
+
const un = { fn: `${ifaceName}.${m}`, loc: abstractionLoc(sfIface, line, character),
|
|
9496
9953
|
hash, inferred: [...infU].sort(), interfaceUnion: true };
|
|
9497
9954
|
// The reason travels with the disclosure, spelled the way the CONSUMER of this entry spells the
|
|
9498
9955
|
// same site (`dispatch:<pkg>.<Iface>.<member>`, half 1's form), so a `deny E Unknown[dispatch]` at
|
|
@@ -9508,7 +9965,7 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
|
|
|
9508
9965
|
// stays scoped to `broad`, and correctly: ⟨0.6⟩ requires `unknownWhy` on a DIRECT Unknown source, and
|
|
9509
9966
|
// a union that inherited its Unknown from an implementer's body is not one.
|
|
9510
9967
|
un.unresolved = infU.has("Unknown");
|
|
9511
|
-
if (broad) un.unknownWhy = [`dispatch:${
|
|
9968
|
+
if (broad) un.unknownWhy = [`dispatch:${ownerPkg}.${ifaceName}.${m}`];
|
|
9512
9969
|
if (blindU.size) un.invisible = [...blindU].sort();
|
|
9513
9970
|
functions.push(un);
|
|
9514
9971
|
}
|
|
@@ -10805,6 +11262,9 @@ if (policyPath !== null) {
|
|
|
10805
11262
|
for (const name of fns.keys()) { const h = unitHash(name); if (h) gateHashByName.set(name, h); }
|
|
10806
11263
|
const gateOut = evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners,
|
|
10807
11264
|
null, null, null, gateHashByName);
|
|
11265
|
+
// ⟨0.39⟩ the synthetic entries a rule matched — named, never scored. Same printer on both verdict
|
|
11266
|
+
// routes, so §3.1's byte-equality with `gate --report` covers the note too.
|
|
11267
|
+
noteSyntheticHits(gateOut);
|
|
10808
11268
|
// ⟨0.27⟩ SPEC §4 — a rule that bound NO function is disclosed, never scored as satisfied. The exit
|
|
10809
11269
|
// code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
|
|
10810
11270
|
// repositories and a layer exists in only some of them, so refusal would make a shared policy
|