candor-ts 0.9.2 → 0.10.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 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.9)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.10)."*
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
@@ -183,7 +183,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
183
183
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
184
184
  | Unmatched external calls contribute nothing (curated-κ caveat) | SEMANTICS §8 C1 |
185
185
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
186
- | `{ candor: { version, toolchain, spec: "0.9" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
186
+ | `{ candor: { version, toolchain, spec: "0.10" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
187
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
188
188
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
189
189
 
@@ -201,7 +201,7 @@ read the Rust source".
201
201
 
202
202
  ## Status
203
203
 
204
- 0.9.x, speaking candor-spec 0.9: the analysis core, the gate (`--policy` / `--gate-json` /
204
+ 0.10.x, speaking candor-spec 0.10: the analysis core, the gate (`--policy` / `--gate-json` /
205
205
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
206
206
  `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
207
207
  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.9.2",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.9)",
3
+ "version": "0.10.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.10)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/query.mjs CHANGED
@@ -9,18 +9,22 @@
9
9
  * but its author had by then read the reference engines; the ongoing guarantee for it is the
10
10
  * conformance differential, not clean-room provenance.
11
11
  *
12
- * node query.mjs parsepolicy <file>
13
- * node query.mjs show <prefix> <query> <0|1>
14
- * node query.mjs where <prefix> <Effect> <0|1>
15
- * node query.mjs callers <prefix> <query> <0|1>
16
- * node query.mjs map <prefix> <0|1>
17
- * node query.mjs whatif <prefix> <fn> <Effect> [policy-file] [0|1]
12
+ * CANONICAL grammar (candor-spec §3.3.1 ⟨0.10⟩ — one shape, every engine):
13
+ * node query.mjs <verb> <verb-args…> [--report <locator>] [--policy <file>] [--json] [--strict] [--include-unknown]
14
+ * The report is DISCOVERED (walk up from CWD for a `.candor/` dir → `<that>/.candor/report`; CANDOR_REPORT
15
+ * overrides) unless --report gives a locator (a dir → `<dir>/.candor/report`; a `.json` path → that report
16
+ * path; else a prefix). diff/gains are the exception: two positional locators <current> <baseline>.
17
+ *
18
+ * DEPRECATED aliases (kept accepted through the 0.10 line, stderr-noted — candor-spec §3.3.1 / PART 17):
19
+ * node query.mjs <verb> <PREFIX> <verb-args…> [0|1] (leading-positional report + trailing 0|1 sentinel)
20
+ * node query.mjs whatif/fix <prefix> <fn> <Effect> [policy-file] [0|1] (positional policy)
18
21
  */
19
22
  import fs from "node:fs";
20
23
  import path from "node:path";
21
24
  import { fileURLToPath } from "node:url";
22
25
 
23
- import { parsePolicy, scopeMatches } from "./policy.mjs";
26
+ import { parsePolicy, scopeMatches, discoverConfigPolicy } from "./policy.mjs";
27
+ import { hasReport } from "./query-core.mjs";
24
28
  import { printAgents } from "./contract.mjs";
25
29
  // ONE source of truth for loading + name-matching — query.mjs kept DRIFTED local copies that didn't
26
30
  // merge sibling reports, didn't tolerate a corrupt report (bare JSON.parse → uncaught crash), and used
@@ -39,28 +43,195 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
39
43
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
40
44
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
41
45
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
42
- const SPEC_VERSION = "0.9";
46
+ const SPEC_VERSION = "0.10";
47
+
48
+ // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
49
+ // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
50
+ // [--strict] [--include-unknown]`. The report is DISCOVERED by default; --report overrides. The old
51
+ // leading-positional-report form, the trailing `0|1` JSON sentinel, and a positional policy stay
52
+ // accepted as DEPRECATED aliases (stderr-noted) so the conformance suite's old-grammar invocations
53
+ // (and every 0.9 caller) keep working — never removed before the next breaking bump.
54
+
55
+ // A one-line deprecation note to STDERR (stdout stays pure JSON — the machine consumer never sees it).
56
+ // De-duplicated so a single invocation prints each distinct note at most once.
57
+ const _deprecated = new Set();
58
+ const deprecate = (msg) => { if (!_deprecated.has(msg)) { _deprecated.add(msg); console.error(`candor-ts-query: [deprecated] ${msg}`); } };
59
+
60
+ // Resolve a --report <locator> by the ONE §3.3.1 rule: a directory → `<dir>/.candor/report`; a path
61
+ // ending `.json` → that full report path (minus the `.json`, since loadReport takes a prefix and adds
62
+ // it back); otherwise a bare prefix. Returns the PREFIX loadReport/loadCallgraph expect.
63
+ function locatorToPrefix(loc) {
64
+ try { if (fs.statSync(loc).isDirectory()) return path.join(loc, ".candor", "report"); } catch { /* not a dir */ }
65
+ if (loc.endsWith(".json")) return loc.slice(0, -".json".length); // full report path → its prefix
66
+ return loc; // bare prefix
67
+ }
68
+
69
+ // DISCOVER the report prefix when no --report: CANDOR_REPORT env wins; else walk UP from CWD for a
70
+ // `.candor/` directory and use its `report` prefix (the §3.4 discovery mechanism, the twin of scan.mjs's
71
+ // config walk-up). Returns null when NEITHER is found — the caller then fails LOUD (exit 2). It must NOT
72
+ // fall back to a bogus `.candor/report` prefix: that made the loaders read ZERO functions and every
73
+ // discovery verb emit an authoritative-empty answer at exit 0 — a false all-clear, the §4 cardinal sin
74
+ // (`where Net` in a dir with no `.candor/` up-tree). Matches the Rust engine's discover_report_prefix.
75
+ function discoverReportPrefix() {
76
+ const env = process.env.CANDOR_REPORT;
77
+ if (env) return locatorToPrefix(env);
78
+ for (let d = process.cwd(); ; d = path.dirname(d)) {
79
+ if (fs.existsSync(path.join(d, ".candor"))) return path.join(d, ".candor", "report");
80
+ if (path.dirname(d) === d) break; // filesystem root
81
+ }
82
+ return null; // no --report, no .candor/ discovered
83
+ }
84
+
85
+ // The report prefix for a discovery verb (no --report): the parsed/explicit locator, else discovery.
86
+ // A null prefix (no --report AND nothing discovered) is a LOUD exit-2 failure — never a silent empty
87
+ // answer. A resolved prefix that names NO report files is likewise loud (hasReport). One helper so every
88
+ // verb's no-report path is identical to the Rust engine's (report_or_discover + the no-files check).
89
+ function requireReport(prefix) {
90
+ if (prefix === null) {
91
+ console.error("candor-ts: no report found (no --report and no .candor/ discovered) — scan the crate first.");
92
+ process.exit(2);
93
+ }
94
+ if (!hasReport(prefix)) {
95
+ console.error(`candor-ts: no report files at prefix '${prefix}' — check the path, or scan the crate first.`);
96
+ process.exit(2);
97
+ }
98
+ return prefix;
99
+ }
100
+
101
+ // Parse the canonical flags out of a verb's args, leaving the POSITIONAL verb-args behind. Handles the
102
+ // deprecated `0|1` trailing sentinel (→ noted, dropped; JSON is the default here anyway) so the old
103
+ // grammar stays green. `flags` names the boolean flags this verb honours (`strict`/`includeUnknown`);
104
+ // `argc` is the verb's CANONICAL positional arity (report excluded) — the sentinel/leading-report peels
105
+ // are gated on the positional count EXCEEDING it, so a canonical arg (`show 1`, `callers 0`, `path fn 0`)
106
+ // is never eaten as a sentinel (matches the Rust grammar's Shape.verb_args arity gate).
107
+ // Returns { positionals, reportPrefix, reportExplicit, policyFile, strict, includeUnknown }.
108
+ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknown = false, argc = 0 } = {}) {
109
+ const positionals = [];
110
+ let reportLocator = null, policyFile = null, wantStrict = false, wantIncludeUnknown = false;
111
+ for (let i = 0; i < rawArgs.length; i++) {
112
+ const a = rawArgs[i];
113
+ if (a === "--report") {
114
+ // A `--report` with no following value is a LOUD usage error (exit 2), never a silent fall-back to
115
+ // discovery and never an uncaught `locatorToPrefix(undefined)` TypeError (`where Fs --report`).
116
+ 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); }
117
+ reportLocator = rawArgs[++i]; continue;
118
+ }
119
+ if (policy && a === "--policy") {
120
+ if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
121
+ policyFile = rawArgs[++i]; continue;
122
+ }
123
+ if (a === "--json") { continue; } // JSON is candor-ts's only output; accept + ignore
124
+ if (strict && a === "--strict") { wantStrict = true; continue; }
125
+ if (includeUnknown && a === "--include-unknown") { wantIncludeUnknown = true; continue; }
126
+ positionals.push(a);
127
+ }
128
+ // Deprecated trailing `0|1` JSON sentinel (Rust/TS legacy): if the LAST positional is a bare 0 or 1,
129
+ // strip it (candor-ts emits JSON regardless) and note the deprecation. ARITY-GATED: only when the
130
+ // positional count EXCEEDS the verb's canonical arity — otherwise `show 1` / `callers 0` / `where 1` /
131
+ // `path fn 0` would have their genuine query token eaten and run with a missing arg (degenerate empty
132
+ // result, exit 0). Never strip a positional the canonical form needs (matches Rust's arity gate).
133
+ if (positionals.length > argc && /^[01]$/.test(positionals[positionals.length - 1])) {
134
+ deprecate("the trailing `0|1` JSON sentinel is deprecated — candor-ts emits JSON; use --json to select it explicitly");
135
+ positionals.pop();
136
+ }
137
+ const reportExplicit = reportLocator !== null;
138
+ const reportPrefix = reportExplicit ? locatorToPrefix(reportLocator) : discoverReportPrefix();
139
+ return { positionals, reportPrefix, reportExplicit, policyFile, strict: wantStrict, includeUnknown: wantIncludeUnknown };
140
+ }
141
+
142
+ // A verb that takes ONE report + `argc` verb-positionals (where <Effect>: 1; show/callers/impact <fn>:
143
+ // 1; path <fn> <Effect>: 2; map/reachable/blindspots: 0). Applies discovery + --report, then peels the
144
+ // DEPRECATED leading-positional report: if --report wasn't given AND the first positional resolves to a
145
+ // report AND there's one positional MORE than the verb needs, treat that first token as the report.
146
+ // Returns { prefix, args } — `args` is exactly the verb's own positionals.
147
+ function resolveReportVerb(rawArgs, argc, opts = {}) {
148
+ const p = parseCanonical(rawArgs, { ...opts, argc });
149
+ let { positionals, reportPrefix } = p;
150
+ if (!p.reportExplicit && positionals.length === argc + 1 && hasReport(locatorToPrefix(positionals[0]))) {
151
+ deprecate("a leading-positional report is deprecated — pass it as `--report <locator>` (a dir, a .json path, or a prefix); the report is discovered from `.candor/` by default");
152
+ reportPrefix = locatorToPrefix(positionals[0]);
153
+ positionals = positionals.slice(1);
154
+ }
155
+ return { ...p, prefix: requireReport(reportPrefix), args: positionals };
156
+ }
157
+
158
+ // whatif/fix share a shape: one report + `<fn> <Effect>` + a policy. Canonical §3.3.1: `<fn> <Effect>
159
+ // [--policy <file>]`, report discovered/--report. DEPRECATED aliases (kept green for the old grammar):
160
+ // a leading-positional report AND a trailing positional policy — `<prefix> <fn> <Effect> [policy]`.
161
+ // Peels both (stderr-noted), then resolves the policy through resolvePolicy (flag > positional >
162
+ // CANDOR_POLICY > .candor/config). Returns { prefix, target, eff, policyFile }.
163
+ function resolveWhatifFix(rawArgs) {
164
+ const p = parseCanonical(rawArgs, { policy: true, argc: 2 });
165
+ let positionals = p.positionals, prefix = p.reportPrefix, positionalPolicy = null;
166
+ // A leading-positional report fires only when --report is absent, there are MORE than the 2 verb args,
167
+ // and the first token resolves to a report (else the extra positional is the deprecated policy).
168
+ if (!p.reportExplicit && positionals.length > 2 && hasReport(locatorToPrefix(positionals[0]))) {
169
+ deprecate("a leading-positional report is deprecated — pass it as `--report <locator>`; the report is discovered from `.candor/` by default");
170
+ prefix = locatorToPrefix(positionals[0]);
171
+ positionals = positionals.slice(1);
172
+ }
173
+ const [target, eff, posPolicy] = positionals; // a 3rd positional is the deprecated policy
174
+ if (posPolicy) positionalPolicy = posPolicy;
175
+ const { policyFile } = resolvePolicy(p.policyFile, positionalPolicy);
176
+ return { prefix: requireReport(prefix), target, eff, policyFile };
177
+ }
178
+
179
+ // fix-gate/unverified share a shape: one report + a policy + no verb-positionals (unverified also takes
180
+ // --strict). Canonical §3.3.1: `[--policy <file>] [--strict]`, report discovered/--report. DEPRECATED
181
+ // alias: a leading report + a positional policy — `<prefix> <policy-file> [--strict]`. Peels both
182
+ // (stderr-noted), then resolves the policy through resolvePolicy. Returns { prefix, policyFile, strict }.
183
+ function resolveGateVerb(rawArgs, { strict = false } = {}) {
184
+ const p = parseCanonical(rawArgs, { policy: true, strict, argc: 0 });
185
+ let positionals = p.positionals, prefix = p.reportPrefix, positionalPolicy = null;
186
+ if (!p.reportExplicit && positionals.length && hasReport(locatorToPrefix(positionals[0]))) {
187
+ deprecate("a leading-positional report is deprecated — pass it as `--report <locator>`; the report is discovered from `.candor/` by default");
188
+ prefix = locatorToPrefix(positionals[0]);
189
+ positionals = positionals.slice(1);
190
+ }
191
+ if (positionals[0]) positionalPolicy = positionals[0]; // the remaining positional is the deprecated policy
192
+ const { policyFile } = resolvePolicy(p.policyFile, positionalPolicy);
193
+ return { prefix: requireReport(prefix), policyFile, strict: p.strict };
194
+ }
195
+
196
+ // Resolve the policy for the gate verbs (whatif/fix/fix-gate/unverified): the --policy flag, else the
197
+ // deprecated positional policy, else CANDOR_POLICY, else the `.candor/config` `policy` key (§3.3/§3.4,
198
+ // the same precedence scan.mjs uses). Returns { policyFile, fromPositional } — policyFile null if none.
199
+ function resolvePolicy(policyFlag, positionalPolicy) {
200
+ if (policyFlag) return { policyFile: policyFlag, fromPositional: false };
201
+ if (positionalPolicy) {
202
+ deprecate("a positional policy file is deprecated — pass it as `--policy <file>` (or set CANDOR_POLICY / a .candor/config `policy` key)");
203
+ return { policyFile: positionalPolicy, fromPositional: true };
204
+ }
205
+ if (process.env.CANDOR_POLICY) return { policyFile: process.env.CANDOR_POLICY, fromPositional: false };
206
+ const disc = discoverConfigPolicy(process.cwd());
207
+ if (disc?.policyPath) return { policyFile: disc.policyPath, fromPositional: false };
208
+ return { policyFile: null, fromPositional: false };
209
+ }
43
210
 
