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 +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/query.mjs +253 -52
- package/scan.mjs +1 -1
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.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.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
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
|
-
*
|
|
13
|
-
* node query.mjs
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
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",
|
|
50
|
-
["where",
|
|
51
|
-
["callers",
|
|
52
|
-
["map",
|
|
53
|
-
["containment",
|
|
54
|
-
["diff", "<
|
|
55
|
-
["reachable",
|
|
56
|
-
["impact",
|
|
57
|
-
["blindspots",
|
|
58
|
-
["gains", "<
|
|
59
|
-
["path",
|
|
60
|
-
["whatif",
|
|
61
|
-
["fix",
|
|
62
|
-
["fix-gate",
|
|
63
|
-
["unverified",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
149
|
-
//
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
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 (
|
|
432
|
+
if (policyFile) {
|
|
237
433
|
let text;
|
|
238
434
|
try {
|
|
239
|
-
text = fs.readFileSync(
|
|
435
|
+
text = fs.readFileSync(policyFile, "utf8");
|
|
240
436
|
} catch {
|
|
241
|
-
console.error(`candor: policy ${
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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.
|
|
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
|