candor-ts 0.15.0 → 0.17.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/package.json +2 -2
- package/query.mjs +184 -20
- package/scan.mjs +145 -22
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.17)."*
|
|
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.17" }, 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.17.x, speaking candor-spec 0.17: 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/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.17.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.17)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/query.mjs
CHANGED
|
@@ -41,6 +41,96 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
41
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
|
+
// The §6 effect vocabulary — used to reject a typo'd effect name in `where` (corpus-audit #3). Kept in step
|
|
45
|
+
// with SPEC §6 / the umbrella's list; an unknown name PRESENT in a report (a spec extension) is still allowed.
|
|
46
|
+
const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
|
|
47
|
+
|
|
48
|
+
// ---- #8 output mode: PROSE at a TTY, JSON when piped or `--json` — so interactive `candor where Db` reads
|
|
49
|
+
// like candor-java/-rust instead of dumping raw JSON, while a pipe/redirect (never a TTY) still yields the
|
|
50
|
+
// pinned JSON untouched. MCP/LSP call query-core directly (not this CLI), so they're unaffected; conformance
|
|
51
|
+
// passes `--json` or captures over a pipe → JSON. `--json` forces JSON; `--text`/`--human` forces prose. -----
|
|
52
|
+
const wantJsonOut = (a) =>
|
|
53
|
+
a.includes("--json") || (!a.includes("--text") && !a.includes("--human") && !process.stdout.isTTY);
|
|
54
|
+
// Emit the pinned JSON, or render prose via proseFn(data). Returns data so the caller can still exit on it.
|
|
55
|
+
const put = (a, data, proseFn) => { if (!proseFn || wantJsonOut(a)) emit(data); else proseFn(data); return data; };
|
|
56
|
+
const csv = (xs) => (xs && xs.length ? xs.join(", ") : "none");
|
|
57
|
+
const rows = (xs, pre = " ") => { for (const x of xs) console.log(pre + x); };
|
|
58
|
+
// Per-verb prose renderers. Read the SAME shapes query-core returns (so JSON and prose can't drift); kept
|
|
59
|
+
// terse and scannable, in candor's voice (cf. the existing `tour`/`path` human forms).
|
|
60
|
+
const P = {
|
|
61
|
+
where: (d) => {
|
|
62
|
+
const n = d.directly.length + d.inherited.length;
|
|
63
|
+
if (n === 0) { console.log(`candor: 0 functions perform ${d.effect} in this report.`); return; }
|
|
64
|
+
console.log(`candor where ${d.effect} — ${n} function${n === 1 ? "" : "s"}:`);
|
|
65
|
+
if (d.directly.length) { console.log(` perform it directly (${d.directly.length}):`); rows(d.directly); }
|
|
66
|
+
if (d.inherited.length) { console.log(` reach it transitively (${d.inherited.length}):`); rows(d.inherited); }
|
|
67
|
+
},
|
|
68
|
+
callers: (d) => {
|
|
69
|
+
if (!d.of.length) { console.log("candor: no function in the call graph matches that name."); return; }
|
|
70
|
+
console.log(`candor callers — who reaches \`${d.of.join("`, `")}\`:`);
|
|
71
|
+
console.log(` direct callers (${d.direct.length}): ${csv(d.direct)}`);
|
|
72
|
+
console.log(` transitive callers (${d.transitive.length}): ${csv(d.transitive)}`);
|
|
73
|
+
},
|
|
74
|
+
show: (d) => {
|
|
75
|
+
if (!d.length) { console.log("candor: no effectful function matches that name (pure functions are omitted from the report)."); return; }
|
|
76
|
+
d.forEach((e, i) => {
|
|
77
|
+
if (i) console.log("");
|
|
78
|
+
console.log(`${e.fn}`);
|
|
79
|
+
console.log(` effects: ${csv(e.inferred)}${e.direct && e.direct.length ? ` (direct: ${e.direct.join(", ")})` : ""}`);
|
|
80
|
+
if (e.hosts?.length) console.log(` hosts: ${e.hosts.join(", ")}`);
|
|
81
|
+
if (e.cmds?.length) console.log(` cmds: ${e.cmds.join(", ")}`);
|
|
82
|
+
if (e.paths?.length) console.log(` paths: ${e.paths.join(", ")}`);
|
|
83
|
+
if (e.tables?.length) console.log(` tables: ${e.tables.join(", ")}`);
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
map: (d) => {
|
|
87
|
+
const mods = Object.entries(d);
|
|
88
|
+
if (!mods.length) { console.log("candor: no effectful modules in this report."); return; }
|
|
89
|
+
console.log("candor map — effects by module:");
|
|
90
|
+
for (const [m, v] of mods) console.log(` ${m} — ${csv(v.effects)} (${v.functions} fn${v.functions === 1 ? "" : "s"})`);
|
|
91
|
+
},
|
|
92
|
+
containment: (d) => {
|
|
93
|
+
if ("leaks" in d) { // ratchet (a baseline was given)
|
|
94
|
+
if (!d.leaks.length) console.log("candor containment — no boundary effect reached a new layer vs the baseline. ✓");
|
|
95
|
+
else { console.log(`candor containment — ${d.leaks.length} boundary effect(s) reached a NEW layer (leak):`); rows(d.leaks); }
|
|
96
|
+
if (d.cleanups && d.cleanups.length) { console.log(` no longer present (${d.cleanups.length}):`); rows(d.cleanups); }
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!d.contained.length && !Object.keys(d.ambient).length) { console.log("candor containment — no boundary effects in this report."); return; }
|
|
100
|
+
console.log("candor containment — how well each boundary effect stays in one layer:");
|
|
101
|
+
for (const c of d.contained)
|
|
102
|
+
console.log(` ${c.effect}: ${c.containmentPct}% in \`${c.owner}\` (spread across ${c.layers} layer${c.layers === 1 ? "" : "s"})`);
|
|
103
|
+
const amb = Object.entries(d.ambient);
|
|
104
|
+
if (amb.length) console.log(` ambient (reported, not scored): ${amb.map(([e, n]) => `${e}×${n}`).join(", ")}`);
|
|
105
|
+
},
|
|
106
|
+
reachable: (d) => {
|
|
107
|
+
const effs = Object.entries(d.effects);
|
|
108
|
+
console.log(`candor reachable — what the ${d.entryPoints} entry point${d.entryPoints === 1 ? "" : "s"} do at runtime:`);
|
|
109
|
+
if (!effs.length) { console.log(" no effect reaches an entry point."); return; }
|
|
110
|
+
for (const [e, v] of effs) console.log(` ${e}: ${v.count} (via ${csv(v.via)})`);
|
|
111
|
+
},
|
|
112
|
+
impact: (d) => {
|
|
113
|
+
console.log(`candor impact — the blast radius of \`${d.fn}\`:`);
|
|
114
|
+
console.log(` ${d.affectedCount} effectful function(s) transitively call it${d.affected.length ? ":" : "."}`);
|
|
115
|
+
if (d.affected.length) rows(d.affected);
|
|
116
|
+
if (d.entryPoints.length) { console.log(` reachable from ${d.entryPoints.length} entry point(s):`); rows(d.entryPoints.map((ep) => `${ep.fn} [${csv(ep.inferred)}]`)); }
|
|
117
|
+
},
|
|
118
|
+
blindspots: (d) => {
|
|
119
|
+
if (!d.sources.length) { console.log(`candor blindspots — no Unknown sources${d.totalUnknown ? " (all Unknown here is inherited, not rooted in a call)" : ""}. ✓`); return; }
|
|
120
|
+
console.log(`candor blindspots — ${d.sources.length} Unknown source${d.sources.length === 1 ? "" : "s"} (of ${d.totalUnknown} function(s) carrying Unknown), most-smearing first:`);
|
|
121
|
+
for (const s of d.sources) console.log(` \`${s.fn}\` — ${csv(s.why)}; reaches ${s.reaches} caller(s)`);
|
|
122
|
+
},
|
|
123
|
+
gains: (d) => {
|
|
124
|
+
if (!d.gained.length) { console.log("candor gains — no newly-reached effects vs the baseline. ✓"); return; }
|
|
125
|
+
console.log(`candor gains — the surface newly reaches: ${d.gained.join(", ")}`);
|
|
126
|
+
for (const g of d.byFunction) console.log(` \`${g.fn}\` gained ${g.effect}${g.origin ? ` (${g.origin})` : ""}`);
|
|
127
|
+
},
|
|
128
|
+
diff: (d) => {
|
|
129
|
+
if (!d.changes.length) { console.log("candor diff — no effect changes vs the baseline. ✓"); return; }
|
|
130
|
+
console.log(`candor diff — ${d.changes.length} function(s) changed vs the baseline:`);
|
|
131
|
+
for (const c of d.changes) console.log(` \`${c.fn}\`${c.gained.length ? ` +${c.gained.join(",")}` : ""}${c.lost.length ? ` -${c.lost.join(",")}` : ""}`);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
44
134
|
|
|
45
135
|
// Render `path` in HUMAN (non-`--json`) form — the indented provenance chain, BYTE-IDENTICAL to the
|
|
46
136
|
// Rust reference (candor-query/src/callers.rs) and the Java port (Query.java). The `--json` shape is
|
|
@@ -94,7 +184,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
94
184
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
95
185
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
96
186
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
97
|
-
const SPEC_VERSION = "0.
|
|
187
|
+
const SPEC_VERSION = "0.17";
|
|
98
188
|
|
|
99
189
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
100
190
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -186,7 +276,8 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
|
|
|
186
276
|
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
|
|
187
277
|
policyFile = rawArgs[++i]; continue;
|
|
188
278
|
}
|
|
189
|
-
if (a === "--json") { continue; }
|
|
279
|
+
if (a === "--json" || a === "--text" || a === "--human") { continue; } // output-mode flags (#8) — consumed by
|
|
280
|
+
// wantJsonOut(rawArgs), never a positional
|
|
190
281
|
if (strict && a === "--strict") { wantStrict = true; continue; }
|
|
191
282
|
if (includeUnknown && a === "--include-unknown") { wantIncludeUnknown = true; continue; }
|
|
192
283
|
positionals.push(a);
|
|
@@ -307,7 +398,7 @@ const SUBCOMMANDS = [
|
|
|
307
398
|
const usage = () => {
|
|
308
399
|
const w = Math.max(...SUBCOMMANDS.map(([n, a]) => `${n} ${a}`.trimEnd().length));
|
|
309
400
|
const lines = SUBCOMMANDS.map(([n, a, d]) => ` ${`${n} ${a}`.trimEnd().padEnd(w)} ${d}`);
|
|
310
|
-
lines.push(` ${"-V, --version".padEnd(w)} print the
|
|
401
|
+
lines.push(` ${"-V, --version".padEnd(w)} print the installed version + upgrade line (offline)`);
|
|
311
402
|
lines.push(` ${"-h, --help".padEnd(w)} show this help`);
|
|
312
403
|
return `USAGE: candor-ts-query <command> [args]\n\n${lines.join("\n")}`;
|
|
313
404
|
};
|
|
@@ -321,12 +412,55 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
|
321
412
|
}
|
|
322
413
|
|
|
323
414
|
// -h / --help: a print-and-exit MODE, handled before the switch (so `-h`'s single dash is never
|
|
324
|
-
// mistaken for a command).
|
|
415
|
+
// mistaken for a command). House-style page: identity + model paragraph + COMMON/ALL ACTIONS
|
|
416
|
+
// (the action names derived from SUBCOMMANDS, so the list can never go stale) + OPTIONS + footer.
|
|
417
|
+
// The exit-2 error path keeps the denser fully-described usage() above.
|
|
325
418
|
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
326
|
-
|
|
419
|
+
const names = SUBCOMMANDS.map(([n]) => n);
|
|
420
|
+
const allActions = [names.slice(0, 9), names.slice(9)].map((row) => ` ${row.join(" ")}`).join("\n");
|
|
421
|
+
console.log(`candor-ts-query — read-only queries over a candor report.
|
|
422
|
+
|
|
423
|
+
Answers come from the report candor-ts wrote — discovered by walking up from the
|
|
424
|
+
cwd to a .candor/ dir (CANDOR_REPORT overrides; --report pins a locator). No
|
|
425
|
+
re-scan, no network. Every engine speaks the same grammar, so these actions and
|
|
426
|
+
flags match the rest of the family.
|
|
427
|
+
|
|
428
|
+
USAGE
|
|
429
|
+
candor-ts-query <action> [args] [options]
|
|
327
430
|
|
|
328
|
-
|
|
431
|
+
COMMON ACTIONS
|
|
432
|
+
where <Effect> the functions that perform an effect
|
|
433
|
+
path <fn> <Effect> the call path by which a function reaches an effect
|
|
434
|
+
callers <fn> who calls a function, direct and transitive
|
|
435
|
+
tour [N] the N most surprising transitive reaches (default 10)
|
|
436
|
+
blindspots the Unknown sources worth resolving, ranked by reach
|
|
437
|
+
gains <current> <base> what a new version newly reaches (the supply-chain diff)
|
|
438
|
+
fix <fn> <Effect> the boundary hoist that would clear a violation
|
|
329
439
|
|
|
440
|
+
ALL ACTIONS
|
|
441
|
+
${allActions}
|
|
442
|
+
|
|
443
|
+
OPTIONS (uniform across every engine)
|
|
444
|
+
--report <locator> use this report instead of discovering .candor/
|
|
445
|
+
--policy <file> evaluate a policy — exit 1 on a violation (whatif, fix, fix-gate,
|
|
446
|
+
unverified; CANDOR_POLICY / a .candor/config \`policy\` key when absent)
|
|
447
|
+
--json machine-readable JSON (the default when output is piped/redirected)
|
|
448
|
+
--text, --human human-readable prose (the default at a terminal)
|
|
449
|
+
--include-unknown callers: also list the unresolved-dispatch frontier
|
|
450
|
+
--strict unverified: exit 1 on an unverified hole (advisory otherwise)
|
|
451
|
+
-V, --version print the installed version + upgrade line (offline)
|
|
452
|
+
-h, --help show this help
|
|
453
|
+
|
|
454
|
+
diff and gains take two positional report locators: <current> <baseline>. Run
|
|
455
|
+
candor-ts-query with no action for the full per-action argument list.
|
|
456
|
+
|
|
457
|
+
EXAMPLES
|
|
458
|
+
candor-ts-query where Db
|
|
459
|
+
candor-ts-query path app.orders.render Net
|
|
460
|
+
candor-ts-query gains new/.candor/report.json old/.candor/report.json
|
|
461
|
+
candor-ts-query fix-gate --policy candor.policy
|
|
462
|
+
|
|
463
|
+
Docs: candor.poly.io · Verify an install: candor doctor
|
|
330
464
|
See https://github.com/tombaldwin/candor`);
|
|
331
465
|
process.exit(0);
|
|
332
466
|
}
|
|
@@ -354,7 +488,10 @@ switch (cmd) {
|
|
|
354
488
|
// written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
|
|
355
489
|
// the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
|
|
356
490
|
const { prefix, args: [q] } = resolveReportVerb(args, 1);
|
|
357
|
-
|
|
491
|
+
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
|
|
492
|
+
// `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
|
|
493
|
+
if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
|
|
494
|
+
put(args, coreShow(loadReportOrDie(prefix), q), P.show);
|
|
358
495
|
break;
|
|
359
496
|
}
|
|
360
497
|
case "where": {
|
|
@@ -362,7 +499,18 @@ switch (cmd) {
|
|
|
362
499
|
// Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
|
|
363
500
|
// fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
|
|
364
501
|
const { prefix, args: [eff] } = resolveReportVerb(args, 1);
|
|
365
|
-
|
|
502
|
+
// A missing/empty <Effect> is a LOUD usage error (exit 2, like candor-java's missing-arg path) —
|
|
503
|
+
// never an authoritative-empty {directly:[],inherited:[]} at exit 0 (a false all-clear shape).
|
|
504
|
+
if (!eff) { console.error("usage: candor-ts-query where <Effect> [--report <locator>] [--json]"); process.exit(2); }
|
|
505
|
+
// A typo'd / unknown effect NAME is a LOUD error (exit 2) — never a false-empty {directly:[],inherited:[]}
|
|
506
|
+
// at exit 0, which reads as an authoritative "nothing performs Net" when the user actually typed "Network"
|
|
507
|
+
// (corpus-audit #3). A KNOWN effect that is simply absent stays a valid 0-result; an unknown name that is
|
|
508
|
+
// PRESENT in the report (a spec extension effect) is allowed — so error only when the name is NEITHER.
|
|
509
|
+
const fnsW = loadReportOrDie(prefix);
|
|
510
|
+
if (!KNOWN_EFFECTS.includes(eff) && !new Set(fnsW.flatMap((e) => e.inferred || [])).has(eff)) {
|
|
511
|
+
console.error(`candor-ts-query where: unknown effect '${eff}' (known: ${KNOWN_EFFECTS.join(", ")})`); process.exit(2);
|
|
512
|
+
}
|
|
513
|
+
put(args, coreWhere(fnsW, eff), P.where);
|
|
366
514
|
break;
|
|
367
515
|
}
|
|
368
516
|
case "callers": {
|
|
@@ -370,15 +518,24 @@ switch (cmd) {
|
|
|
370
518
|
// it, the byte-for-byte {of,direct,transitive} shape is unchanged (cross-engine parity). Call the
|
|
371
519
|
// shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
|
|
372
520
|
const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
|
|
521
|
+
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an empty
|
|
522
|
+
// {of:[],direct:[],transitive:[]} at exit 0 (reads as "nothing reaches it" for a fn never named).
|
|
523
|
+
if (!q) { console.error("usage: candor-ts-query callers <query> [--include-unknown] [--report <locator>] [--json]"); process.exit(2); }
|
|
373
524
|
const cg = loadCallgraph(prefix);
|
|
374
|
-
|
|
375
|
-
|
|
525
|
+
const cres = includeUnknown ? callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q) : coreCallers(cg, q);
|
|
526
|
+
// A nonexistent function is a LOUD error (exit 2), like path/impact — never an empty {of:[],direct:[],
|
|
527
|
+
// transitive:[]} at exit 0, which reads as an authoritative "nothing calls it" for a fn that doesn't exist
|
|
528
|
+
// (corpus-audit #3). Gated on a NON-empty callgraph so a missing sidecar isn't misreported as "no such fn".
|
|
529
|
+
if (Object.keys(cg).length > 0 && cres.of.length === 0) {
|
|
530
|
+
console.error(`candor-ts-query callers: no function matching '${q}' in the call graph`); process.exit(2);
|
|
531
|
+
}
|
|
532
|
+
put(args, cres, P.callers);
|
|
376
533
|
break;
|
|
377
534
|
}
|
|
378
535
|
case "map": {
|
|
379
536
|
// Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
|
|
380
537
|
const { prefix } = resolveReportVerb(args, 0);
|
|
381
|
-
|
|
538
|
+
put(args, coreMap(loadReportOrDie(prefix)), P.map);
|
|
382
539
|
break;
|
|
383
540
|
}
|
|
384
541
|
case "containment": {
|
|
@@ -408,10 +565,10 @@ switch (cmd) {
|
|
|
408
565
|
process.exit(2);
|
|
409
566
|
}
|
|
410
567
|
const r = coreContainment(loadReportOrDie(prefix), baseFns);
|
|
411
|
-
|
|
568
|
+
put(args, r, P.containment);
|
|
412
569
|
process.exit(r.leaks.length ? 1 : 0);
|
|
413
570
|
}
|
|
414
|
-
|
|
571
|
+
put(args, coreContainment(loadReportOrDie(prefix)), P.containment);
|
|
415
572
|
break;
|
|
416
573
|
}
|
|
417
574
|
case "diff": {
|
|
@@ -440,7 +597,7 @@ switch (cmd) {
|
|
|
440
597
|
const versionMismatch = engineV && baseV && engineV !== baseV;
|
|
441
598
|
if (versionMismatch)
|
|
442
599
|
console.error(`candor-ts: ⚠ baseline @${baseV} ≠ engine @${engineV} — some changes may be the engine reclassifying, not your code. Treat an engine swap as baseline-invalidating: review, then regenerate the baseline.`);
|
|
443
|
-
|
|
600
|
+
put(args, { baseline_version: baseV ?? "", engine_version: engineV ?? "", changes }, P.diff);
|
|
444
601
|
// diff DISCLOSES (the posture) — it is not a gate. Its gained-effect exit 1 is a convenience for
|
|
445
602
|
// same-build ratchet use; under a version mismatch that signal is BOGUS (unmasking, not regression),
|
|
446
603
|
// so exit 0 and let the ⚠ inform — never deliver the wave as a CI failure (review §2.1: guards fail
|
|
@@ -456,23 +613,26 @@ switch (cmd) {
|
|
|
456
613
|
const roots = fns.filter((e) => e.entryPoint);
|
|
457
614
|
const byEff = {};
|
|
458
615
|
for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
|
|
459
|
-
|
|
616
|
+
put(args, { entryPoints: roots.length,
|
|
460
617
|
effects: Object.fromEntries(Object.entries(byEff).sort()
|
|
461
|
-
.map(([k, v]) => [k, { count: v.length, via: v.sort() }])) });
|
|
618
|
+
.map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable);
|
|
462
619
|
break;
|
|
463
620
|
}
|
|
464
621
|
case "impact": {
|
|
465
622
|
// blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
|
|
466
623
|
// MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
|
|
467
624
|
const { prefix, args: [q] } = resolveReportVerb(args, 1);
|
|
468
|
-
|
|
625
|
+
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an
|
|
626
|
+
// affectedCount:0 blast radius at exit 0 for a function that was never named.
|
|
627
|
+
if (!q) { console.error("usage: candor-ts-query impact <query> [--report <locator>] [--json]"); process.exit(2); }
|
|
628
|
+
put(args, coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q), P.impact);
|
|
469
629
|
break;
|
|
470
630
|
}
|
|
471
631
|
case "blindspots": {
|
|
472
632
|
// the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
|
|
473
633
|
// Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
|
|
474
634
|
const { prefix } = resolveReportVerb(args, 0);
|
|
475
|
-
|
|
635
|
+
put(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)), P.blindspots);
|
|
476
636
|
break;
|
|
477
637
|
}
|
|
478
638
|
case "tour": {
|
|
@@ -576,9 +736,9 @@ switch (cmd) {
|
|
|
576
736
|
// read as total), plus `coverageDelta` when the baseline names different blind packages. Both
|
|
577
737
|
// OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
|
|
578
738
|
// Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
|
|
579
|
-
|
|
739
|
+
put(args, { baseline_version: gbv ?? "", engine_version: gv ?? "",
|
|
580
740
|
...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)),
|
|
581
|
-
...gainsCoverage(curPrefix, basePrefix) });
|
|
741
|
+
...gainsCoverage(curPrefix, basePrefix) }, P.gains);
|
|
582
742
|
break;
|
|
583
743
|
}
|
|
584
744
|
case "path": {
|
|
@@ -587,6 +747,10 @@ switch (cmd) {
|
|
|
587
747
|
// pinned JSON shape. parseCanonical otherwise swallows --json, so detect it explicitly (as `tour` does).
|
|
588
748
|
const wantJson = args.includes("--json");
|
|
589
749
|
const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
|
|
750
|
+
// BOTH positionals are required (`path <fn> <Effect>`) — a missing/empty one is a LOUD usage error
|
|
751
|
+
// (exit 2, like candor-java). Before this gate, one arg slid through as `<fn> undefined` and printed
|
|
752
|
+
// "does not perform undefined" at exit 0 — a false all-clear over a question that was never posed.
|
|
753
|
+
if (!fn || !eff) { console.error("usage: candor-ts-query path <fn> <Effect> [--report <locator>] [--json]"); process.exit(2); }
|
|
590
754
|
const fns = loadReportOrDie(prefix);
|
|
591
755
|
const cg = loadCallgraph(prefix);
|
|
592
756
|
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.17";
|
|
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
|
}
|
|
@@ -1554,6 +1574,32 @@ const identIsEnvAlias = (id) => {
|
|
|
1554
1574
|
// The receiver expression READS process.env — it is either `process.env` itself or a confirmed alias.
|
|
1555
1575
|
const readsProcessEnv = (expr) => isProcessEnvExpr(expr) || identIsEnvAlias(expr);
|
|
1556
1576
|
|
|
1577
|
+
// A bare-identifier call whose callee is DEFAULT- or NAMED-imported from a known HTTP-client package is a
|
|
1578
|
+
// Net call (corpus-audit #13). The κ table lists these packages, but its rule only fires on a MEMBER call
|
|
1579
|
+
// (`axios.get(…)`); a default-imported callable invoked bare — the canonical `import fetch from 'node-fetch';
|
|
1580
|
+
// fetch(url)` — resolves to no signature when the package isn't installed, so it read Unknown (callback:fetch)
|
|
1581
|
+
// instead of Net, the effect users most care about. Resolve the identifier's symbol up to its
|
|
1582
|
+
// ImportDeclaration and match the specifier; used both to CLASSIFY the call Net and to SUPPRESS the spurious
|
|
1583
|
+
// callback-Unknown for the same node.
|
|
1584
|
+
const NET_REQUEST_NAMED = new Set(["fetch", "request", "stream", "pipeline"]); // undici/node-fetch callables
|
|
1585
|
+
const importedFromNetPkg = (id) => {
|
|
1586
|
+
if (!id || !ts.isIdentifier(id)) return false;
|
|
1587
|
+
for (const d of checker.getSymbolAtLocation(id)?.declarations ?? []) {
|
|
1588
|
+
let n = d;
|
|
1589
|
+
while (n && !ts.isImportDeclaration(n)) n = n.parent;
|
|
1590
|
+
if (!(n && ts.isImportDeclaration(n) && ts.isStringLiteralLike(n.moduleSpecifier)
|
|
1591
|
+
&& /^(node-fetch|undici|axios|got|superagent|phin)$/.test(n.moduleSpecifier.text))) continue;
|
|
1592
|
+
// Only the package's CLIENT CALLABLE is Net: the DEFAULT import (`import fetch from 'node-fetch'`,
|
|
1593
|
+
// `import got from 'got'`) or a NAMED request function (`import { fetch, request } from 'undici'`). A
|
|
1594
|
+
// named CLASS/utility export — `Headers`, `Response`, `Request`, `CookieJar`, `FormData` — is NOT a
|
|
1595
|
+
// request and must not be over-reported as Net (review finding). Namespace imports resolve via κ member
|
|
1596
|
+
// calls elsewhere, not as a bare callable here.
|
|
1597
|
+
if (ts.isImportClause(d)) return true; // default import = the client
|
|
1598
|
+
if (ts.isImportSpecifier(d) && NET_REQUEST_NAMED.has((d.propertyName ?? d.name).text)) return true;
|
|
1599
|
+
}
|
|
1600
|
+
return false;
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1557
1603
|
// ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
|
|
1558
1604
|
function visitCalls(node) {
|
|
1559
1605
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
@@ -1610,6 +1656,10 @@ function visitCalls(node) {
|
|
|
1610
1656
|
}
|
|
1611
1657
|
if (kEff) {
|
|
1612
1658
|
rec.direct.add(kEff); // κ-modeled package reached via an uninstalled namespace import
|
|
1659
|
+
} else if (ts.isCallExpression(node) && importedFromNetPkg(node.expression)) {
|
|
1660
|
+
rec.direct.add("Net"); // bare call to an HTTP-client default/named import whose pkg isn't installed
|
|
1661
|
+
// (so its signature didn't resolve) — Net, not Unknown (#13). Host capture
|
|
1662
|
+
// happens in the global/builtin arm below, which fires for the same node.
|
|
1613
1663
|
} else {
|
|
1614
1664
|
rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
|
|
1615
1665
|
const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
|
|
@@ -2143,6 +2193,9 @@ function visitCalls(node) {
|
|
|
2143
2193
|
};
|
|
2144
2194
|
if ((ctext === "process.hrtime" || ctext === "process.hrtime.bigint") && processIsGlobal()) geff = "Clock";
|
|
2145
2195
|
else if (ctext === "process.send" && processIsGlobal()) geff = "Ipc";
|
|
2196
|
+
else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
|
|
2197
|
+
geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
|
|
2198
|
+
|
|
2146
2199
|
else if (ts.isIdentifier(callee) && callee.text === "fetch"
|
|
2147
2200
|
&& !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
|
|
2148
2201
|
.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
|
|
@@ -2616,6 +2669,20 @@ let gateViolations = [];
|
|
|
2616
2669
|
// · Valid + same build → per-fn compare: an EXISTING fn gaining an effect is an [AS-EFF-005]
|
|
2617
2670
|
// violation (exit 1, joins --gate-json); a fn absent from the baseline is NEW code, reviewed as
|
|
2618
2671
|
// such, not a regression. Baselines omit pure fns (spec §2), so absent-prior means no prior claim.
|
|
2672
|
+
//
|
|
2673
|
+
// ⟨0.16⟩ Callgraph-aware existence (SPEC §7 item 5). Reports OMIT pure functions, so a fn that
|
|
2674
|
+
// shipped PURE and now performs an effect is absent from the baseline report and reads as exempt "new
|
|
2675
|
+
// code" — the sharpest supply-chain shape escaping the guard. Fix: key existence on the baseline
|
|
2676
|
+
// CALLGRAPH sidecar (<baseline>.callgraph.json, §2.2 — it lists every project fn INCLUDING pure
|
|
2677
|
+
// leaves), exactly as `gains`'s `origin` existence test does (query-core.mjs `gains`: a fn is
|
|
2678
|
+
// "existing" if it is a baseline-callgraph node — a caller key or a callee):
|
|
2679
|
+
// · sidecar PRESENT + loaded → a fn that is a baseline-callgraph node has baseline effect set ∅
|
|
2680
|
+
// (pure → omitted from the report) and any effect now is a GAIN violation. pure→effectful is caught.
|
|
2681
|
+
// A fn in NEITHER report nor callgraph genuinely did not exist → stays exempt "new".
|
|
2682
|
+
// · sidecar ABSENT → degrade to report-only existence (pre-⟨0.16⟩: a formerly-pure fn reads as new;
|
|
2683
|
+
// still catches an already-effectful fn WIDENING). One stderr note that the guard is weaker.
|
|
2684
|
+
// · sidecar PRESENT-but-CORRUPT → fail closed (exit 2), like a corrupt baseline: a broken sidecar
|
|
2685
|
+
// must not silently NARROW the guard back to report-only.
|
|
2619
2686
|
if (baselinePath !== null) {
|
|
2620
2687
|
const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
|
|
2621
2688
|
if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
@@ -2647,14 +2714,66 @@ if (baselinePath !== null) {
|
|
|
2647
2714
|
for (const e of arr) {
|
|
2648
2715
|
if (e && typeof e.fn === "string" && e.fn) base.set(e.fn, new Set(Array.isArray(e.inferred) ? e.inferred : []));
|
|
2649
2716
|
}
|
|
2717
|
+
// ⟨0.16⟩ Load the baseline callgraph sidecar next to the baseline report. The sidecar for a
|
|
2718
|
+
// report at <stem>.json is <stem>.callgraph.json (scan.mjs writes exactly this pair). Three states:
|
|
2719
|
+
// loaded — a parsed object → its node set (every key + every callee) keys existence, mirroring
|
|
2720
|
+
// the `gains` origin test (query-core.mjs). A baseline-callgraph node whose baseline
|
|
2721
|
+
// effects are ∅ (pure → omitted from the report) that now performs an effect is a GAIN.
|
|
2722
|
+
// absent — no sidecar file → degrade to report-only existence + one stderr note (guard weaker).
|
|
2723
|
+
// corrupt — file present but not parseable / not a plain object → fail closed (exit 2). A broken
|
|
2724
|
+
// sidecar must not silently narrow the guard (SPEC §7 item 5).
|
|
2725
|
+
// shownB may be "(configured empty)"; the real path is baselinePath here (non-null, exists).
|
|
2726
|
+
const sidecarPath = baselinePath.replace(/\.json$/i, "") + ".callgraph.json";
|
|
2727
|
+
let cgNodes = null; // null = sidecar absent (report-only degrade)
|
|
2728
|
+
if (fs.existsSync(sidecarPath)) {
|
|
2729
|
+
let baseCg = null;
|
|
2730
|
+
try { baseCg = JSON.parse(fs.readFileSync(sidecarPath, "utf8")); } catch { baseCg = undefined; }
|
|
2731
|
+
// A non-object parse (null / array / number) is a corrupt sidecar: it cannot list nodes, and
|
|
2732
|
+
// treating it as "absent" would silently narrow the guard — fail closed like a corrupt baseline.
|
|
2733
|
+
if (baseCg === undefined || baseCg === null || typeof baseCg !== "object" || Array.isArray(baseCg)) {
|
|
2734
|
+
console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
|
|
2735
|
+
+ `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
|
|
2736
|
+
+ `report-only. Regenerate the baseline with this build.`);
|
|
2737
|
+
process.exit(2);
|
|
2738
|
+
}
|
|
2739
|
+
// The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
|
|
2740
|
+
// as `gains` computes cgNodes. Non-array edge values are tolerated (skipped), matching loadCallgraph.
|
|
2741
|
+
cgNodes = new Set(Object.entries(baseCg).flatMap(([k, vs]) => [k, ...(Array.isArray(vs) ? vs : [])]));
|
|
2742
|
+
} else {
|
|
2743
|
+
console.error(`candor-ts: no baseline callgraph sidecar at ${sidecarPath} — the AS-EFF-005 guard is `
|
|
2744
|
+
+ `WEAKER: existence falls back to the report, which omits pure functions, so a formerly-PURE fn `
|
|
2745
|
+
+ `turning effectful reads as new code and is NOT caught (only an already-effectful fn widening is). `
|
|
2746
|
+
+ `Regenerate the baseline with --out so the .callgraph.json is written alongside it.`);
|
|
2747
|
+
}
|
|
2748
|
+
const unknownOnly = []; // ⟨0.16⟩ advisory: fns that gained ONLY Unknown vs the baseline
|
|
2650
2749
|
for (const name of [...inferred.keys()].sort()) {
|
|
2651
2750
|
const prior = base.get(name);
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2751
|
+
// ⟨0.16⟩ Existence ladder: in the baseline REPORT → its recorded inferred set is the prior;
|
|
2752
|
+
// else a baseline-callgraph NODE (sidecar present) → it existed and was pure, so prior = ∅ (any
|
|
2753
|
+
// effect now is a gain); else genuinely absent → new code, exempt. Without the sidecar (cgNodes
|
|
2754
|
+
// null) only the report path decides, the pre-⟨0.16⟩ semantics.
|
|
2755
|
+
const priorSet = prior !== undefined ? prior
|
|
2756
|
+
: (cgNodes !== null && cgNodes.has(name)) ? new Set() // baseline-pure node → ∅ prior
|
|
2757
|
+
: null; // new function — not a regression
|
|
2758
|
+
if (priorSet === null) continue;
|
|
2759
|
+
const gained = [...inferred.get(name)].filter((x) => !priorSet.has(x)).sort();
|
|
2760
|
+
if (!gained.length) continue;
|
|
2761
|
+
// ⟨0.16⟩ the ratchet fires only on gaining a REAL boundary effect. An Unknown-ONLY gain is
|
|
2762
|
+
// the §4 trust marker, not an effect (`pure` policies exclude it), and on version bumps it is
|
|
2763
|
+
// dominated by resolution noise — DISCLOSE it (advisory), never fail the gate on it. Mirrors the
|
|
2764
|
+
// reference engine (candor-scan gate.rs check_baseline).
|
|
2765
|
+
const real = gained.filter((x) => x !== "Unknown");
|
|
2766
|
+
if (!real.length) { unknownOnly.push(name); continue; }
|
|
2767
|
+
gateViolations.push({ rule: "AS-EFF-005", fn: name, effects: real,
|
|
2768
|
+
detail: `\`${name}\` gained effect { ${real.join(", ")} } not present in the baseline` });
|
|
2769
|
+
}
|
|
2770
|
+
if (unknownOnly.length) {
|
|
2771
|
+
unknownOnly.sort();
|
|
2772
|
+
const shown = unknownOnly.slice(0, 3).join(", ");
|
|
2773
|
+
const more = unknownOnly.length > 3 ? ` (+${unknownOnly.length - 3} more)` : "";
|
|
2774
|
+
console.error(`candor-ts: note — ${unknownOnly.length} function(s) gained an unresolved call `
|
|
2775
|
+
+ `(Unknown) vs the baseline but no real effect — advisory, NOT a regression (Unknown is the §4 `
|
|
2776
|
+
+ `trust marker, dominated by resolution noise on version bumps): ${shown}${more}`);
|
|
2658
2777
|
}
|
|
2659
2778
|
}
|
|
2660
2779
|
}
|
|
@@ -2721,6 +2840,10 @@ if (gateJsonPath) {
|
|
|
2721
2840
|
// gateViolations is non-empty only when a gate surface (policy / baseline) was active and fired.
|
|
2722
2841
|
if (gateViolations.length) {
|
|
2723
2842
|
console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
|
|
2843
|
+
// FAILURE-only pointer at the engine's own remedy verb (append-only, same stream as the summary; a
|
|
2844
|
+
// zero-violation run is byte-identical — the exit code, violation lines and summary text are pinned
|
|
2845
|
+
// by the conformance suite and stay untouched).
|
|
2846
|
+
console.error("→ candor-ts-query fix-gate names the remedy for each");
|
|
2724
2847
|
process.exit(1);
|
|
2725
2848
|
}
|
|
2726
2849
|
if (policyPath !== null) console.error("candor-ts: policy ✓");
|