candor-ts 0.14.1 → 0.16.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 +72 -7
- package/scan.mjs +347 -29
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.16)."*
|
|
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.16" }, 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.16.x, speaking candor-spec 0.16: 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.16.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.16)",
|
|
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.16";
|
|
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]
|
|
@@ -307,7 +307,7 @@ const SUBCOMMANDS = [
|
|
|
307
307
|
const usage = () => {
|
|
308
308
|
const w = Math.max(...SUBCOMMANDS.map(([n, a]) => `${n} ${a}`.trimEnd().length));
|
|
309
309
|
const lines = SUBCOMMANDS.map(([n, a, d]) => ` ${`${n} ${a}`.trimEnd().padEnd(w)} ${d}`);
|
|
310
|
-
lines.push(` ${"-V, --version".padEnd(w)} print the
|
|
310
|
+
lines.push(` ${"-V, --version".padEnd(w)} print the installed version + upgrade line (offline)`);
|
|
311
311
|
lines.push(` ${"-h, --help".padEnd(w)} show this help`);
|
|
312
312
|
return `USAGE: candor-ts-query <command> [args]\n\n${lines.join("\n")}`;
|
|
313
313
|
};
|
|
@@ -321,12 +321,54 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
// -h / --help: a print-and-exit MODE, handled before the switch (so `-h`'s single dash is never
|
|
324
|
-
// mistaken for a command).
|
|
324
|
+
// mistaken for a command). House-style page: identity + model paragraph + COMMON/ALL ACTIONS
|
|
325
|
+
// (the action names derived from SUBCOMMANDS, so the list can never go stale) + OPTIONS + footer.
|
|
326
|
+
// The exit-2 error path keeps the denser fully-described usage() above.
|
|
325
327
|
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
326
|
-
|
|
328
|
+
const names = SUBCOMMANDS.map(([n]) => n);
|
|
329
|
+
const allActions = [names.slice(0, 9), names.slice(9)].map((row) => ` ${row.join(" ")}`).join("\n");
|
|
330
|
+
console.log(`candor-ts-query — read-only queries over a candor report.
|
|
327
331
|
|
|
328
|
-
|
|
332
|
+
Answers come from the report candor-ts wrote — discovered by walking up from the
|
|
333
|
+
cwd to a .candor/ dir (CANDOR_REPORT overrides; --report pins a locator). No
|
|
334
|
+
re-scan, no network. Every engine speaks the same grammar, so these actions and
|
|
335
|
+
flags match the rest of the family.
|
|
329
336
|
|
|
337
|
+
USAGE
|
|
338
|
+
candor-ts-query <action> [args] [options]
|
|
339
|
+
|
|
340
|
+
COMMON ACTIONS
|
|
341
|
+
where <Effect> the functions that perform an effect
|
|
342
|
+
path <fn> <Effect> the call path by which a function reaches an effect
|
|
343
|
+
callers <fn> who calls a function, direct and transitive
|
|
344
|
+
tour [N] the N most surprising transitive reaches (default 10)
|
|
345
|
+
blindspots the Unknown sources worth resolving, ranked by reach
|
|
346
|
+
gains <current> <base> what a new version newly reaches (the supply-chain diff)
|
|
347
|
+
fix <fn> <Effect> the boundary hoist that would clear a violation
|
|
348
|
+
|
|
349
|
+
ALL ACTIONS
|
|
350
|
+
${allActions}
|
|
351
|
+
|
|
352
|
+
OPTIONS (uniform across every engine)
|
|
353
|
+
--report <locator> use this report instead of discovering .candor/
|
|
354
|
+
--policy <file> evaluate a policy — exit 1 on a violation (whatif, fix, fix-gate,
|
|
355
|
+
unverified; CANDOR_POLICY / a .candor/config \`policy\` key when absent)
|
|
356
|
+
--json machine-readable output
|
|
357
|
+
--include-unknown callers: also list the unresolved-dispatch frontier
|
|
358
|
+
--strict unverified: exit 1 on an unverified hole (advisory otherwise)
|
|
359
|
+
-V, --version print the installed version + upgrade line (offline)
|
|
360
|
+
-h, --help show this help
|
|
361
|
+
|
|
362
|
+
diff and gains take two positional report locators: <current> <baseline>. Run
|
|
363
|
+
candor-ts-query with no action for the full per-action argument list.
|
|
364
|
+
|
|
365
|
+
EXAMPLES
|
|
366
|
+
candor-ts-query where Db
|
|
367
|
+
candor-ts-query path app.orders.render Net
|
|
368
|
+
candor-ts-query gains new/.candor/report.json old/.candor/report.json
|
|
369
|
+
candor-ts-query fix-gate --policy candor.policy
|
|
370
|
+
|
|
371
|
+
Docs: candor.poly.io · Verify an install: candor doctor
|
|
330
372
|
See https://github.com/tombaldwin/candor`);
|
|
331
373
|
process.exit(0);
|
|
332
374
|
}
|
|
@@ -354,6 +396,9 @@ switch (cmd) {
|
|
|
354
396
|
// written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
|
|
355
397
|
// the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
|
|
356
398
|
const { prefix, args: [q] } = resolveReportVerb(args, 1);
|
|
399
|
+
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
|
|
400
|
+
// `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
|
|
401
|
+
if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
|
|
357
402
|
emit(coreShow(loadReportOrDie(prefix), q));
|
|
358
403
|
break;
|
|
359
404
|
}
|
|
@@ -362,6 +407,9 @@ switch (cmd) {
|
|
|
362
407
|
// Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
|
|
363
408
|
// fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
|
|
364
409
|
const { prefix, args: [eff] } = resolveReportVerb(args, 1);
|
|
410
|
+
// A missing/empty <Effect> is a LOUD usage error (exit 2, like candor-java's missing-arg path) —
|
|
411
|
+
// never an authoritative-empty {directly:[],inherited:[]} at exit 0 (a false all-clear shape).
|
|
412
|
+
if (!eff) { console.error("usage: candor-ts-query where <Effect> [--report <locator>] [--json]"); process.exit(2); }
|
|
365
413
|
emit(coreWhere(loadReportOrDie(prefix), eff));
|
|
366
414
|
break;
|
|
367
415
|
}
|
|
@@ -370,6 +418,9 @@ switch (cmd) {
|
|
|
370
418
|
// it, the byte-for-byte {of,direct,transitive} shape is unchanged (cross-engine parity). Call the
|
|
371
419
|
// shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
|
|
372
420
|
const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
|
|
421
|
+
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an empty
|
|
422
|
+
// {of:[],direct:[],transitive:[]} at exit 0 (reads as "nothing reaches it" for a fn never named).
|
|
423
|
+
if (!q) { console.error("usage: candor-ts-query callers <query> [--include-unknown] [--report <locator>] [--json]"); process.exit(2); }
|
|
373
424
|
const cg = loadCallgraph(prefix);
|
|
374
425
|
if (includeUnknown) emit(callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q));
|
|
375
426
|
else emit(coreCallers(cg, q));
|
|
@@ -465,6 +516,9 @@ switch (cmd) {
|
|
|
465
516
|
// blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
|
|
466
517
|
// MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
|
|
467
518
|
const { prefix, args: [q] } = resolveReportVerb(args, 1);
|
|
519
|
+
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an
|
|
520
|
+
// affectedCount:0 blast radius at exit 0 for a function that was never named.
|
|
521
|
+
if (!q) { console.error("usage: candor-ts-query impact <query> [--report <locator>] [--json]"); process.exit(2); }
|
|
468
522
|
emit(coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q));
|
|
469
523
|
break;
|
|
470
524
|
}
|
|
@@ -571,7 +625,14 @@ switch (cmd) {
|
|
|
571
625
|
// a MISSING sidecar loads {} and a corrupt (matched-but-unparseable) one is tagged `partial`
|
|
572
626
|
// with its edges dropped-and-disclosed: either way "new" is unavailable and origin falls back
|
|
573
627
|
// to "unknown" — the JSON itself discloses, never guessing "new" over a truncated graph.
|
|
574
|
-
|
|
628
|
+
// ⟨0.15 staged⟩ coverage disclosure (COVERAGE-DESIGN.md §3): the CURRENT report's `coverage`
|
|
629
|
+
// envelope rides along (a gained effect in an uncovered dep is invisible — "no gains" must not
|
|
630
|
+
// read as total), plus `coverageDelta` when the baseline names different blind packages. Both
|
|
631
|
+
// OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
|
|
632
|
+
// Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
|
|
633
|
+
emit({ baseline_version: gbv ?? "", engine_version: gv ?? "",
|
|
634
|
+
...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)),
|
|
635
|
+
...gainsCoverage(curPrefix, basePrefix) });
|
|
575
636
|
break;
|
|
576
637
|
}
|
|
577
638
|
case "path": {
|
|
@@ -580,6 +641,10 @@ switch (cmd) {
|
|
|
580
641
|
// pinned JSON shape. parseCanonical otherwise swallows --json, so detect it explicitly (as `tour` does).
|
|
581
642
|
const wantJson = args.includes("--json");
|
|
582
643
|
const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
|
|
644
|
+
// BOTH positionals are required (`path <fn> <Effect>`) — a missing/empty one is a LOUD usage error
|
|
645
|
+
// (exit 2, like candor-java). Before this gate, one arg slid through as `<fn> undefined` and printed
|
|
646
|
+
// "does not perform undefined" at exit 0 — a false all-clear over a question that was never posed.
|
|
647
|
+
if (!fn || !eff) { console.error("usage: candor-ts-query path <fn> <Effect> [--report <locator>] [--json]"); process.exit(2); }
|
|
583
648
|
const fns = loadReportOrDie(prefix);
|
|
584
649
|
const cg = loadCallgraph(prefix);
|
|
585
650
|
if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
|
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.16";
|
|
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
|
|
@@ -55,25 +55,45 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
|
55
55
|
// -h / --help: a print-and-exit MODE (like --version), handled before the arg walk so `-h` (a single
|
|
56
56
|
// dash) is never mistaken for the scan target by the positional fallthrough below.
|
|
57
57
|
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
58
|
-
console.log(`candor-ts
|
|
58
|
+
console.log(`candor-ts — the TypeScript/JavaScript effect analyzer.
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Reads TS/JS source through the TypeScript compiler API — no build needed. Calls
|
|
61
|
+
are resolved through the checker; a call that cannot be resolved reads Unknown,
|
|
62
|
+
never silently pure. The report lands in .candor/, where candor-ts-query and the
|
|
63
|
+
umbrella \`candor\` CLI discover it.
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
--json print the report as JSON to stdout (instead of writing files)
|
|
65
|
-
--policy <file> enforce a policy file (deny/pure/allow/forbid, candor-spec §6.2) — exit 1 on a
|
|
66
|
-
violation, 2 if unreadable; honours $CANDOR_POLICY when the flag is absent
|
|
67
|
-
--gate-json <f> write the structured gate verdict { spec, ok, violations } as JSON (candor-spec §3.3)
|
|
68
|
-
--allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
|
|
69
|
-
--agents print the agent contract for this build (AGENTS.md)
|
|
70
|
-
-V, --version print the build and spec version (offline)
|
|
71
|
-
-h, --help show this help
|
|
65
|
+
USAGE
|
|
66
|
+
candor-ts <dir | file.ts | tsconfig.json> [flags]
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
guard against a saved same-build report: exit 1 when an existing function gained an effect, exit 2
|
|
75
|
-
on an unparseable or different-build baseline (never evaluated), a stderr note when absent.
|
|
68
|
+
The target is a project directory, a single .ts file, or a tsconfig.json.
|
|
76
69
|
|
|
70
|
+
OPTIONS
|
|
71
|
+
--out <prefix> write the report to <prefix>.json + <prefix>.callgraph.json
|
|
72
|
+
--json print the report as JSON to stdout (instead of writing files)
|
|
73
|
+
--policy <file> enforce a policy file (deny/pure/allow/forbid) — exit 1 on a
|
|
74
|
+
violation, 2 if unreadable
|
|
75
|
+
--gate-json <file> write the structured gate verdict { spec, ok, violations } as JSON
|
|
76
|
+
--allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
|
|
77
|
+
--agents print the agent contract for this build (AGENTS.md)
|
|
78
|
+
-V, --version print the installed version + upgrade line (offline)
|
|
79
|
+
-h, --help show this help
|
|
80
|
+
|
|
81
|
+
ENVIRONMENT / CONFIG
|
|
82
|
+
CANDOR_POLICY=<file> the policy when --policy is absent (a .candor/config
|
|
83
|
+
\`policy\` key works too)
|
|
84
|
+
CANDOR_BASELINE=<report.json> (or a .candor/config \`baseline\` key) runs the AS-EFF-005
|
|
85
|
+
regression guard against a saved same-build report: exit 1
|
|
86
|
+
when an existing function gained an effect, exit 2 on an
|
|
87
|
+
unparseable or different-build baseline (never evaluated),
|
|
88
|
+
a stderr note when absent
|
|
89
|
+
|
|
90
|
+
EXAMPLES
|
|
91
|
+
candor-ts .
|
|
92
|
+
candor-ts src --allow-js
|
|
93
|
+
candor-ts . --policy candor.policy --gate-json gate.json
|
|
94
|
+
candor-ts-query where Db query the report this scan wrote
|
|
95
|
+
|
|
96
|
+
Docs: candor.poly.io · Verify an install: candor doctor
|
|
77
97
|
See https://github.com/tombaldwin/candor`);
|
|
78
98
|
process.exit(0);
|
|
79
99
|
}
|
|
@@ -477,9 +497,122 @@ function programHeadLiteral(node) {
|
|
|
477
497
|
// options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
|
|
478
498
|
// are considered; returns null when the URL slot is not a static string literal — the safe direction.
|
|
479
499
|
const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
|
|
500
|
+
// CONST-STRING PROPAGATION (java constant-inlining parity): resolve a bare identifier that references a
|
|
501
|
+
// `const NAME = "literal"` string to its literal value, and ONLY then. Returns the string, or null. The
|
|
502
|
+
// soundness rule is strict: resolve ONLY when EVERY value-declaration of the symbol is an immutable
|
|
503
|
+
// `const` (or a `readonly` field) whose initializer is a plain string literal. A `let`/`var` (reassignable),
|
|
504
|
+
// a declaration with no string-literal initializer (runtime value, function result, env read, config field,
|
|
505
|
+
// concatenation, another template), or a symbol with MORE than the string-literal decls we can see → null,
|
|
506
|
+
// so the call stays bare/runtime as before. NEVER guess a value we cannot read off a `const` initializer.
|
|
507
|
+
function constStringValue(expr) {
|
|
508
|
+
if (!ts.isIdentifier(expr)) return null;
|
|
509
|
+
const sym = checker.getSymbolAtLocation(expr);
|
|
510
|
+
const decls = sym?.declarations ?? [];
|
|
511
|
+
if (decls.length === 0) return null;
|
|
512
|
+
let resolved = null;
|
|
513
|
+
for (const d of decls) {
|
|
514
|
+
// A `const x = "..."` variable declaration, or a `readonly x = "..."` class/property field. Both are
|
|
515
|
+
// VariableDeclaration/PropertyDeclaration nodes with an initializer; the immutability gate differs.
|
|
516
|
+
if (ts.isVariableDeclaration(d)) {
|
|
517
|
+
// the enclosing VariableDeclarationList must be `const` — a `let`/`var` can be reassigned later.
|
|
518
|
+
const list = d.parent;
|
|
519
|
+
const isConst = list && ts.isVariableDeclarationList(list)
|
|
520
|
+
&& (list.flags & ts.NodeFlags.Const) !== 0;
|
|
521
|
+
if (!isConst || !d.initializer || !ts.isStringLiteral(d.initializer)) return null;
|
|
522
|
+
if (resolved != null && resolved !== d.initializer.text) return null; // conflicting decls — bail
|
|
523
|
+
resolved = d.initializer.text;
|
|
524
|
+
} else if (ts.isPropertyDeclaration(d)) {
|
|
525
|
+
const isReadonly = (ts.getCombinedModifierFlags(d) & ts.ModifierFlags.Readonly) !== 0;
|
|
526
|
+
if (!isReadonly || !d.initializer || !ts.isStringLiteral(d.initializer)) return null;
|
|
527
|
+
if (resolved != null && resolved !== d.initializer.text) return null;
|
|
528
|
+
resolved = d.initializer.text;
|
|
529
|
+
} else {
|
|
530
|
+
return null; // any other declaration shape (function, param, import alias, …) → do not resolve
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return resolved;
|
|
534
|
+
}
|
|
535
|
+
// Resolve a URL ARGUMENT EXPRESSION to a statically-known URL/host string when its HOST is anchored by a
|
|
536
|
+
// `const NAME = "literal"` string (java constant-inlining parity). Three shapes, all requiring the host to
|
|
537
|
+
// live at the HEAD of the value:
|
|
538
|
+
// • a bare const identifier fetch(API_BASE) → API_BASE's value
|
|
539
|
+
// • a template whose HEAD is a const fetch(`${API_BASE}/chat`) → value + the literal template tail
|
|
540
|
+
// • a concat whose LEFT is a const fetch(API_BASE + "/chat") → value + the right literal
|
|
541
|
+
// The template tail / concat right are appended ONLY when they are themselves plain literals, so the
|
|
542
|
+
// returned string is a real static URL prefix `hostLiteral` can parse (`https://host/…`). A template with a
|
|
543
|
+
// literal host-bearing PREFIX before the interpolation (`\`https://${h}\``) has a non-empty template HEAD, so
|
|
544
|
+
// its head is NOT a const identifier → not resolved here (and the literal prefix alone never named a full
|
|
545
|
+
// host). Anything else (non-const identifier, interpolation of a runtime value, nested template) → null.
|
|
546
|
+
function resolveConstUrlString(expr) {
|
|
547
|
+
if (expr == null) return null;
|
|
548
|
+
// bare identifier: fetch(API_BASE)
|
|
549
|
+
const bare = constStringValue(expr);
|
|
550
|
+
if (bare != null) return bare;
|
|
551
|
+
// template literal `${HEAD_CONST}<tail literal>`: the const value must sit at the HEAD (empty template
|
|
552
|
+
// head text), and we may only append a SINGLE trailing literal span — a second `${…}` interpolation is a
|
|
553
|
+
// runtime value we will not resolve, but it only follows the host, so the host prefix is still sound.
|
|
554
|
+
if (ts.isTemplateExpression(expr)) {
|
|
555
|
+
if (expr.head.text !== "") return null; // literal prefix before the const → not const-anchored
|
|
556
|
+
const first = expr.templateSpans[0];
|
|
557
|
+
const head = first && constStringValue(first.expression);
|
|
558
|
+
if (head == null) return null; // first interpolation is not a const string
|
|
559
|
+
// append the literal text between the first interpolation and the next (or end) — the URL path segment.
|
|
560
|
+
return head + (first.literal.text ?? "");
|
|
561
|
+
}
|
|
562
|
+
// string concat `CONST + "…"`: left must be a const string; append the right ONLY if it is a plain literal.
|
|
563
|
+
if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
564
|
+
const left = constStringValue(expr.left);
|
|
565
|
+
if (left == null) return null;
|
|
566
|
+
const right = ts.isStringLiteralLike(expr.right) ? expr.right.text : "";
|
|
567
|
+
return left + right;
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
// Does a literal URL-head string already contain a COMPLETE authority — i.e. is there a `/` AFTER the
|
|
572
|
+
// `://` still WITHIN the literal text? `https://api.openai.com/v1/` → yes (host fully present, only the
|
|
573
|
+
// PATH follows); `https://api.` / `https://` / `https://api.openai.com:` → no (the authority is not yet
|
|
574
|
+
// terminated, so an interpolation could still be part of the host/port). Requires a `scheme://` prefix;
|
|
575
|
+
// a bare relative path never qualifies.
|
|
576
|
+
function literalHeadCompletesAuthority(head) {
|
|
577
|
+
const m = head.match(/^[a-z][a-z0-9+.-]*:\/\//i);
|
|
578
|
+
if (!m) return false; // no scheme://… → authority not started in the literal
|
|
579
|
+
return head.indexOf("/", m[0].length) >= 0; // a `/` after the `://` terminates the authority
|
|
580
|
+
}
|
|
581
|
+
// LITERAL-HEAD HOST EXTRACTION (java literal-inlining parity): a template `\`https://host/${path}\`` or a
|
|
582
|
+
// concat `"https://host/" + path` whose FIRST STATIC segment (the text before the first interpolation /
|
|
583
|
+
// the concat's left literal) ALREADY contains a complete `scheme://authority/…` carries a statically-known
|
|
584
|
+
// host — the interpolation is only in the PATH. Return that literal head (a real URL prefix `hostLiteral`
|
|
585
|
+
// parses to the authority). If the head does NOT terminate the authority with a `/` (`https://${h}/x`,
|
|
586
|
+
// `https://api.${x}.com/y`, `https://host:${port}/y`, `https://api.openai${x}/v1`) the interpolation could
|
|
587
|
+
// be part of the host/port → return null (safe under-report: stays bare Net). Distinct from
|
|
588
|
+
// resolveConstUrlString, which anchors on a CONST identifier at the head; here the head is a plain LITERAL.
|
|
589
|
+
function literalHeadHostUrl(expr) {
|
|
590
|
+
if (expr == null) return null;
|
|
591
|
+
// template `\`<head>${…}…\``: the literal head is expr.head.text (empty when the interpolation leads).
|
|
592
|
+
if (ts.isTemplateExpression(expr)) {
|
|
593
|
+
const head = expr.head.text;
|
|
594
|
+
return literalHeadCompletesAuthority(head) ? head : null;
|
|
595
|
+
}
|
|
596
|
+
// concat `"<left literal>" + <anything>`: only the LEFT operand's literal text is the static head; the
|
|
597
|
+
// right is a runtime value living in the path. (A nested `"a" + "b" + x` left is a BinaryExpression, not
|
|
598
|
+
// a string literal, so it is not read here — a safe under-report, not a fabrication.)
|
|
599
|
+
if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken
|
|
600
|
+
&& ts.isStringLiteralLike(expr.left)) {
|
|
601
|
+
const head = expr.left.text;
|
|
602
|
+
return literalHeadCompletesAuthority(head) ? head : null;
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
480
606
|
function urlArgLiteral(node, member) {
|
|
481
607
|
const args = node.arguments ?? [];
|
|
482
|
-
const litAt = (i) =>
|
|
608
|
+
const litAt = (i) => {
|
|
609
|
+
const a = args[i];
|
|
610
|
+
if (!a) return null;
|
|
611
|
+
if (ts.isStringLiteralLike(a)) return a.text;
|
|
612
|
+
// const-anchored host (fetch(API_BASE), `${API_BASE}/x`, API_BASE+"/x"), THEN literal-head extraction
|
|
613
|
+
// (`\`https://host/${p}\``, `"https://host/" + p`) when the literal head already completes the authority.
|
|
614
|
+
return resolveConstUrlString(a) ?? literalHeadHostUrl(a);
|
|
615
|
+
};
|
|
483
616
|
if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
|
|
484
617
|
return litAt(0);
|
|
485
618
|
}
|
|
@@ -1364,6 +1497,83 @@ const HOF_INVOKERS = new Set([
|
|
|
1364
1497
|
"then", "catch", "finally", "nextTick",
|
|
1365
1498
|
]);
|
|
1366
1499
|
|
|
1500
|
+
// ---- process.env recognition: the direct dot access (`process.env.KEY`) is the JVM System.getenv twin,
|
|
1501
|
+
// but the same environment READ is spelled several other ways that all read silent-pure without help:
|
|
1502
|
+
// bracket access (`process.env[k]`), a local const-alias (`const env = process.env; env.KEY`),
|
|
1503
|
+
// destructuring (`const {KEY} = process.env`), and the `in` operator (`"KEY" in process.env`). Each of
|
|
1504
|
+
// these on process.env (or a confirmed direct alias of it) is Env. SOUNDNESS: only process.env and a
|
|
1505
|
+
// DIRECT `x = process.env` / `const {env} = process` binding trigger — a bracket/alias/destructure/`in`
|
|
1506
|
+
// on any OTHER object stays pure (no fabrication), and a reassigned alias local is cleared.
|
|
1507
|
+
//
|
|
1508
|
+
// `process` here must be Node's process object, NOT a project-local `const process = {…}` shadow
|
|
1509
|
+
// (mirrors the process.hrtime/send guard). It qualifies when it is the ambient GLOBAL (no project
|
|
1510
|
+
// declaration) OR a default-import of the `node:process` builtin (`import process from 'node:process'`,
|
|
1511
|
+
// as chalk's supports-color does) — the two are the same object.
|
|
1512
|
+
const declImportsNodeProcess = (decl) => {
|
|
1513
|
+
// ImportClause default binding or a namespace/named import from 'node:process' | 'process'.
|
|
1514
|
+
let spec = null;
|
|
1515
|
+
if (ts.isImportClause(decl) && decl.parent && ts.isImportDeclaration(decl.parent)) spec = decl.parent.moduleSpecifier;
|
|
1516
|
+
else if (ts.isImportSpecifier(decl)) spec = decl.parent?.parent?.parent?.moduleSpecifier;
|
|
1517
|
+
else if (ts.isNamespaceImport(decl)) spec = decl.parent?.parent?.moduleSpecifier;
|
|
1518
|
+
const text = spec && ts.isStringLiteral(spec) ? spec.text : null;
|
|
1519
|
+
return text === "node:process" || text === "process";
|
|
1520
|
+
};
|
|
1521
|
+
const identIsGlobalProcess = (id) => {
|
|
1522
|
+
if (!ts.isIdentifier(id) || id.text !== "process") return false;
|
|
1523
|
+
const decls = checker.getSymbolAtLocation(id)?.declarations ?? [];
|
|
1524
|
+
if (decls.some(declImportsNodeProcess)) return true; // `import process from 'node:process'`
|
|
1525
|
+
return !decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))); // else the ambient global
|
|
1526
|
+
};
|
|
1527
|
+
// `process.env` as an expression (PropertyAccess `process.env` where `process` is the global).
|
|
1528
|
+
const isProcessEnvExpr = (expr) =>
|
|
1529
|
+
expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
|
|
1530
|
+
|
|
1531
|
+
// The set of local-binding SYMBOLS that alias process.env — collected below, one pre-pass over the
|
|
1532
|
+
// sources. A symbol lands here iff its ONLY initializer/assignment is `= process.env` (a reassignment
|
|
1533
|
+
// to anything else removes it → the alias is cleared, per the spec's reassignment rule).
|
|
1534
|
+
const envAliasSymbols = new Set();
|
|
1535
|
+
{
|
|
1536
|
+
const aliasCandidates = new Set(); // symbol -> declared `= process.env`
|
|
1537
|
+
const disqualified = new Set(); // symbol assigned to something that is NOT process.env
|
|
1538
|
+
const noteBinding = (symbol, init) => {
|
|
1539
|
+
if (!symbol) return;
|
|
1540
|
+
if (init && isProcessEnvExpr(init)) aliasCandidates.add(symbol);
|
|
1541
|
+
else disqualified.add(symbol); // bound/assigned to a non-process.env value → not (or no longer) an alias
|
|
1542
|
+
};
|
|
1543
|
+
const collectAliases = (node) => {
|
|
1544
|
+
// `const env = process.env` / `let`/`var` — a name-identifier binding with an initializer.
|
|
1545
|
+
if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
|
|
1546
|
+
noteBinding(checker.getSymbolAtLocation(node.name), node.initializer ?? null);
|
|
1547
|
+
}
|
|
1548
|
+
// `const { env } = process` — destructuring `env` off the global `process` makes `env` an alias too.
|
|
1549
|
+
else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
|
|
1550
|
+
&& node.initializer && ts.isIdentifier(node.initializer) && identIsGlobalProcess(node.initializer)) {
|
|
1551
|
+
for (const el of node.name.elements) {
|
|
1552
|
+
// the property picked off `process` must be `env` (`{env}` or `{env: local}`); the bound name is the alias.
|
|
1553
|
+
const propName = el.propertyName ? (ts.isIdentifier(el.propertyName) ? el.propertyName.text : null)
|
|
1554
|
+
: (ts.isIdentifier(el.name) ? el.name.text : null);
|
|
1555
|
+
if (propName === "env" && ts.isIdentifier(el.name)) aliasCandidates.add(checker.getSymbolAtLocation(el.name));
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
// `env = <expr>` reassignment — a `let`/`var` alias reassigned to a non-process.env value is cleared.
|
|
1559
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
1560
|
+
&& ts.isIdentifier(node.left)) {
|
|
1561
|
+
noteBinding(checker.getSymbolAtLocation(node.left), node.right);
|
|
1562
|
+
}
|
|
1563
|
+
ts.forEachChild(node, collectAliases);
|
|
1564
|
+
};
|
|
1565
|
+
for (const sf of sources) collectAliases(sf);
|
|
1566
|
+
for (const s of aliasCandidates) if (s && !disqualified.has(s)) envAliasSymbols.add(s);
|
|
1567
|
+
}
|
|
1568
|
+
// True when `id` is an identifier resolving to a confirmed process.env alias local.
|
|
1569
|
+
const identIsEnvAlias = (id) => {
|
|
1570
|
+
if (!id || !ts.isIdentifier(id)) return false;
|
|
1571
|
+
const sym = checker.getSymbolAtLocation(id);
|
|
1572
|
+
return !!sym && envAliasSymbols.has(sym);
|
|
1573
|
+
};
|
|
1574
|
+
// The receiver expression READS process.env — it is either `process.env` itself or a confirmed alias.
|
|
1575
|
+
const readsProcessEnv = (expr) => isProcessEnvExpr(expr) || identIsEnvAlias(expr);
|
|
1576
|
+
|
|
1367
1577
|
// ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
|
|
1368
1578
|
function visitCalls(node) {
|
|
1369
1579
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
@@ -1911,10 +2121,25 @@ function visitCalls(node) {
|
|
|
1911
2121
|
}
|
|
1912
2122
|
}
|
|
1913
2123
|
}
|
|
1914
|
-
// process.env
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2124
|
+
// Reading process.env — the JVM System.getenv twin → Env. All the common idioms count, not just the
|
|
2125
|
+
// direct `process.env.KEY` dot access (see the process.env-recognition note above): dot/bracket access
|
|
2126
|
+
// on process.env or a confirmed alias, destructuring a key off it, and the `in` membership test.
|
|
2127
|
+
{
|
|
2128
|
+
const markEnv = () => { const owner = enclosing(node); if (owner) fns.get(owner).direct.add("Env"); };
|
|
2129
|
+
// `process.env.KEY` / `env.KEY` (dot) and `process.env["KEY"]` / `env[k]` (bracket, literal OR dynamic key).
|
|
2130
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && readsProcessEnv(node.expression)) {
|
|
2131
|
+
markEnv();
|
|
2132
|
+
}
|
|
2133
|
+
// `const {KEY} = process.env` / `const {KEY} = env` — the object-binding pattern's initializer reads env.
|
|
2134
|
+
else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
|
|
2135
|
+
&& node.initializer && readsProcessEnv(node.initializer)) {
|
|
2136
|
+
markEnv();
|
|
2137
|
+
}
|
|
2138
|
+
// `"KEY" in process.env` / `"KEY" in env` — the `in` operator's right operand reads env.
|
|
2139
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword
|
|
2140
|
+
&& readsProcessEnv(node.right)) {
|
|
2141
|
+
markEnv();
|
|
2142
|
+
}
|
|
1918
2143
|
}
|
|
1919
2144
|
// Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
|
|
1920
2145
|
// `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
|
|
@@ -2297,6 +2522,19 @@ for (const [name, rec] of fns) {
|
|
|
2297
2522
|
// `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
|
|
2298
2523
|
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
2299
2524
|
package: pkgName, functions };
|
|
2525
|
+
// ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
|
|
2526
|
+
// name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
|
|
2527
|
+
// the --gate-json advisory, so the three can never tell different stories.
|
|
2528
|
+
const uncoveredLedger = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
2529
|
+
// ⟨0.15 staged⟩ `coverage` envelope field — the stderr disclosure travels WITH the artifact, so a
|
|
2530
|
+
// report-consuming verb can no longer read a partially-covered report as total. Same names/counts as
|
|
2531
|
+
// the stderr line. OMITTED entirely when nothing is uncovered (the `extensions`-field precedent): a
|
|
2532
|
+
// fully-covered report stays byte-identical to a ⟨0.14⟩ one, so the rung is wire-compatible. The
|
|
2533
|
+
// per-function posture is UNCHANGED: a resolvable-but-uncovered call keeps `invisible`, an
|
|
2534
|
+
// unresolvable one keeps the stronger `Unknown` (COVERAGE-DESIGN.md §2 blesses both).
|
|
2535
|
+
if (uncoveredLedger.length) {
|
|
2536
|
+
envelope.coverage = { uncovered: uncoveredLedger.map(([name, calls]) => ({ name, calls })) };
|
|
2537
|
+
}
|
|
2300
2538
|
const cg = {};
|
|
2301
2539
|
for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
2302
2540
|
// Write ATOMICALLY (temp + rename): a concurrent reader — the MCP server or another `query` while
|
|
@@ -2350,7 +2588,7 @@ if (!wantJson) {
|
|
|
2350
2588
|
}
|
|
2351
2589
|
}
|
|
2352
2590
|
if (unlistedSeen.size > 0) {
|
|
2353
|
-
const top =
|
|
2591
|
+
const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
|
|
2354
2592
|
const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
|
|
2355
2593
|
const more = top.length > 8 ? ` + ${top.length - 8} more` : "";
|
|
2356
2594
|
console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
|
|
@@ -2398,6 +2636,20 @@ let gateViolations = [];
|
|
|
2398
2636
|
// · Valid + same build → per-fn compare: an EXISTING fn gaining an effect is an [AS-EFF-005]
|
|
2399
2637
|
// violation (exit 1, joins --gate-json); a fn absent from the baseline is NEW code, reviewed as
|
|
2400
2638
|
// such, not a regression. Baselines omit pure fns (spec §2), so absent-prior means no prior claim.
|
|
2639
|
+
//
|
|
2640
|
+
// ⟨0.16⟩ Callgraph-aware existence (SPEC §7 item 5). Reports OMIT pure functions, so a fn that
|
|
2641
|
+
// shipped PURE and now performs an effect is absent from the baseline report and reads as exempt "new
|
|
2642
|
+
// code" — the sharpest supply-chain shape escaping the guard. Fix: key existence on the baseline
|
|
2643
|
+
// CALLGRAPH sidecar (<baseline>.callgraph.json, §2.2 — it lists every project fn INCLUDING pure
|
|
2644
|
+
// leaves), exactly as `gains`'s `origin` existence test does (query-core.mjs `gains`: a fn is
|
|
2645
|
+
// "existing" if it is a baseline-callgraph node — a caller key or a callee):
|
|
2646
|
+
// · sidecar PRESENT + loaded → a fn that is a baseline-callgraph node has baseline effect set ∅
|
|
2647
|
+
// (pure → omitted from the report) and any effect now is a GAIN violation. pure→effectful is caught.
|
|
2648
|
+
// A fn in NEITHER report nor callgraph genuinely did not exist → stays exempt "new".
|
|
2649
|
+
// · sidecar ABSENT → degrade to report-only existence (pre-⟨0.16⟩: a formerly-pure fn reads as new;
|
|
2650
|
+
// still catches an already-effectful fn WIDENING). One stderr note that the guard is weaker.
|
|
2651
|
+
// · sidecar PRESENT-but-CORRUPT → fail closed (exit 2), like a corrupt baseline: a broken sidecar
|
|
2652
|
+
// must not silently NARROW the guard back to report-only.
|
|
2401
2653
|
if (baselinePath !== null) {
|
|
2402
2654
|
const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
|
|
2403
2655
|
if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
@@ -2429,14 +2681,66 @@ if (baselinePath !== null) {
|
|
|
2429
2681
|
for (const e of arr) {
|
|
2430
2682
|
if (e && typeof e.fn === "string" && e.fn) base.set(e.fn, new Set(Array.isArray(e.inferred) ? e.inferred : []));
|
|
2431
2683
|
}
|
|
2684
|
+
// ⟨0.16⟩ Load the baseline callgraph sidecar next to the baseline report. The sidecar for a
|
|
2685
|
+
// report at <stem>.json is <stem>.callgraph.json (scan.mjs writes exactly this pair). Three states:
|
|
2686
|
+
// loaded — a parsed object → its node set (every key + every callee) keys existence, mirroring
|
|
2687
|
+
// the `gains` origin test (query-core.mjs). A baseline-callgraph node whose baseline
|
|
2688
|
+
// effects are ∅ (pure → omitted from the report) that now performs an effect is a GAIN.
|
|
2689
|
+
// absent — no sidecar file → degrade to report-only existence + one stderr note (guard weaker).
|
|
2690
|
+
// corrupt — file present but not parseable / not a plain object → fail closed (exit 2). A broken
|
|
2691
|
+
// sidecar must not silently narrow the guard (SPEC §7 item 5).
|
|
2692
|
+
// shownB may be "(configured empty)"; the real path is baselinePath here (non-null, exists).
|
|
2693
|
+
const sidecarPath = baselinePath.replace(/\.json$/i, "") + ".callgraph.json";
|
|
2694
|
+
let cgNodes = null; // null = sidecar absent (report-only degrade)
|
|
2695
|
+
if (fs.existsSync(sidecarPath)) {
|
|
2696
|
+
let baseCg = null;
|
|
2697
|
+
try { baseCg = JSON.parse(fs.readFileSync(sidecarPath, "utf8")); } catch { baseCg = undefined; }
|
|
2698
|
+
// A non-object parse (null / array / number) is a corrupt sidecar: it cannot list nodes, and
|
|
2699
|
+
// treating it as "absent" would silently narrow the guard — fail closed like a corrupt baseline.
|
|
2700
|
+
if (baseCg === undefined || baseCg === null || typeof baseCg !== "object" || Array.isArray(baseCg)) {
|
|
2701
|
+
console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
|
|
2702
|
+
+ `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
|
|
2703
|
+
+ `report-only. Regenerate the baseline with this build.`);
|
|
2704
|
+
process.exit(2);
|
|
2705
|
+
}
|
|
2706
|
+
// The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
|
|
2707
|
+
// as `gains` computes cgNodes. Non-array edge values are tolerated (skipped), matching loadCallgraph.
|
|
2708
|
+
cgNodes = new Set(Object.entries(baseCg).flatMap(([k, vs]) => [k, ...(Array.isArray(vs) ? vs : [])]));
|
|
2709
|
+
} else {
|
|
2710
|
+
console.error(`candor-ts: no baseline callgraph sidecar at ${sidecarPath} — the AS-EFF-005 guard is `
|
|
2711
|
+
+ `WEAKER: existence falls back to the report, which omits pure functions, so a formerly-PURE fn `
|
|
2712
|
+
+ `turning effectful reads as new code and is NOT caught (only an already-effectful fn widening is). `
|
|
2713
|
+
+ `Regenerate the baseline with --out so the .callgraph.json is written alongside it.`);
|
|
2714
|
+
}
|
|
2715
|
+
const unknownOnly = []; // ⟨0.16⟩ advisory: fns that gained ONLY Unknown vs the baseline
|
|
2432
2716
|
for (const name of [...inferred.keys()].sort()) {
|
|
2433
2717
|
const prior = base.get(name);
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2718
|
+
// ⟨0.16⟩ Existence ladder: in the baseline REPORT → its recorded inferred set is the prior;
|
|
2719
|
+
// else a baseline-callgraph NODE (sidecar present) → it existed and was pure, so prior = ∅ (any
|
|
2720
|
+
// effect now is a gain); else genuinely absent → new code, exempt. Without the sidecar (cgNodes
|
|
2721
|
+
// null) only the report path decides, the pre-⟨0.16⟩ semantics.
|
|
2722
|
+
const priorSet = prior !== undefined ? prior
|
|
2723
|
+
: (cgNodes !== null && cgNodes.has(name)) ? new Set() // baseline-pure node → ∅ prior
|
|
2724
|
+
: null; // new function — not a regression
|
|
2725
|
+
if (priorSet === null) continue;
|
|
2726
|
+
const gained = [...inferred.get(name)].filter((x) => !priorSet.has(x)).sort();
|
|
2727
|
+
if (!gained.length) continue;
|
|
2728
|
+
// ⟨0.16⟩ the ratchet fires only on gaining a REAL boundary effect. An Unknown-ONLY gain is
|
|
2729
|
+
// the §4 trust marker, not an effect (`pure` policies exclude it), and on version bumps it is
|
|
2730
|
+
// dominated by resolution noise — DISCLOSE it (advisory), never fail the gate on it. Mirrors the
|
|
2731
|
+
// reference engine (candor-scan gate.rs check_baseline).
|
|
2732
|
+
const real = gained.filter((x) => x !== "Unknown");
|
|
2733
|
+
if (!real.length) { unknownOnly.push(name); continue; }
|
|
2734
|
+
gateViolations.push({ rule: "AS-EFF-005", fn: name, effects: real,
|
|
2735
|
+
detail: `\`${name}\` gained effect { ${real.join(", ")} } not present in the baseline` });
|
|
2736
|
+
}
|
|
2737
|
+
if (unknownOnly.length) {
|
|
2738
|
+
unknownOnly.sort();
|
|
2739
|
+
const shown = unknownOnly.slice(0, 3).join(", ");
|
|
2740
|
+
const more = unknownOnly.length > 3 ? ` (+${unknownOnly.length - 3} more)` : "";
|
|
2741
|
+
console.error(`candor-ts: note — ${unknownOnly.length} function(s) gained an unresolved call `
|
|
2742
|
+
+ `(Unknown) vs the baseline but no real effect — advisory, NOT a regression (Unknown is the §4 `
|
|
2743
|
+
+ `trust marker, dominated by resolution noise on version bumps): ${shown}${more}`);
|
|
2440
2744
|
}
|
|
2441
2745
|
}
|
|
2442
2746
|
}
|
|
@@ -2481,7 +2785,17 @@ for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
|
|
|
2481
2785
|
// the SAME gateViolations that set the exit code (so it can't disagree). Written whenever the flag is set —
|
|
2482
2786
|
// ok:true,[] when no gate is configured. Must precede the exit(1) below.
|
|
2483
2787
|
if (gateJsonPath) {
|
|
2484
|
-
const
|
|
2788
|
+
const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0, violations: gateViolations };
|
|
2789
|
+
// ⟨0.15 staged⟩ coverage ADVISORY (COVERAGE-DESIGN.md §3): when the κ ledger is non-empty, the
|
|
2790
|
+
// verdict discloses what the gate could NOT see — VERDICT-PRESERVING (the ⟨0.9⟩ provable-purity
|
|
2791
|
+
// auto-disclosure precedent exactly): ok/violations/exit are computed above and untouched here. A
|
|
2792
|
+
// gate does not fail on uncovered deps (nearly every real scan has some); the policy author sees
|
|
2793
|
+
// the note and decides — `deny Unknown` remains the opt-in strict posture. OMITTED when fully
|
|
2794
|
+
// covered, so a pre-0.15 consumer's verdict is byte-identical.
|
|
2795
|
+
if (uncoveredLedger.length) {
|
|
2796
|
+
verdictObj.coverage = { uncovered: uncoveredLedger.length, packages: uncoveredLedger.map(([p]) => p) };
|
|
2797
|
+
}
|
|
2798
|
+
const verdict = JSON.stringify(verdictObj, null, 1);
|
|
2485
2799
|
if (gateJsonPath === "-") console.log(verdict);
|
|
2486
2800
|
else {
|
|
2487
2801
|
// The verdict is a SURFACING side-output: an unwritable path must be one stderr line, never a raw
|
|
@@ -2493,6 +2807,10 @@ if (gateJsonPath) {
|
|
|
2493
2807
|
// gateViolations is non-empty only when a gate surface (policy / baseline) was active and fired.
|
|
2494
2808
|
if (gateViolations.length) {
|
|
2495
2809
|
console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
|
|
2810
|
+
// FAILURE-only pointer at the engine's own remedy verb (append-only, same stream as the summary; a
|
|
2811
|
+
// zero-violation run is byte-identical — the exit code, violation lines and summary text are pinned
|
|
2812
|
+
// by the conformance suite and stay untouched).
|
|
2813
|
+
console.error("→ candor-ts-query fix-gate names the remedy for each");
|
|
2496
2814
|
process.exit(1);
|
|
2497
2815
|
}
|
|
2498
2816
|
if (policyPath !== null) console.error("candor-ts: policy ✓");
|