candor-ts 0.17.0 → 0.18.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 +71 -18
- package/scan.mjs +1 -1
- package/surface.mjs +20 -2
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.18)."*
|
|
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.18" }, 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.18.x, speaking candor-spec 0.18: 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.18.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.18)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/query.mjs
CHANGED
|
@@ -44,6 +44,18 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
|
44
44
|
// The §6 effect vocabulary — used to reject a typo'd effect name in `where` (corpus-audit #3). Kept in step
|
|
45
45
|
// with SPEC §6 / the umbrella's list; an unknown name PRESENT in a report (a spec extension) is still allowed.
|
|
46
46
|
const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
|
|
47
|
+
// Suggest the nearest known flag for a typo (longest shared prefix ≥3): `--polciy` → `--policy` (#2).
|
|
48
|
+
function didYouMeanFlag(unknown) {
|
|
49
|
+
const known = ["--report", "--policy", "--json", "--text", "--strict", "--include-unknown"];
|
|
50
|
+
const u = unknown.replace(/^-+/, "").toLowerCase();
|
|
51
|
+
let best = null, bestLen = 2;
|
|
52
|
+
for (const k of known) {
|
|
53
|
+
const kn = k.replace(/^-+/, "");
|
|
54
|
+
let s = 0; while (s < u.length && s < kn.length && u[s] === kn[s]) s++;
|
|
55
|
+
if (s >= 3 && s > bestLen) { bestLen = s; best = k; }
|
|
56
|
+
}
|
|
57
|
+
return best ? ` — did you mean \`${best}\`?` : "";
|
|
58
|
+
}
|
|
47
59
|
|
|
48
60
|
// ---- #8 output mode: PROSE at a TTY, JSON when piped or `--json` — so interactive `candor where Db` reads
|
|
49
61
|
// like candor-java/-rust instead of dumping raw JSON, while a pipe/redirect (never a TTY) still yields the
|
|
@@ -184,7 +196,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
184
196
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
185
197
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
186
198
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
187
|
-
const SPEC_VERSION = "0.
|
|
199
|
+
const SPEC_VERSION = "0.18";
|
|
188
200
|
|
|
189
201
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
190
202
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -272,14 +284,21 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
|
|
|
272
284
|
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --report requires a <locator> value (a directory, a .json report path, or a prefix)"); process.exit(2); }
|
|
273
285
|
reportLocator = rawArgs[++i]; continue;
|
|
274
286
|
}
|
|
275
|
-
if (
|
|
287
|
+
if (a === "--policy") { // consumed for EVERY verb (a valid candor flag); used only by policy verbs
|
|
276
288
|
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
|
|
277
|
-
|
|
289
|
+
const v = rawArgs[++i]; if (policy) policyFile = v; continue;
|
|
278
290
|
}
|
|
279
291
|
if (a === "--json" || a === "--text" || a === "--human") { continue; } // output-mode flags (#8) — consumed by
|
|
280
292
|
// wantJsonOut(rawArgs), never a positional
|
|
281
|
-
if (
|
|
282
|
-
if (
|
|
293
|
+
if (a === "--strict") { if (strict) wantStrict = true; continue; } // vocabulary — tolerated everywhere,
|
|
294
|
+
if (a === "--include-unknown") { if (includeUnknown) wantIncludeUnknown = true; continue; } // used only by the verb that reads it
|
|
295
|
+
if (a.startsWith("-") && a.length > 1) {
|
|
296
|
+
// An unrecognized flag is a TYPO, not a positional — reject it LOUD (exit 2), never silently swallow.
|
|
297
|
+
// A swallowed `--polciy` runs the query with NO policy and exits green: a CI author who typos --policy
|
|
298
|
+
// ships a gate that never fires (corpus re-audit cardinal sin — a loud error, never a silent guess).
|
|
299
|
+
console.error(`candor-ts-query: unknown flag '${a}'${didYouMeanFlag(a)}\n known flags: --report, --policy, --json, --text, --strict, --include-unknown`);
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
283
302
|
positionals.push(a);
|
|
284
303
|
}
|
|
285
304
|
// Deprecated trailing `0|1` JSON sentinel (Rust/TS legacy): if the LAST positional is a bare 0 or 1,
|
|
@@ -384,11 +403,11 @@ const SUBCOMMANDS = [
|
|
|
384
403
|
["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
|
|
385
404
|
["blindspots", REPORT_TAIL, "the Unknown sources, ranked by blast radius"],
|
|
386
405
|
["tour", `[<N>] ${REPORT_TAIL}`, "the N most surprising transitive reaches — the guided cold-repo poke (no re-scan)"],
|
|
387
|
-
["gains", "<current> <baseline> [--json]", "the supply-chain alarm: what the surface gained between two reports"],
|
|
406
|
+
["gains", "<current> <baseline> [--json] [--strict]", "the supply-chain alarm: what the surface gained between two reports (--strict: exit 1 on ANY gain)"],
|
|
388
407
|
["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
|
|
389
408
|
["whatif", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
|
|
390
409
|
["fix", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the boundary fix: where the effect belongs + the hoist refactor"],
|
|
391
|
-
["fix-gate", `[--policy <file>] ${REPORT_TAIL}`, "a fix for EVERY boundary crossing —
|
|
410
|
+
["fix-gate", `[--policy <file>] [--strict] ${REPORT_TAIL}`, "a fix for EVERY boundary crossing — advisory (--strict: exit 1 while any remains)"],
|
|
392
411
|
["unverified", `[--policy <file>] [--strict] ${REPORT_TAIL}`, "pure/deny layers that PASS but are Unknown (not PROVABLY clean)"],
|
|
393
412
|
["agents", "", "print the agent contract for this build (AGENTS.md)"],
|
|
394
413
|
];
|
|
@@ -447,7 +466,9 @@ OPTIONS (uniform across every engine)
|
|
|
447
466
|
--json machine-readable JSON (the default when output is piped/redirected)
|
|
448
467
|
--text, --human human-readable prose (the default at a terminal)
|
|
449
468
|
--include-unknown callers: also list the unresolved-dispatch frontier
|
|
450
|
-
--strict
|
|
469
|
+
--strict make an advisory verb a CI gate — exit 1 while a finding remains:
|
|
470
|
+
unverified (an unverified-purity hole), fix-gate (a boundary
|
|
471
|
+
crossing), gains (ANY gained effect). Advisory (exit 0) otherwise.
|
|
451
472
|
-V, --version print the installed version + upgrade line (offline)
|
|
452
473
|
-h, --help show this help
|
|
453
474
|
|
|
@@ -693,13 +714,33 @@ switch (cmd) {
|
|
|
693
714
|
const out = { reaches: finds.map((f) => ({
|
|
694
715
|
effect: f.effect, fn: f.func, hops: f.hops, loc: f.sourceLoc, score: f.score, source: f.source,
|
|
695
716
|
})) };
|
|
717
|
+
// The MACHINE half of the mostly-Unknown disclosure (Fable-review finding E): a JSON consumer (the
|
|
718
|
+
// agent loop) got a bare `{"reaches":[]}` and read it as clean — the same false all-clear the text
|
|
719
|
+
// branch qualifies. ADDITIVE + present only when the ≥⅓-Unknown threshold trips (byte-identical
|
|
720
|
+
// otherwise). Keys sorted after `reaches` (reaches < unknown) to match Rust's serde output.
|
|
721
|
+
const teff = fns.filter((e) => (e.inferred ?? []).length > 0).length;
|
|
722
|
+
const tunk = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
|
|
723
|
+
if (teff > 0 && tunk * 3 >= teff) out.unknown = { count: tunk, total: teff };
|
|
696
724
|
console.log(JSON.stringify(out));
|
|
697
725
|
break;
|
|
698
726
|
}
|
|
699
727
|
if (finds.length === 0) {
|
|
700
728
|
// Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
|
|
701
|
-
// answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine.
|
|
702
|
-
|
|
729
|
+
// answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine. BUT never
|
|
730
|
+
// reassure "nothing hidden" over a meaningfully-Unknown graph (unresolved calls — missing tsconfig /
|
|
731
|
+
// imports): those Unknowns ARE the hidden part, their transitive effects unanalyzed (re-audit cardinal
|
|
732
|
+
// sin). Same ≥⅓-effectful-Unknown gate as the scan opener (surface.mjs emitSurface).
|
|
733
|
+
const teff = fns.filter((e) => (e.inferred ?? []).length > 0).length;
|
|
734
|
+
const tunk = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
|
|
735
|
+
if (teff > 0 && tunk * 3 >= teff) {
|
|
736
|
+
console.log(
|
|
737
|
+
`candor: no surprising reaches — but ${tunk} of ${teff} function(s) are Unknown `
|
|
738
|
+
+ `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\`; `
|
|
739
|
+
+ `a missing tsconfig.json or unresolvable imports are the usual cause.`,
|
|
740
|
+
);
|
|
741
|
+
} else {
|
|
742
|
+
console.log("candor: nothing hidden — every effect sits where its name says it should.");
|
|
743
|
+
}
|
|
703
744
|
break;
|
|
704
745
|
}
|
|
705
746
|
console.log(`candor tour — the ${finds.length} most surprising reach${finds.length === 1 ? "" : "es"} in ${crateName}:`);
|
|
@@ -716,8 +757,13 @@ switch (cmd) {
|
|
|
716
757
|
// surface gained between two reports (base → cur), the cross-engine machine-readable form.
|
|
717
758
|
// §3.3.1: like diff, two positional locators <current> <baseline> (no discovery), each resolved by
|
|
718
759
|
// the shared locator rule; --json accepted.
|
|
719
|
-
|
|
720
|
-
|
|
760
|
+
// gains has no `--policy` of its own: parseCanonical consumes `--policy` for every verb (a valid flag),
|
|
761
|
+
// which for gains would SILENTLY drop it and exit 0 — a CI author who reaches for `--policy` to gate a
|
|
762
|
+
// supply-chain diff ships a gate that never fires. Reject it loud and point at the real gate. `--strict`
|
|
763
|
+
// (below) fails on ANY gained effect; the effect-SPECIFIC gate is a `deny <E> gained` scan policy.
|
|
764
|
+
if (args.includes("--policy")) { console.error("candor-ts-query gains: unknown flag '--policy' — gains is a diff view; to FAIL CI on a newly-gained effect gate at scan time with a `deny <E> gained` policy (AS-EFF-005), or use `--strict` to fail on ANY gain\n known flags: --json, --strict"); process.exit(2); }
|
|
765
|
+
const { positionals, strict } = parseCanonical(args, { strict: true });
|
|
766
|
+
if (positionals.length < 2) { console.error("usage: candor-ts-query gains <current> <baseline> [--json] [--strict]"); process.exit(2); }
|
|
721
767
|
const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
|
|
722
768
|
// BOTH locators must name real report files (the Rust engine's no-files check, named per side):
|
|
723
769
|
// a typo'd prefix loaded [] with hardFail=false and emitted an authoritative EMPTY
|
|
@@ -736,10 +782,13 @@ switch (cmd) {
|
|
|
736
782
|
// read as total), plus `coverageDelta` when the baseline names different blind packages. Both
|
|
737
783
|
// OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
|
|
738
784
|
// Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
|
|
785
|
+
const gainsResult = coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix));
|
|
739
786
|
put(args, { baseline_version: gbv ?? "", engine_version: gv ?? "",
|
|
740
|
-
...
|
|
741
|
-
|
|
742
|
-
|
|
787
|
+
...gainsResult, ...gainsCoverage(curPrefix, basePrefix) }, P.gains);
|
|
788
|
+
// Advisory by default (exit 0 — gains is a diff view); `--strict` fails on ANY gained effect so a
|
|
789
|
+
// supply-chain CI job can require a bump introduce no new capability (mirrors `unverified --strict`).
|
|
790
|
+
process.exit(strict && (gainsResult.gained?.length ?? 0) > 0 ? 1 : 0);
|
|
791
|
+
break; // unreachable
|
|
743
792
|
}
|
|
744
793
|
case "path": {
|
|
745
794
|
// BOTH a human default AND a --json form (like the Rust/Java engines). The surface opener suggests
|
|
@@ -818,15 +867,19 @@ switch (cmd) {
|
|
|
818
867
|
// A remedy for EVERY deny/pure crossing — the shape the edit-time loop folds into its block message.
|
|
819
868
|
// §3.3.1: `fix-gate [--policy <file>]`, report discovered / --report. DEPRECATED alias: the old
|
|
820
869
|
// `fix-gate <prefix> <policy-file>` (leading report + positional policy).
|
|
821
|
-
|
|
870
|
+
// Advisory by default (exit 0 — the agent fix-loop reads the remedy and edits); `--strict` makes the
|
|
871
|
+
// exit follow `ok`, so CI can REQUIRE zero outstanding crossings (mirrors `unverified --strict`).
|
|
872
|
+
const { prefix, policyFile, strict } = resolveGateVerb(args, { strict: true });
|
|
822
873
|
if (!policyFile) { console.error("candor: fix-gate requires a policy file (pass --policy <file>, or set CANDOR_POLICY / a .candor/config `policy` key)"); process.exit(2); }
|
|
823
874
|
let ptext;
|
|
824
875
|
try { ptext = fs.readFileSync(policyFile, "utf8"); }
|
|
825
876
|
catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
|
|
826
877
|
const cg = loadCallgraph(prefix);
|
|
827
878
|
if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix-gate needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
|
|
828
|
-
|
|
829
|
-
|
|
879
|
+
const fgr = coreFixGate(cg, loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches);
|
|
880
|
+
emit(fgr);
|
|
881
|
+
process.exit(strict && !fgr.ok ? 1 : 0);
|
|
882
|
+
break; // unreachable
|
|
830
883
|
}
|
|
831
884
|
case "unverified": {
|
|
832
885
|
// PROVABLE-PURITY disclosure: pure/deny layers that PASS but contain Unknown (not provably clean). A
|
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.18";
|
|
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
|
package/surface.mjs
CHANGED
|
@@ -215,8 +215,26 @@ export function bestFind(inferred, direct, calls, isTest = () => false) {
|
|
|
215
215
|
// the sink (defaults to console.error). Mirrors surface.rs::emit exactly.
|
|
216
216
|
export function emitSurface(inferred, direct, calls, loc, isTest = () => false, log = console.error) {
|
|
217
217
|
const res = bestFind(inferred, direct, calls, isTest);
|
|
218
|
-
|
|
219
|
-
if (res.winner
|
|
218
|
+
// A real SURPRISING reach is a genuine finding — show it (below), even amid Unknowns.
|
|
219
|
+
if (res !== null && res.winner !== null) { /* fall through to the surprising-reach message */ }
|
|
220
|
+
else {
|
|
221
|
+
// No surprising reach. But do NOT reassure "nothing hidden" over a meaningfully-UNKNOWN graph: those
|
|
222
|
+
// Unknowns (unresolved calls — e.g. a missing tsconfig.json, unresolvable imports) ARE the hidden part,
|
|
223
|
+
// and their transitive effects are unanalyzed. "nothing hidden" there is a false all-clear — the
|
|
224
|
+
// cardinal sin for a tool that sells transitive-reach detection (corpus re-audit). Qualify + point at
|
|
225
|
+
// blindspots. `bestFind` returns null for BOTH "no effectful fns" and "effectful-but-nothing-surprising
|
|
226
|
+
// (incl. all-Unknown)", so measure the Unknown fraction from `inferred` directly, not from `res`.
|
|
227
|
+
const total = [...inferred.values()].filter((s) => s.size > 0).length; // EFFECTFUL fns (pure units excluded)
|
|
228
|
+
const unknown = [...inferred.values()].filter((s) => s.has("Unknown")).length;
|
|
229
|
+
if (total > 0 && unknown * 3 >= total) { // ≥ ~1/3 of effectful functions Unknown → meaningfully unresolved
|
|
230
|
+
log(
|
|
231
|
+
`candor: no surprising reaches — but ${unknown} of ${total} function(s) are Unknown `
|
|
232
|
+
+ `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\`; `
|
|
233
|
+
+ `a missing tsconfig.json or unresolvable imports are the usual cause.`,
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (res === null) return; // genuinely nothing effectful/surprising and few Unknowns — emit nothing
|
|
220
238
|
log("candor: nothing hidden — every effect sits where its name says it should.");
|
|
221
239
|
return;
|
|
222
240
|
}
|