candor-ts 0.14.0 → 0.15.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 +5 -1
- package/package.json +2 -2
- package/query-core.mjs +51 -0
- package/query.mjs +10 -3
- package/scan.mjs +261 -8
package/AGENTS.md
CHANGED
|
@@ -12,7 +12,7 @@ chains by hand.
|
|
|
12
12
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
13
13
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
14
14
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
15
|
-
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.15)."*
|
|
16
16
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
17
17
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
18
18
|
>
|
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
184
184
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
185
185
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
186
186
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
187
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
187
|
+
| `{ candor: { version, toolchain, spec: "0.15" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
188
188
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
189
189
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
190
190
|
|
|
@@ -202,7 +202,7 @@ read the Rust source".
|
|
|
202
202
|
|
|
203
203
|
## Status
|
|
204
204
|
|
|
205
|
-
0.
|
|
205
|
+
0.15.x, speaking candor-spec 0.15: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
206
206
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
207
207
|
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
208
208
|
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
package/mcp.mjs
CHANGED
|
@@ -291,8 +291,12 @@ const TOOLS = {
|
|
|
291
291
|
// ⟨spec 0.12 staged⟩ baseline callgraph → byFunction[].origin, same as the CLI (parity). The
|
|
292
292
|
// loader's non-enumerable `partial` tag rides along: a corrupt baseline sidecar (edges dropped,
|
|
293
293
|
// disclosed) downgrades origin to "unknown", never a fabricated "new" over a truncated graph.
|
|
294
|
+
// ⟨0.15 staged⟩ coverage disclosure — the SAME gainsCoverage the CLI verb spreads (the parity
|
|
295
|
+
// rule): optional `coverage` (current envelope's ledger) + `coverageDelta` (baseline names
|
|
296
|
+
// differ), both omitted when nothing applies — no other field of the tool result changes.
|
|
294
297
|
return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
|
|
295
|
-
...Q.gains(loadReportLoud(p), loadReportLoud(b), Q.loadCallgraph(b))
|
|
298
|
+
...Q.gains(loadReportLoud(p), loadReportLoud(b), Q.loadCallgraph(b)),
|
|
299
|
+
...Q.gainsCoverage(p, b) };
|
|
296
300
|
},
|
|
297
301
|
},
|
|
298
302
|
candor_activity: {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.15)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/query-core.mjs
CHANGED
|
@@ -124,6 +124,57 @@ function packagesLabel(pkgs) {
|
|
|
124
124
|
return first.slice(0, n).join(".");
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** ⟨0.15 staged⟩ the report's §2 `coverage` envelope field (COVERAGE-DESIGN.md §1) — the κ ledger of
|
|
128
|
+
* packages whose effects were INVISIBLE to the scan (absent, NOT a claim they're pure). Returns the
|
|
129
|
+
* normalized uncovered list [{name, calls}] (multi-report siblings merged, counts summed, sorted the
|
|
130
|
+
* producer's way: count desc, name asc), or null when absent/empty — the pre-0.15 report and the
|
|
131
|
+
* fully-covered report look identical here, and null keeps the consumer's output field OMITTED
|
|
132
|
+
* (never a fabricated `coverage: []` claim over a report that never carried the field). */
|
|
133
|
+
export function reportCoverage(prefix) {
|
|
134
|
+
const files = fs.existsSync(`${prefix}.json`) ? [`${prefix}.json`] : siblings(prefix, isReport);
|
|
135
|
+
const merged = new Map();
|
|
136
|
+
for (const f of files) {
|
|
137
|
+
try {
|
|
138
|
+
const unc = JSON.parse(fs.readFileSync(f, "utf8"))?.coverage?.uncovered;
|
|
139
|
+
if (!Array.isArray(unc)) continue; // absent/malformed field → contributes nothing (§2 forward-compat)
|
|
140
|
+
for (const e of unc) {
|
|
141
|
+
// Tolerate a foreign/hand-edited entry: a string `name` is required; a non-numeric `calls`
|
|
142
|
+
// counts as 0 (the entry still NAMES the blind spot — dropping it would under-disclose).
|
|
143
|
+
if (e && typeof e === "object" && typeof e.name === "string" && e.name) {
|
|
144
|
+
const n = typeof e.calls === "number" && Number.isFinite(e.calls) ? e.calls : 0;
|
|
145
|
+
merged.set(e.name, (merged.get(e.name) ?? 0) + n);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch { /* unreadable sibling — the reportVersion posture: keep looking */ }
|
|
149
|
+
}
|
|
150
|
+
if (merged.size === 0) return null;
|
|
151
|
+
return [...merged.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
152
|
+
.map(([name, calls]) => ({ name, calls }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** ⟨0.15 staged⟩ gains' coverage disclosure (COVERAGE-DESIGN.md §3) — the OPTIONAL blocks the gains
|
|
156
|
+
* JSON carries, computed from the two reports' envelopes. ONE code path for the CLI verb and the MCP
|
|
157
|
+
* `candor_gains` tool (the parity rule). Returns a spreadable object:
|
|
158
|
+
* · `coverage: {uncovered:[{name,calls}]}` — the CURRENT report's ledger, when non-empty (a gained
|
|
159
|
+
* effect in an uncovered dep is invisible, so "no gains" must not read as total);
|
|
160
|
+
* · `coverageDelta: {nowUncovered:[name], noLongerUncovered:[name]}` — whenever the two ledgers
|
|
161
|
+
* NAME different packages (a dep becoming uncovered between scans is itself a signal). The field
|
|
162
|
+
* names are the java reference engine's exactly (cross-engine wire parity). Keyed on names, not
|
|
163
|
+
* counts: a call-count wobble is ordinary code change, a new blind package is the alarm.
|
|
164
|
+
* Both omitted when nothing applies — a coverage-free comparison is byte-identical to ⟨0.14⟩. */
|
|
165
|
+
export function gainsCoverage(curPrefix, basePrefix) {
|
|
166
|
+
const cur = reportCoverage(curPrefix);
|
|
167
|
+
const base = reportCoverage(basePrefix);
|
|
168
|
+
const out = {};
|
|
169
|
+
if (cur) out.coverage = { uncovered: cur };
|
|
170
|
+
const curNames = new Set((cur ?? []).map((e) => e.name));
|
|
171
|
+
const baseNames = new Set((base ?? []).map((e) => e.name));
|
|
172
|
+
const nowUncovered = [...curNames].filter((n) => !baseNames.has(n)).sort();
|
|
173
|
+
const noLongerUncovered = [...baseNames].filter((n) => !curNames.has(n)).sort();
|
|
174
|
+
if (nowUncovered.length || noLongerUncovered.length) out.coverageDelta = { nowUncovered, noLongerUncovered };
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
127
178
|
// The returned array carries a non-enumerable `hardFail` flag: true iff a report file was FOUND but
|
|
128
179
|
// yielded NO trustworthy functions — a parse failure OR a malformed shape (a `null`/array/wrong-typed
|
|
129
180
|
// doc, a non-array `functions`, all-junk entries). The loud CLI wrapper (loadReportOrDie) needs it to
|
package/query.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
38
38
|
containment as coreContainment, diff as coreDiff,
|
|
39
39
|
where as coreWhere, map as coreMap, whatif as coreWhatif,
|
|
40
40
|
fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
|
|
41
|
-
matches as coreMatches,
|
|
41
|
+
matches as coreMatches, gainsCoverage,
|
|
42
42
|
loadReport, loadCallgraph, reportVersion, reportPackage } from "./query-core.mjs";
|
|
43
43
|
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
44
44
|
|
|
@@ -94,7 +94,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
94
94
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
95
95
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
96
96
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
97
|
-
const SPEC_VERSION = "0.
|
|
97
|
+
const SPEC_VERSION = "0.15";
|
|
98
98
|
|
|
99
99
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
100
100
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -571,7 +571,14 @@ switch (cmd) {
|
|
|
571
571
|
// a MISSING sidecar loads {} and a corrupt (matched-but-unparseable) one is tagged `partial`
|
|
572
572
|
// with its edges dropped-and-disclosed: either way "new" is unavailable and origin falls back
|
|
573
573
|
// to "unknown" — the JSON itself discloses, never guessing "new" over a truncated graph.
|
|
574
|
-
|
|
574
|
+
// ⟨0.15 staged⟩ coverage disclosure (COVERAGE-DESIGN.md §3): the CURRENT report's `coverage`
|
|
575
|
+
// envelope rides along (a gained effect in an uncovered dep is invisible — "no gains" must not
|
|
576
|
+
// read as total), plus `coverageDelta` when the baseline names different blind packages. Both
|
|
577
|
+
// OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
|
|
578
|
+
// Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
|
|
579
|
+
emit({ baseline_version: gbv ?? "", engine_version: gv ?? "",
|
|
580
|
+
...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)),
|
|
581
|
+
...gainsCoverage(curPrefix, basePrefix) });
|
|
575
582
|
break;
|
|
576
583
|
}
|
|
577
584
|
case "path": {
|
package/scan.mjs
CHANGED
|
@@ -41,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
41
41
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
42
42
|
// Reused, never re-littered.
|
|
43
43
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
44
|
-
const SPEC_VERSION = "0.
|
|
44
|
+
const SPEC_VERSION = "0.15";
|
|
45
45
|
|
|
46
46
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
47
47
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -477,9 +477,122 @@ function programHeadLiteral(node) {
|
|
|
477
477
|
// options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
|
|
478
478
|
// are considered; returns null when the URL slot is not a static string literal — the safe direction.
|
|
479
479
|
const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
|
|
480
|
+
// CONST-STRING PROPAGATION (java constant-inlining parity): resolve a bare identifier that references a
|
|
481
|
+
// `const NAME = "literal"` string to its literal value, and ONLY then. Returns the string, or null. The
|
|
482
|
+
// soundness rule is strict: resolve ONLY when EVERY value-declaration of the symbol is an immutable
|
|
483
|
+
// `const` (or a `readonly` field) whose initializer is a plain string literal. A `let`/`var` (reassignable),
|
|
484
|
+
// a declaration with no string-literal initializer (runtime value, function result, env read, config field,
|
|
485
|
+
// concatenation, another template), or a symbol with MORE than the string-literal decls we can see → null,
|
|
486
|
+
// so the call stays bare/runtime as before. NEVER guess a value we cannot read off a `const` initializer.
|
|
487
|
+
function constStringValue(expr) {
|
|
488
|
+
if (!ts.isIdentifier(expr)) return null;
|
|
489
|
+
const sym = checker.getSymbolAtLocation(expr);
|
|
490
|
+
const decls = sym?.declarations ?? [];
|
|
491
|
+
if (decls.length === 0) return null;
|
|
492
|
+
let resolved = null;
|
|
493
|
+
for (const d of decls) {
|
|
494
|
+
// A `const x = "..."` variable declaration, or a `readonly x = "..."` class/property field. Both are
|
|
495
|
+
// VariableDeclaration/PropertyDeclaration nodes with an initializer; the immutability gate differs.
|
|
496
|
+
if (ts.isVariableDeclaration(d)) {
|
|
497
|
+
// the enclosing VariableDeclarationList must be `const` — a `let`/`var` can be reassigned later.
|
|
498
|
+
const list = d.parent;
|
|
499
|
+
const isConst = list && ts.isVariableDeclarationList(list)
|
|
500
|
+
&& (list.flags & ts.NodeFlags.Const) !== 0;
|
|
501
|
+
if (!isConst || !d.initializer || !ts.isStringLiteral(d.initializer)) return null;
|
|
502
|
+
if (resolved != null && resolved !== d.initializer.text) return null; // conflicting decls — bail
|
|
503
|
+
resolved = d.initializer.text;
|
|
504
|
+
} else if (ts.isPropertyDeclaration(d)) {
|
|
505
|
+
const isReadonly = (ts.getCombinedModifierFlags(d) & ts.ModifierFlags.Readonly) !== 0;
|
|
506
|
+
if (!isReadonly || !d.initializer || !ts.isStringLiteral(d.initializer)) return null;
|
|
507
|
+
if (resolved != null && resolved !== d.initializer.text) return null;
|
|
508
|
+
resolved = d.initializer.text;
|
|
509
|
+
} else {
|
|
510
|
+
return null; // any other declaration shape (function, param, import alias, …) → do not resolve
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return resolved;
|
|
514
|
+
}
|
|
515
|
+
// Resolve a URL ARGUMENT EXPRESSION to a statically-known URL/host string when its HOST is anchored by a
|
|
516
|
+
// `const NAME = "literal"` string (java constant-inlining parity). Three shapes, all requiring the host to
|
|
517
|
+
// live at the HEAD of the value:
|
|
518
|
+
// • a bare const identifier fetch(API_BASE) → API_BASE's value
|
|
519
|
+
// • a template whose HEAD is a const fetch(`${API_BASE}/chat`) → value + the literal template tail
|
|
520
|
+
// • a concat whose LEFT is a const fetch(API_BASE + "/chat") → value + the right literal
|
|
521
|
+
// The template tail / concat right are appended ONLY when they are themselves plain literals, so the
|
|
522
|
+
// returned string is a real static URL prefix `hostLiteral` can parse (`https://host/…`). A template with a
|
|
523
|
+
// literal host-bearing PREFIX before the interpolation (`\`https://${h}\``) has a non-empty template HEAD, so
|
|
524
|
+
// its head is NOT a const identifier → not resolved here (and the literal prefix alone never named a full
|
|
525
|
+
// host). Anything else (non-const identifier, interpolation of a runtime value, nested template) → null.
|
|
526
|
+
function resolveConstUrlString(expr) {
|
|
527
|
+
if (expr == null) return null;
|
|
528
|
+
// bare identifier: fetch(API_BASE)
|
|
529
|
+
const bare = constStringValue(expr);
|
|
530
|
+
if (bare != null) return bare;
|
|
531
|
+
// template literal `${HEAD_CONST}<tail literal>`: the const value must sit at the HEAD (empty template
|
|
532
|
+
// head text), and we may only append a SINGLE trailing literal span — a second `${…}` interpolation is a
|
|
533
|
+
// runtime value we will not resolve, but it only follows the host, so the host prefix is still sound.
|
|
534
|
+
if (ts.isTemplateExpression(expr)) {
|
|
535
|
+
if (expr.head.text !== "") return null; // literal prefix before the const → not const-anchored
|
|
536
|
+
const first = expr.templateSpans[0];
|
|
537
|
+
const head = first && constStringValue(first.expression);
|
|
538
|
+
if (head == null) return null; // first interpolation is not a const string
|
|
539
|
+
// append the literal text between the first interpolation and the next (or end) — the URL path segment.
|
|
540
|
+
return head + (first.literal.text ?? "");
|
|
541
|
+
}
|
|
542
|
+
// string concat `CONST + "…"`: left must be a const string; append the right ONLY if it is a plain literal.
|
|
543
|
+
if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
544
|
+
const left = constStringValue(expr.left);
|
|
545
|
+
if (left == null) return null;
|
|
546
|
+
const right = ts.isStringLiteralLike(expr.right) ? expr.right.text : "";
|
|
547
|
+
return left + right;
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
// Does a literal URL-head string already contain a COMPLETE authority — i.e. is there a `/` AFTER the
|
|
552
|
+
// `://` still WITHIN the literal text? `https://api.openai.com/v1/` → yes (host fully present, only the
|
|
553
|
+
// PATH follows); `https://api.` / `https://` / `https://api.openai.com:` → no (the authority is not yet
|
|
554
|
+
// terminated, so an interpolation could still be part of the host/port). Requires a `scheme://` prefix;
|
|
555
|
+
// a bare relative path never qualifies.
|
|
556
|
+
function literalHeadCompletesAuthority(head) {
|
|
557
|
+
const m = head.match(/^[a-z][a-z0-9+.-]*:\/\//i);
|
|
558
|
+
if (!m) return false; // no scheme://… → authority not started in the literal
|
|
559
|
+
return head.indexOf("/", m[0].length) >= 0; // a `/` after the `://` terminates the authority
|
|
560
|
+
}
|
|
561
|
+
// LITERAL-HEAD HOST EXTRACTION (java literal-inlining parity): a template `\`https://host/${path}\`` or a
|
|
562
|
+
// concat `"https://host/" + path` whose FIRST STATIC segment (the text before the first interpolation /
|
|
563
|
+
// the concat's left literal) ALREADY contains a complete `scheme://authority/…` carries a statically-known
|
|
564
|
+
// host — the interpolation is only in the PATH. Return that literal head (a real URL prefix `hostLiteral`
|
|
565
|
+
// parses to the authority). If the head does NOT terminate the authority with a `/` (`https://${h}/x`,
|
|
566
|
+
// `https://api.${x}.com/y`, `https://host:${port}/y`, `https://api.openai${x}/v1`) the interpolation could
|
|
567
|
+
// be part of the host/port → return null (safe under-report: stays bare Net). Distinct from
|
|
568
|
+
// resolveConstUrlString, which anchors on a CONST identifier at the head; here the head is a plain LITERAL.
|
|
569
|
+
function literalHeadHostUrl(expr) {
|
|
570
|
+
if (expr == null) return null;
|
|
571
|
+
// template `\`<head>${…}…\``: the literal head is expr.head.text (empty when the interpolation leads).
|
|
572
|
+
if (ts.isTemplateExpression(expr)) {
|
|
573
|
+
const head = expr.head.text;
|
|
574
|
+
return literalHeadCompletesAuthority(head) ? head : null;
|
|
575
|
+
}
|
|
576
|
+
// concat `"<left literal>" + <anything>`: only the LEFT operand's literal text is the static head; the
|
|
577
|
+
// right is a runtime value living in the path. (A nested `"a" + "b" + x` left is a BinaryExpression, not
|
|
578
|
+
// a string literal, so it is not read here — a safe under-report, not a fabrication.)
|
|
579
|
+
if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken
|
|
580
|
+
&& ts.isStringLiteralLike(expr.left)) {
|
|
581
|
+
const head = expr.left.text;
|
|
582
|
+
return literalHeadCompletesAuthority(head) ? head : null;
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
480
586
|
function urlArgLiteral(node, member) {
|
|
481
587
|
const args = node.arguments ?? [];
|
|
482
|
-
const litAt = (i) =>
|
|
588
|
+
const litAt = (i) => {
|
|
589
|
+
const a = args[i];
|
|
590
|
+
if (!a) return null;
|
|
591
|
+
if (ts.isStringLiteralLike(a)) return a.text;
|
|
592
|
+
// const-anchored host (fetch(API_BASE), `${API_BASE}/x`, API_BASE+"/x"), THEN literal-head extraction
|
|
593
|
+
// (`\`https://host/${p}\``, `"https://host/" + p`) when the literal head already completes the authority.
|
|
594
|
+
return resolveConstUrlString(a) ?? literalHeadHostUrl(a);
|
|
595
|
+
};
|
|
483
596
|
if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
|
|
484
597
|
return litAt(0);
|
|
485
598
|
}
|
|
@@ -1114,9 +1227,34 @@ function moduleUnit(sf) {
|
|
|
1114
1227
|
}
|
|
1115
1228
|
return qual;
|
|
1116
1229
|
}
|
|
1230
|
+
// The synthesized static-initializer unit for a `class C { static { … } }` block (spec §2 unitKind
|
|
1231
|
+
// "initializer"). A static block runs at class-DEFINITION time, not instance construction — but its
|
|
1232
|
+
// body's effects otherwise walked up in `enclosing` to the ClassDeclaration, which maps to the
|
|
1233
|
+
// `C.constructor` unit, so a static-init effect was MISLABELED as the instance ctor (and carried no
|
|
1234
|
+
// unitKind). Mint it as its own unit, lazily, mirroring `moduleUnit`. (An anonymous class expression's
|
|
1235
|
+
// static block keys under `<anonymous>`; there is at most one static-init unit per class name.)
|
|
1236
|
+
function staticBlockUnit(node) {
|
|
1237
|
+
const cls = node.parent;
|
|
1238
|
+
const sf = node.getSourceFile();
|
|
1239
|
+
const mod = moduleOf(sf);
|
|
1240
|
+
const cname = (ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) && cls.name ? cls.name.text : "<anonymous>";
|
|
1241
|
+
const qual = `${mod}.${cname}.<static-init>`;
|
|
1242
|
+
let rec = fns.get(qual);
|
|
1243
|
+
if (!rec) {
|
|
1244
|
+
rec = { local: "<static-init>", direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
1245
|
+
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
|
|
1246
|
+
entry: false, unitKind: "initializer",
|
|
1247
|
+
loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1` };
|
|
1248
|
+
fns.set(qual, rec);
|
|
1249
|
+
}
|
|
1250
|
+
return qual;
|
|
1251
|
+
}
|
|
1117
1252
|
// nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
|
|
1118
1253
|
function enclosing(node) {
|
|
1119
1254
|
for (let p = node; p; p = p.parent) {
|
|
1255
|
+
// A `static { … }` block is its own initializer unit (class-definition time), NOT the instance ctor
|
|
1256
|
+
// the ClassDeclaration maps to — intercept before the nodeName lookup would fold it into .constructor.
|
|
1257
|
+
if (ts.isClassStaticBlockDeclaration(p)) return staticBlockUnit(p);
|
|
1120
1258
|
// A call/effect lexically inside a DECORATOR (`@factory(arg)`) runs at class-DEFINITION time, NOT in
|
|
1121
1259
|
// the decorated declaration's body. The parent chain of a decorator's expression is
|
|
1122
1260
|
// CallExpression → Decorator → MethodDeclaration/ClassDeclaration/Parameter, so `enclosing` otherwise
|
|
@@ -1339,6 +1477,83 @@ const HOF_INVOKERS = new Set([
|
|
|
1339
1477
|
"then", "catch", "finally", "nextTick",
|
|
1340
1478
|
]);
|
|
1341
1479
|
|
|
1480
|
+
// ---- process.env recognition: the direct dot access (`process.env.KEY`) is the JVM System.getenv twin,
|
|
1481
|
+
// but the same environment READ is spelled several other ways that all read silent-pure without help:
|
|
1482
|
+
// bracket access (`process.env[k]`), a local const-alias (`const env = process.env; env.KEY`),
|
|
1483
|
+
// destructuring (`const {KEY} = process.env`), and the `in` operator (`"KEY" in process.env`). Each of
|
|
1484
|
+
// these on process.env (or a confirmed direct alias of it) is Env. SOUNDNESS: only process.env and a
|
|
1485
|
+
// DIRECT `x = process.env` / `const {env} = process` binding trigger — a bracket/alias/destructure/`in`
|
|
1486
|
+
// on any OTHER object stays pure (no fabrication), and a reassigned alias local is cleared.
|
|
1487
|
+
//
|
|
1488
|
+
// `process` here must be Node's process object, NOT a project-local `const process = {…}` shadow
|
|
1489
|
+
// (mirrors the process.hrtime/send guard). It qualifies when it is the ambient GLOBAL (no project
|
|
1490
|
+
// declaration) OR a default-import of the `node:process` builtin (`import process from 'node:process'`,
|
|
1491
|
+
// as chalk's supports-color does) — the two are the same object.
|
|
1492
|
+
const declImportsNodeProcess = (decl) => {
|
|
1493
|
+
// ImportClause default binding or a namespace/named import from 'node:process' | 'process'.
|
|
1494
|
+
let spec = null;
|
|
1495
|
+
if (ts.isImportClause(decl) && decl.parent && ts.isImportDeclaration(decl.parent)) spec = decl.parent.moduleSpecifier;
|
|
1496
|
+
else if (ts.isImportSpecifier(decl)) spec = decl.parent?.parent?.parent?.moduleSpecifier;
|
|
1497
|
+
else if (ts.isNamespaceImport(decl)) spec = decl.parent?.parent?.moduleSpecifier;
|
|
1498
|
+
const text = spec && ts.isStringLiteral(spec) ? spec.text : null;
|
|
1499
|
+
return text === "node:process" || text === "process";
|
|
1500
|
+
};
|
|
1501
|
+
const identIsGlobalProcess = (id) => {
|
|
1502
|
+
if (!ts.isIdentifier(id) || id.text !== "process") return false;
|
|
1503
|
+
const decls = checker.getSymbolAtLocation(id)?.declarations ?? [];
|
|
1504
|
+
if (decls.some(declImportsNodeProcess)) return true; // `import process from 'node:process'`
|
|
1505
|
+
return !decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))); // else the ambient global
|
|
1506
|
+
};
|
|
1507
|
+
// `process.env` as an expression (PropertyAccess `process.env` where `process` is the global).
|
|
1508
|
+
const isProcessEnvExpr = (expr) =>
|
|
1509
|
+
expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
|
|
1510
|
+
|
|
1511
|
+
// The set of local-binding SYMBOLS that alias process.env — collected below, one pre-pass over the
|
|
1512
|
+
// sources. A symbol lands here iff its ONLY initializer/assignment is `= process.env` (a reassignment
|
|
1513
|
+
// to anything else removes it → the alias is cleared, per the spec's reassignment rule).
|
|
1514
|
+
const envAliasSymbols = new Set();
|
|
1515
|
+
{
|
|
1516
|
+
const aliasCandidates = new Set(); // symbol -> declared `= process.env`
|
|
1517
|
+
const disqualified = new Set(); // symbol assigned to something that is NOT process.env
|
|
1518
|
+
const noteBinding = (symbol, init) => {
|
|
1519
|
+
if (!symbol) return;
|
|
1520
|
+
if (init && isProcessEnvExpr(init)) aliasCandidates.add(symbol);
|
|
1521
|
+
else disqualified.add(symbol); // bound/assigned to a non-process.env value → not (or no longer) an alias
|
|
1522
|
+
};
|
|
1523
|
+
const collectAliases = (node) => {
|
|
1524
|
+
// `const env = process.env` / `let`/`var` — a name-identifier binding with an initializer.
|
|
1525
|
+
if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
|
|
1526
|
+
noteBinding(checker.getSymbolAtLocation(node.name), node.initializer ?? null);
|
|
1527
|
+
}
|
|
1528
|
+
// `const { env } = process` — destructuring `env` off the global `process` makes `env` an alias too.
|
|
1529
|
+
else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
|
|
1530
|
+
&& node.initializer && ts.isIdentifier(node.initializer) && identIsGlobalProcess(node.initializer)) {
|
|
1531
|
+
for (const el of node.name.elements) {
|
|
1532
|
+
// the property picked off `process` must be `env` (`{env}` or `{env: local}`); the bound name is the alias.
|
|
1533
|
+
const propName = el.propertyName ? (ts.isIdentifier(el.propertyName) ? el.propertyName.text : null)
|
|
1534
|
+
: (ts.isIdentifier(el.name) ? el.name.text : null);
|
|
1535
|
+
if (propName === "env" && ts.isIdentifier(el.name)) aliasCandidates.add(checker.getSymbolAtLocation(el.name));
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
// `env = <expr>` reassignment — a `let`/`var` alias reassigned to a non-process.env value is cleared.
|
|
1539
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
1540
|
+
&& ts.isIdentifier(node.left)) {
|
|
1541
|
+
noteBinding(checker.getSymbolAtLocation(node.left), node.right);
|
|
1542
|
+
}
|
|
1543
|
+
ts.forEachChild(node, collectAliases);
|
|
1544
|
+
};
|
|
1545
|
+
for (const sf of sources) collectAliases(sf);
|
|
1546
|
+
for (const s of aliasCandidates) if (s && !disqualified.has(s)) envAliasSymbols.add(s);
|
|
1547
|
+
}
|
|
1548
|
+
// True when `id` is an identifier resolving to a confirmed process.env alias local.
|
|
1549
|
+
const identIsEnvAlias = (id) => {
|
|
1550
|
+
if (!id || !ts.isIdentifier(id)) return false;
|
|
1551
|
+
const sym = checker.getSymbolAtLocation(id);
|
|
1552
|
+
return !!sym && envAliasSymbols.has(sym);
|
|
1553
|
+
};
|
|
1554
|
+
// The receiver expression READS process.env — it is either `process.env` itself or a confirmed alias.
|
|
1555
|
+
const readsProcessEnv = (expr) => isProcessEnvExpr(expr) || identIsEnvAlias(expr);
|
|
1556
|
+
|
|
1342
1557
|
// ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
|
|
1343
1558
|
function visitCalls(node) {
|
|
1344
1559
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
@@ -1886,10 +2101,25 @@ function visitCalls(node) {
|
|
|
1886
2101
|
}
|
|
1887
2102
|
}
|
|
1888
2103
|
}
|
|
1889
|
-
// process.env
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2104
|
+
// Reading process.env — the JVM System.getenv twin → Env. All the common idioms count, not just the
|
|
2105
|
+
// direct `process.env.KEY` dot access (see the process.env-recognition note above): dot/bracket access
|
|
2106
|
+
// on process.env or a confirmed alias, destructuring a key off it, and the `in` membership test.
|
|
2107
|
+
{
|
|
2108
|
+
const markEnv = () => { const owner = enclosing(node); if (owner) fns.get(owner).direct.add("Env"); };
|
|
2109
|
+
// `process.env.KEY` / `env.KEY` (dot) and `process.env["KEY"]` / `env[k]` (bracket, literal OR dynamic key).
|
|
2110
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && readsProcessEnv(node.expression)) {
|
|
2111
|
+
markEnv();
|
|
2112
|
+
}
|
|
2113
|
+
// `const {KEY} = process.env` / `const {KEY} = env` — the object-binding pattern's initializer reads env.
|
|
2114
|
+
else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
|
|
2115
|
+
&& node.initializer && readsProcessEnv(node.initializer)) {
|
|
2116
|
+
markEnv();
|
|
2117
|
+
}
|
|
2118
|
+
// `"KEY" in process.env` / `"KEY" in env` — the `in` operator's right operand reads env.
|
|
2119
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword
|
|
2120
|
+
&& readsProcessEnv(node.right)) {
|
|
2121
|
+
markEnv();
|
|
2122
|
+
}
|
|
1893
2123
|
}
|
|
1894
2124
|
// Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
|
|
1895
2125
|
// `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
|
|
@@ -2272,6 +2502,19 @@ for (const [name, rec] of fns) {
|
|
|
2272
2502
|
// `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
|
|
2273
2503
|
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
2274
2504
|
package: pkgName, functions };
|
|
2505
|
+
// ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
|
|
2506
|
+
// name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
|
|
2507
|
+
// the --gate-json advisory, so the three can never tell different stories.
|
|
2508
|
+
const uncoveredLedger = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
2509
|
+
// ⟨0.15 staged⟩ `coverage` envelope field — the stderr disclosure travels WITH the artifact, so a
|
|
2510
|
+
// report-consuming verb can no longer read a partially-covered report as total. Same names/counts as
|
|
2511
|
+
// the stderr line. OMITTED entirely when nothing is uncovered (the `extensions`-field precedent): a
|
|
2512
|
+
// fully-covered report stays byte-identical to a ⟨0.14⟩ one, so the rung is wire-compatible. The
|
|
2513
|
+
// per-function posture is UNCHANGED: a resolvable-but-uncovered call keeps `invisible`, an
|
|
2514
|
+
// unresolvable one keeps the stronger `Unknown` (COVERAGE-DESIGN.md §2 blesses both).
|
|
2515
|
+
if (uncoveredLedger.length) {
|
|
2516
|
+
envelope.coverage = { uncovered: uncoveredLedger.map(([name, calls]) => ({ name, calls })) };
|
|
2517
|
+
}
|
|
2275
2518
|
const cg = {};
|
|
2276
2519
|
for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
2277
2520
|
// Write ATOMICALLY (temp + rename): a concurrent reader — the MCP server or another `query` while
|
|
@@ -2325,7 +2568,7 @@ if (!wantJson) {
|
|
|
2325
2568
|
}
|
|
2326
2569
|
}
|
|
2327
2570
|
if (unlistedSeen.size > 0) {
|
|
2328
|
-
const top =
|
|
2571
|
+
const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
|
|
2329
2572
|
const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
|
|
2330
2573
|
const more = top.length > 8 ? ` + ${top.length - 8} more` : "";
|
|
2331
2574
|
console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
|
|
@@ -2456,7 +2699,17 @@ for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
|
|
|
2456
2699
|
// the SAME gateViolations that set the exit code (so it can't disagree). Written whenever the flag is set —
|
|
2457
2700
|
// ok:true,[] when no gate is configured. Must precede the exit(1) below.
|
|
2458
2701
|
if (gateJsonPath) {
|
|
2459
|
-
const
|
|
2702
|
+
const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0, violations: gateViolations };
|
|
2703
|
+
// ⟨0.15 staged⟩ coverage ADVISORY (COVERAGE-DESIGN.md §3): when the κ ledger is non-empty, the
|
|
2704
|
+
// verdict discloses what the gate could NOT see — VERDICT-PRESERVING (the ⟨0.9⟩ provable-purity
|
|
2705
|
+
// auto-disclosure precedent exactly): ok/violations/exit are computed above and untouched here. A
|
|
2706
|
+
// gate does not fail on uncovered deps (nearly every real scan has some); the policy author sees
|
|
2707
|
+
// the note and decides — `deny Unknown` remains the opt-in strict posture. OMITTED when fully
|
|
2708
|
+
// covered, so a pre-0.15 consumer's verdict is byte-identical.
|
|
2709
|
+
if (uncoveredLedger.length) {
|
|
2710
|
+
verdictObj.coverage = { uncovered: uncoveredLedger.length, packages: uncoveredLedger.map(([p]) => p) };
|
|
2711
|
+
}
|
|
2712
|
+
const verdict = JSON.stringify(verdictObj, null, 1);
|
|
2460
2713
|
if (gateJsonPath === "-") console.log(verdict);
|
|
2461
2714
|
else {
|
|
2462
2715
|
// The verdict is a SURFACING side-output: an unwritable path must be one stderr line, never a raw
|