44
211
  // The full subcommand catalogue — name + one-line description (derived from the per-subcommand
45
212
  // comments + the module-doc header). The single source for the --help list AND the no-arg/unknown
46
213
  // usage, so the two can never drift back to a stale hand-list again.
214
+ // Grammar per candor-spec §3.3.1 ⟨0.10⟩: the report is a FLAG (--report), discovered from `.candor/`
215
+ // by default; verb args are positional; --json selects JSON; --policy supplies a policy. The old
216
+ // leading-positional/`0|1`/positional-policy forms stay accepted as deprecated aliases (see the parser).
217
+ const REPORT_TAIL = "[--report <locator>] [--json]";
47
218
  const SUBCOMMANDS = [
48
219
  ["parsepolicy", "<file>", "parse a policy file (candor-spec §6.2) and print it as JSON"],
49
- ["show", "<prefix> <query> [0|1]", "the effect record(s) for a function — direct, inferred, surfaces"],
50
- ["where", "<prefix> <Effect> [0|1]", "functions with an effect, split into directly / inherited"],
51
- ["callers", "<prefix> <query> [0|1]", "who reaches a function: {of, direct, transitive} (--include-unknown)"],
52
- ["map", "<prefix> [0|1]", "per-module effect rollup: {effects, functions} by module"],
53
- ["containment", "<prefix> [baseline-prefix]", "§6.1 boundary-effect dispersion; with a baseline, the leak ratchet (exit 1)"],
54
- ["diff", "<cur-prefix> <base-prefix>", "per-function effect delta vs a baseline: {changes:[{fn,gained,lost}]} (exit 1 on a gain)"],
55
- ["reachable", "<prefix>", "effects unioned over the entry points: what the app DOES at runtime"],
56
- ["impact", "<prefix> <query>", "blast radius of a function (backward dual of reachable)"],
57
- ["blindspots", "<prefix>", "the Unknown sources, ranked by blast radius"],
58
- ["gains", "<cur-prefix> <base-prefix>", "the supply-chain alarm: what the surface gained between two reports"],
59
- ["path", "<prefix> <fn> <Effect>", "a call path from a function to where an effect enters"],
60
- ["whatif", "<prefix> <fn> <Effect> [policy-file] [0|1]", "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
61
- ["fix", "<prefix> <fn> <Effect> <policy-file>", "the boundary fix: where the effect belongs + the hoist refactor"],
62
- ["fix-gate", "<prefix> <policy-file>", "a fix for EVERY boundary crossing — the loop's block-message remedy"],
63
- ["unverified", "<prefix> <policy-file> [--strict]", "pure/deny layers that PASS but are Unknown (not PROVABLY clean)"],
220
+ ["show", `<query> ${REPORT_TAIL}`, "the effect record(s) for a function — direct, inferred, surfaces"],
221
+ ["where", `<Effect> ${REPORT_TAIL}`, "functions with an effect, split into directly / inherited"],
222
+ ["callers", `<query> [--include-unknown] ${REPORT_TAIL}`, "who reaches a function: {of, direct, transitive}"],
223
+ ["map", REPORT_TAIL, "per-module effect rollup: {effects, functions} by module"],
224
+ ["containment", `[<baseline>] ${REPORT_TAIL}`, "§6.1 boundary-effect dispersion; with a baseline, the leak ratchet (exit 1)"],
225
+ ["diff", "<current> <baseline> [--json]", "per-function effect delta vs a baseline: {changes:[{fn,gained,lost}]} (exit 1 on a gain)"],
226
+ ["reachable", REPORT_TAIL, "effects unioned over the entry points: what the app DOES at runtime"],
227
+ ["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
228
+ ["blindspots", REPORT_TAIL, "the Unknown sources, ranked by blast radius"],
229
+ ["gains", "<current> <baseline> [--json]", "the supply-chain alarm: what the surface gained between two reports"],
230
+ ["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
231
+ ["whatif", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
232
+ ["fix", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the boundary fix: where the effect belongs + the hoist refactor"],
233
+ ["fix-gate", `[--policy <file>] ${REPORT_TAIL}`, "a fix for EVERY boundary crossing — the loop's block-message remedy"],
234
+ ["unverified", `[--policy <file>] [--strict] ${REPORT_TAIL}`, "pure/deny layers that PASS but are Unknown (not PROVABLY clean)"],
64
235
  ["agents", "", "print the agent contract for this build (AGENTS.md)"],
65
236
  ];
66
237
 
@@ -115,7 +286,7 @@ switch (cmd) {
115
286
  // Was a hand-copy of query-core's show that had DRIFTED — it read the wrong Fs key (`e.fs`, never
116
287
  // written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
117
288
  // the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
118
- const [prefix, q] = args;
289
+ const { prefix, args: [q] } = resolveReportVerb(args, 1);
119
290
  emit(coreShow(loadReport(prefix), q));
120
291
  break;
121
292
  }
@@ -123,7 +294,7 @@ switch (cmd) {
123
294
  // Shared query-core (like show/callers) — the CLI and MCP `candor_where` are ONE implementation.
124
295
  // Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
125
296
  // fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
126
- const [prefix, eff] = args;
297
+ const { prefix, args: [eff] } = resolveReportVerb(args, 1);
127
298
  emit(coreWhere(loadReport(prefix), eff));
128
299
  break;
129
300
  }
@@ -131,8 +302,7 @@ switch (cmd) {
131
302
  // --include-unknown ⟨0.7⟩ adds the unresolved-dispatch frontier (possibleViaUnknownDispatch); without
132
303
  // it, the byte-for-byte {of,direct,transitive} shape is unchanged (cross-engine parity). Call the
133
304
  // shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
134
- const includeUnknown = args.includes("--include-unknown");
135
- const [prefix, q] = args.filter((a) => a !== "--include-unknown");
305
+ const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
136
306
  const cg = loadCallgraph(prefix);
137
307
  if (includeUnknown) emit(callersFrontier(cg, loadReport(prefix), loadHierarchy(prefix), q));
138
308
  else emit(coreCallers(cg, q));
@@ -140,14 +310,30 @@ switch (cmd) {
140
310
  }
141
311
  case "map": {
142
312
  // Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
143
- const [prefix] = args;
313
+ const { prefix } = resolveReportVerb(args, 0);
144
314
  emit(coreMap(loadReport(prefix)));
145
315
  break;
146
316
  }
147
317
  case "containment": {
148
- // SPEC §6.1 boundary-effect dispersion; with a baseline prefix it's the AS-EFF-010 ratchet (exit 1 on a
149
- // new leak), matching candor-java / candor-query. JSON-only, like every other candor-ts query command.
150
- const [prefix, basePrefix] = args;
318
+ // SPEC §6.1 boundary-effect dispersion; with a baseline it's the AS-EFF-010 ratchet (exit 1 on a new
319
+ // leak), matching candor-java / candor-query. JSON-only, like every other candor-ts query command.
320
+ // Canonical §3.3.1: `containment [<baseline>]` — the main report discovered / --report, the SINGLE
321
+ // canonical positional is the OPTIONAL baseline (verb_args: 1). A lone bare positional is therefore
322
+ // the BASELINE (the gating ratchet), NEVER re-read as the deprecated leading report — which silently
323
+ // dropped to non-gating report-mode (exit 0), the §4 cardinal-sin gate-off this fixes. The deprecated
324
+ // old form (`containment <report> <baseline>`) is ARITY-GATED: the leading-report peel fires only when
325
+ // the positionals EXCEED 1, so `containment P` stays the ratchet and `containment leaky P` still peels
326
+ // `leaky` as the report and leaves `P` the baseline (both old-grammar tests stay green). Matches Rust.
327
+ const p = parseCanonical(args, { argc: 1 });
328
+ let prefix = p.reportPrefix, basePrefix;
329
+ if (!p.reportExplicit && p.positionals.length > 1 && hasReport(locatorToPrefix(p.positionals[0]))) {
330
+ deprecate("a leading-positional report is deprecated — pass it as `--report <locator>`; the baseline stays positional (`containment [<baseline>]`)");
331
+ prefix = locatorToPrefix(p.positionals[0]);
332
+ basePrefix = p.positionals[1] ? locatorToPrefix(p.positionals[1]) : undefined;
333
+ } else {
334
+ basePrefix = p.positionals[0] ? locatorToPrefix(p.positionals[0]) : undefined;
335
+ }
336
+ prefix = requireReport(prefix);
151
337
  if (basePrefix) {
152
338
  const baseFns = loadReport(basePrefix);
153
339
  if (baseFns.length === 0) { // fail CLOSED (exit 2), not a wall of bogus "everything leaked" (exit 1)
@@ -168,7 +354,11 @@ switch (cmd) {
168
354
  // core's effectsByFn was rewritten to avoid (merged multi-report siblings sharing a short fn name
169
355
  // dropped one member's effects, so a gained Net could VANISH from diff and its exit-1 contract —
170
356
  // a supply-chain miss, and the CLI disagreeing with MCP `candor_diff` on the same reports).
171
- const [curPrefix, basePrefix] = args;
357
+ // §3.3.1: diff/gains are the exception to discovery — two positional locators <current> <baseline>,
358
+ // each resolved by the shared locator rule (dir / .json path / prefix). --json is accepted (JSON is
359
+ // the only output). No leading-positional-report alias here: both positionals ARE the reports.
360
+ const { positionals } = parseCanonical(args, {});
361
+ const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
172
362
  const { changes } = coreDiff(loadReport(curPrefix), loadReport(basePrefix));
173
363
  // §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
174
364
  // may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
@@ -188,7 +378,7 @@ switch (cmd) {
188
378
  case "reachable": {
189
379
  // what the app DOES at runtime: effects unioned over the entry points (SPEC §3.1; same JSON
190
380
  // shape as the Rust engine: {entryPoints, effects: {Eff: {count, via}}}).
191
- const [prefix] = args;
381
+ const { prefix } = resolveReportVerb(args, 0);
192
382
  const fns = loadReport(prefix);
193
383
  const roots = fns.filter((e) => e.entryPoint);
194
384
  const byEff = {};
@@ -201,21 +391,24 @@ switch (cmd) {
201
391
  case "impact": {
202
392
  // blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
203
393
  // MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
204
- const [prefix, q] = args;
394
+ const { prefix, args: [q] } = resolveReportVerb(args, 1);
205
395
  emit(coreImpact(loadReport(prefix), loadCallgraph(prefix), q));
206
396
  break;
207
397
  }
208
398
  case "blindspots": {
209
399
  // the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
210
400
  // Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
211
- const [prefix] = args;
401
+ const { prefix } = resolveReportVerb(args, 0);
212
402
  emit(coreBlindspots(loadReport(prefix), loadCallgraph(prefix)));
213
403
  break;
214
404
  }
215
405
  case "gains": {
216
406
  // the supply-chain alarm (SPEC §5.1): {gained:[Effect], byFunction:[{fn,effect}]} — what the
217
407
  // surface gained between two reports (base → cur), the cross-engine machine-readable form.
218
- const [curPrefix, basePrefix] = args;
408
+ // §3.3.1: like diff, two positional locators <current> <baseline> (no discovery), each resolved by
409
+ // the shared locator rule; --json accepted.
410
+ const { positionals } = parseCanonical(args, {});
411
+ const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
219
412
  const gv = reportVersion(curPrefix), gbv = reportVersion(basePrefix);
220
413
  if (gv && gbv && gv !== gbv)
221
414
  console.error(`candor-ts: ⚠ baseline @${gbv} ≠ engine @${gv} — a "gained capability" may be the engine reclassifying, not the dependency changing. Regenerate both reports with one build to compare releases.`);
@@ -223,22 +416,25 @@ switch (cmd) {
223
416
  break;
224
417
  }
225
418
  case "path": {
226
- const [prefix, fn, eff] = args;
419
+ const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
227
420
  emit(corePath(loadReport(prefix), loadCallgraph(prefix), fn, eff));
228
421
  break;
229
422
  }
230
423
  case "whatif": {
231
- const [prefix, target, eff, maybePolicy] = args;
232
- // A present policy arg (anything but the 0/1 verbosity sentinels) MUST exist and be readable —
233
- // a typo'd path must be LOUD, not silently "no policy → ok:true, exit 0" (mirrors scan's --policy,
234
- // which exits 2 on an unreadable file: a gate that can't read its policy can't certify anything).
424
+ // §3.3.1: `whatif <fn> <Effect> [--policy <file>]`, report discovered / --report. DEPRECATED aliases:
425
+ // a leading-positional report and a trailing positional policy (`whatif <prefix> <fn> <Effect>
426
+ // [policy]`). resolveWhatifFix peels both (stderr-noted) so the old grammar stays green.
427
+ const { prefix, target, eff, policyFile } = resolveWhatifFix(args);
428
+ // A present policy MUST exist and be readable — a typo'd path must be LOUD, not silently "no policy →
429
+ // ok:true, exit 0" (mirrors scan's --policy, which exits 2 on an unreadable file: a gate that can't
430
+ // read its policy can't certify anything). Flag, positional, CANDOR_POLICY and .candor/config all land here.
235
431
  let pol = null;
236
- if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1") {
432
+ if (policyFile) {
237
433
  let text;
238
434
  try {
239
- text = fs.readFileSync(maybePolicy, "utf8");
435
+ text = fs.readFileSync(policyFile, "utf8");
240
436
  } catch {
241
- console.error(`candor: policy ${maybePolicy} could not be read; whatif NOT evaluated against it`);
437
+ console.error(`candor: policy ${policyFile} could not be read; whatif NOT evaluated against it`);
242
438
  process.exit(2);
243
439
  }
244
440
  pol = parsePolicy(text);
@@ -258,9 +454,11 @@ switch (cmd) {
258
454
  // THE BOUNDARY FIX (integrations/FIX-SPEC.md): where a forbidden effect belongs + the hoist refactor.
259
455
  // The remedial inverse of whatif. A policy is REQUIRED and must be readable (the fix is defined relative
260
456
  // to the boundary the edit crossed) — a typo'd path fails LOUD, never a silently-empty "no crossing".
261
- const [prefix, target, eff, policyFile] = args;
262
- if (!target || !eff) { console.error("usage: candor-ts-query fix <prefix> <fn> <Effect> <policy-file>"); process.exit(2); }
263
- if (!policyFile) { console.error("candor: fix requires a policy file — the fix is the refactor that restores the boundary the edit crossed"); process.exit(2); }
457
+ // §3.3.1: `fix <fn> <Effect> [--policy <file>]`, report discovered / --report; the old
458
+ // `fix <prefix> <fn> <Effect> <policy-file>` form (leading report + positional policy) stays accepted.
459
+ const { prefix, target, eff, policyFile } = resolveWhatifFix(args);
460
+ if (!target || !eff) { console.error("usage: candor-ts-query fix <fn> <Effect> [--policy <file>] [--report <locator>]"); process.exit(2); }
461
+ if (!policyFile) { console.error("candor: fix requires a policy file — the fix is the refactor that restores the boundary the edit crossed (pass --policy <file>, or set CANDOR_POLICY / a .candor/config `policy` key)"); process.exit(2); }
264
462
  let ptext;
265
463
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
266
464
  catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
@@ -275,8 +473,10 @@ switch (cmd) {
275
473
  }
276
474
  case "fix-gate": {
277
475
  // A remedy for EVERY deny/pure crossing — the shape the edit-time loop folds into its block message.
278
- const [prefix, policyFile] = args;
279
- if (!policyFile) { console.error("candor: fix-gate requires a policy file"); process.exit(2); }
476
+ // §3.3.1: `fix-gate [--policy <file>]`, report discovered / --report. DEPRECATED alias: the old
477
+ // `fix-gate <prefix> <policy-file>` (leading report + positional policy).
478
+ const { prefix, policyFile } = resolveGateVerb(args);
479
+ 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); }
280
480
  let ptext;
281
481
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
282
482
  catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
@@ -288,9 +488,10 @@ switch (cmd) {
288
488
  case "unverified": {
289
489
  // PROVABLE-PURITY disclosure: pure/deny layers that PASS but contain Unknown (not provably clean). A
290
490
  // policy is required; `--strict` exits 1 on a hole. Advisory (exit 0) otherwise.
291
- const strict = args.includes("--strict");
292
- const [prefix, policyFile] = args.filter((a) => a !== "--strict");
293
- if (!policyFile) { console.error("candor: unverified requires a policy file"); process.exit(2); }
491
+ // §3.3.1: `unverified [--policy <file>] [--strict]`, report discovered / --report. DEPRECATED alias:
492
+ // the old `unverified <prefix> <policy-file> [--strict]` (leading report + positional policy).
493
+ const { prefix, policyFile, strict } = resolveGateVerb(args, { strict: true });
494
+ if (!policyFile) { console.error("candor: unverified requires a policy file (pass --policy <file>, or set CANDOR_POLICY / a .candor/config `policy` key)"); process.exit(2); }
294
495
  let ptext;
295
496
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
296
497
  catch { console.error(`candor: policy ${policyFile} could not be read`); process.exit(2); }
package/scan.mjs CHANGED
@@ -39,7 +39,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
39
39
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
40
40
  // Reused, never re-littered.
41
41
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
42
- const SPEC_VERSION = "0.9";
42
+ const SPEC_VERSION = "0.10";
43
43
 
44
44
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
45
45
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed