candor-ts 0.7.5 → 0.7.7
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 +4 -4
- package/package.json +1 -1
- package/policy.mjs +5 -3
- package/query.mjs +81 -4
- package/scan.mjs +60 -12
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 0.
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts 0.7.1 (spec 0.7)."* On a
|
|
16
16
|
> 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
|
>
|
|
@@ -20,8 +20,8 @@ chains by hand.
|
|
|
20
20
|
> Net effect, so it will not reach the network to check itself. `candor-ts --version` prints the
|
|
21
21
|
> installed build, the spec contract it speaks, and the upgrade line (`npm install -g
|
|
22
22
|
> candor-ts@latest`) — fully offline. **You** have the network: compare the installed version against
|
|
23
|
-
> npm. If it's behind, **ask the user before upgrading** — e.g. *"candor-ts 0.
|
|
24
|
-
> (you're on 0.
|
|
23
|
+
> npm. If it's behind, **ask the user before upgrading** — e.g. *"candor-ts 0.7.2 is available
|
|
24
|
+
> (you're on 0.7.1) — upgrade before I scan?"* — and run `npm install -g candor-ts@latest` (or `npx
|
|
25
25
|
> -y candor-ts@latest`) only if they agree. Never upgrade silently: an analysis tool's version is
|
|
26
26
|
> part of its result's provenance, so the user decides when it changes. If it's already current (or
|
|
27
27
|
> the user declines), just proceed; if candor isn't installed at all, install it normally.
|
|
@@ -56,7 +56,7 @@ pure functions are omitted** — a function present in the callgraph sidecar but
|
|
|
56
56
|
(a test file? an unexported arrow inside an object literal?) — conclude nothing.
|
|
57
57
|
|
|
58
58
|
A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) carries
|
|
59
|
-
`unitKind: "export"` (spec 0.
|
|
59
|
+
`unitKind: "export"` (spec 0.7, informative); ordinary functions omit the field.
|
|
60
60
|
|
|
61
61
|
**Multi-package (monorepos / private deps):** point `CANDOR_DEPS` at the dependencies' reports
|
|
62
62
|
(a path list, or a directory of `*.json`); an unclassified call into a package with a loaded
|
package/package.json
CHANGED
package/policy.mjs
CHANGED
|
@@ -56,10 +56,12 @@ export function parsePolicy(text) {
|
|
|
56
56
|
return { deny, allow, forbid };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/** §6.2 scope match: by NAME SEGMENT
|
|
59
|
+
/** §6.2 scope match: by NAME SEGMENT, last segment a prefix.
|
|
60
|
+
* Segments split on BOTH "." and "::" — Rust/Java qualify with "::" while TS uses ".", and a shared
|
|
61
|
+
* policy must match across engines (a `Foo::bar` scope authored against Rust was inert in TS before). */
|
|
60
62
|
export function scopeMatches(name, scope) {
|
|
61
|
-
const segs = name.split(
|
|
62
|
-
const parts = scope.split(
|
|
63
|
+
const segs = name.split(/[.:]+/).filter(Boolean);
|
|
64
|
+
const parts = scope.split(/[.:]+/).filter(Boolean);
|
|
63
65
|
if (parts.length === 0 || parts.length > segs.length) return false;
|
|
64
66
|
const last = parts[parts.length - 1], init = parts.slice(0, -1);
|
|
65
67
|
outer: for (let i = 0; i + parts.length <= segs.length; i++) {
|
package/query.mjs
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* node query.mjs whatif <prefix> <fn> <Effect> [policy-file] [0|1]
|
|
18
18
|
*/
|
|
19
19
|
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
20
22
|
|
|
21
23
|
import { parsePolicy, scopeMatches } from "./policy.mjs";
|
|
22
24
|
import { printAgents } from "./contract.mjs";
|
|
@@ -31,6 +33,61 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
31
33
|
loadReport, loadCallgraph, matches } from "./query-core.mjs";
|
|
32
34
|
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
33
35
|
|
|
36
|
+
// ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
|
|
37
|
+
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
38
|
+
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
40
|
+
const SPEC_VERSION = "0.7";
|
|
41
|
+
|
|
42
|
+
// The full subcommand catalogue — name + one-line description (derived from the per-subcommand
|
|
43
|
+
// comments + the module-doc header). The single source for the --help list AND the no-arg/unknown
|
|
44
|
+
// usage, so the two can never drift back to a stale hand-list again.
|
|
45
|
+
const SUBCOMMANDS = [
|
|
46
|
+
["parsepolicy", "<file>", "parse a policy file (candor-spec §6.2) and print it as JSON"],
|
|
47
|
+
["show", "<prefix> <query> [0|1]", "the effect record(s) for a function — direct, inferred, surfaces"],
|
|
48
|
+
["where", "<prefix> <Effect> [0|1]", "functions with an effect, split into directly / inherited"],
|
|
49
|
+
["callers", "<prefix> <query> [0|1]", "who reaches a function: {of, direct, transitive} (--include-unknown)"],
|
|
50
|
+
["map", "<prefix> [0|1]", "per-module effect rollup: {effects, functions} by module"],
|
|
51
|
+
["containment", "<prefix> [baseline-prefix]", "§6.1 boundary-effect dispersion; with a baseline, the leak ratchet (exit 1)"],
|
|
52
|
+
["diff", "<cur-prefix> <base-prefix>", "per-function effect delta vs a baseline: {changes:[{fn,gained,lost}]} (exit 1 on a gain)"],
|
|
53
|
+
["reachable", "<prefix>", "effects unioned over the entry points: what the app DOES at runtime"],
|
|
54
|
+
["impact", "<prefix> <query>", "blast radius of a function (backward dual of reachable)"],
|
|
55
|
+
["blindspots", "<prefix>", "the Unknown sources, ranked by blast radius"],
|
|
56
|
+
["gains", "<cur-prefix> <base-prefix>", "the supply-chain alarm: what the surface gained between two reports"],
|
|
57
|
+
["path", "<prefix> <fn> <Effect>", "a call path from a function to where an effect enters"],
|
|
58
|
+
["whatif", "<prefix> <fn> <Effect> [policy-file] [0|1]", "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
|
|
59
|
+
["agents", "", "print the agent contract for this build (AGENTS.md)"],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// The full usage block — every real subcommand, replacing the stale hand-list. Printed to stderr on
|
|
63
|
+
// the no-arg / unknown-command path (exit 2) and reused in --help (stdout, exit 0).
|
|
64
|
+
const usage = () => {
|
|
65
|
+
const w = Math.max(...SUBCOMMANDS.map(([n, a]) => `${n} ${a}`.trimEnd().length));
|
|
66
|
+
const lines = SUBCOMMANDS.map(([n, a, d]) => ` ${`${n} ${a}`.trimEnd().padEnd(w)} ${d}`);
|
|
67
|
+
lines.push(` ${"-V, --version".padEnd(w)} print the build and spec version (offline)`);
|
|
68
|
+
lines.push(` ${"-h, --help".padEnd(w)} show this help`);
|
|
69
|
+
return `USAGE: candor-ts-query <command> [args]\n\n${lines.join("\n")}`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// --version / -V: a print-and-exit MODE, handled before the switch so it never depends on a command.
|
|
73
|
+
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job.
|
|
74
|
+
if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
75
|
+
console.log(`candor-ts-query ${PKG_VERSION} (spec ${SPEC_VERSION})`);
|
|
76
|
+
console.log("upgrade: npm install -g candor-ts@latest");
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -h / --help: a print-and-exit MODE, handled before the switch (so `-h`'s single dash is never
|
|
81
|
+
// mistaken for a command). Banner + USAGE + the full described subcommand list + the github footer.
|
|
82
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
83
|
+
console.log(`candor-ts-query ${PKG_VERSION} — read-only queries over a candor report (candor-spec ${SPEC_VERSION})
|
|
84
|
+
|
|
85
|
+
${usage()}
|
|
86
|
+
|
|
87
|
+
See https://github.com/tombaldwin/candor`);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
34
91
|
const [, , cmd, ...args] = process.argv;
|
|
35
92
|
switch (cmd) {
|
|
36
93
|
case "--agents":
|
|
@@ -38,7 +95,15 @@ switch (cmd) {
|
|
|
38
95
|
printAgents(); // shared with scan.mjs — one implementation, can't diverge within an install
|
|
39
96
|
break;
|
|
40
97
|
case "parsepolicy": {
|
|
41
|
-
|
|
98
|
+
// An unreadable/missing file is a clean exit-2 error, not an uncaught readFileSync stack trace.
|
|
99
|
+
let text;
|
|
100
|
+
try {
|
|
101
|
+
text = fs.readFileSync(args[0], "utf8");
|
|
102
|
+
} catch {
|
|
103
|
+
console.error(`candor: policy ${args[0] ?? "(no file given)"} could not be read`);
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
emit(parsePolicy(text));
|
|
42
107
|
break;
|
|
43
108
|
}
|
|
44
109
|
case "show": {
|
|
@@ -177,8 +242,18 @@ switch (cmd) {
|
|
|
177
242
|
for (const c of rev.get(n) ?? []) if (!affected.has(c)) { affected.add(c); queue.push(c); }
|
|
178
243
|
}
|
|
179
244
|
const violations = [];
|
|
180
|
-
|
|
181
|
-
|
|
245
|
+
// A present policy arg (anything but the 0/1 verbosity sentinels) MUST exist and be readable —
|
|
246
|
+
// a typo'd path must be LOUD, not silently "no policy → ok:true, exit 0" (mirrors scan's --policy,
|
|
247
|
+
// which exits 2 on an unreadable file: a gate that can't read its policy can't certify anything).
|
|
248
|
+
if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1") {
|
|
249
|
+
let text;
|
|
250
|
+
try {
|
|
251
|
+
text = fs.readFileSync(maybePolicy, "utf8");
|
|
252
|
+
} catch {
|
|
253
|
+
console.error(`candor: policy ${maybePolicy} could not be read; whatif NOT evaluated against it`);
|
|
254
|
+
process.exit(2);
|
|
255
|
+
}
|
|
256
|
+
const pol = parsePolicy(text);
|
|
182
257
|
for (const r of pol.deny) {
|
|
183
258
|
if (r.effects.length && !r.effects.includes(eff)) continue; // pure ([]) forbids ANY effect
|
|
184
259
|
for (const fn of affected)
|
|
@@ -191,6 +266,8 @@ switch (cmd) {
|
|
|
191
266
|
break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
|
|
192
267
|
}
|
|
193
268
|
default:
|
|
194
|
-
|
|
269
|
+
// no command (cmd === undefined) or an unknown one: the FULL usage, not the stale 6-item list.
|
|
270
|
+
if (cmd !== undefined) console.error(`candor-ts-query: unknown command '${cmd}'`);
|
|
271
|
+
console.error(usage());
|
|
195
272
|
process.exit(2);
|
|
196
273
|
}
|
package/scan.mjs
CHANGED
|
@@ -41,23 +41,45 @@ const SPEC_VERSION = "0.7";
|
|
|
41
41
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
42
42
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
43
43
|
// build + upgrade line here, then (the agent has the network) compare against npm and upgrade.
|
|
44
|
-
if (process.argv.includes("--version")) {
|
|
44
|
+
if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
45
45
|
console.log(`candor-ts ${PKG_VERSION} (spec ${SPEC_VERSION})`);
|
|
46
46
|
console.log("upgrade: npm install -g candor-ts@latest");
|
|
47
47
|
process.exit(0);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// -h / --help: a print-and-exit MODE (like --version), handled before the arg walk so `-h` (a single
|
|
51
|
+
// dash) is never mistaken for the scan target by the positional fallthrough below.
|
|
52
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
53
|
+
console.log(`candor-ts ${PKG_VERSION} — TypeScript/JavaScript effect scanner (candor-spec ${SPEC_VERSION})
|
|
54
|
+
|
|
55
|
+
USAGE: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--allow-js] [--agents] [--version]
|
|
56
|
+
|
|
57
|
+
<target> a dir, a .ts file, or a tsconfig.json to scan
|
|
58
|
+
--out <prefix> write the report to <prefix>.json + <prefix>.callgraph.json
|
|
59
|
+
--json print the report as JSON to stdout (instead of writing files)
|
|
60
|
+
--policy <file> enforce a policy file (deny/pure/allow/forbid, candor-spec §6.2) — exit 1 on a
|
|
61
|
+
violation, 2 if unreadable; honours $CANDOR_POLICY when the flag is absent
|
|
62
|
+
--allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
|
|
63
|
+
--agents print the agent contract for this build (AGENTS.md)
|
|
64
|
+
-V, --version print the build and spec version (offline)
|
|
65
|
+
-h, --help show this help
|
|
66
|
+
|
|
67
|
+
See https://github.com/tombaldwin/candor`);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
// ---- args ----------------------------------------------------------------------------------------
|
|
51
72
|
// ONE pass: the first non-flag is the target; value-taking flags consume the next arg and FAIL on a
|
|
52
73
|
// missing/flag-shaped value; an unknown flag fails; flags may precede the target. `--agents` is a
|
|
53
74
|
// flag (a print-and-exit MODE) — it must NOT fire when it is the VALUE of --out/--policy, which the
|
|
54
75
|
// value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
|
|
55
|
-
const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>] [--allow-js] [--agents] [--version]";
|
|
76
|
+
const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--allow-js] [--agents] [--version] [--help]";
|
|
56
77
|
const argv = process.argv.slice(2);
|
|
57
|
-
let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, allowJs = false, wantAgents = false;
|
|
78
|
+
let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, allowJs = false, wantAgents = false, wantJson = false;
|
|
58
79
|
for (let i = 0; i < argv.length; i++) {
|
|
59
80
|
const a = argv[i];
|
|
60
81
|
if (a === "--agents") wantAgents = true;
|
|
82
|
+
else if (a === "--json") wantJson = true;
|
|
61
83
|
else if (a === "--allow-js") allowJs = true;
|
|
62
84
|
else if (a === "--out" || a === "--policy") {
|
|
63
85
|
const v = argv[i + 1];
|
|
@@ -65,7 +87,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
65
87
|
if (a === "--out") outPrefix = v; else policyPath = v;
|
|
66
88
|
i++;
|
|
67
89
|
}
|
|
68
|
-
|
|
90
|
+
// Any leading-dash token that isn't a recognized flag is an unknown flag — NOT a positional target
|
|
91
|
+
// (SPEC §6.2/§7). `-h`/`-V`/`--help`/`--version` are print-and-exit modes consumed above, so by here
|
|
92
|
+
// a single-dash token (`-x`, the typo `-policy`) can only be a mistake; treating it as the scan
|
|
93
|
+
// target would silently scan the wrong thing.
|
|
94
|
+
else if (a.startsWith("-")) { console.error(`candor-ts: unknown flag ${a} (${usage})`); process.exit(2); }
|
|
69
95
|
else if (target === null) target = a;
|
|
70
96
|
else if (outPrefix === null) outPrefix = a; // legacy positional prefix
|
|
71
97
|
else { console.error(`candor-ts: unexpected extra argument ${a} (${usage})`); process.exit(2); }
|
|
@@ -152,13 +178,14 @@ if (!compilerOptions.typeRoots) {
|
|
|
152
178
|
compilerOptions.typeRoots = roots;
|
|
153
179
|
}
|
|
154
180
|
if (!outPrefix) outPrefix = path.join(rootDir, ".candor", "report");
|
|
181
|
+
// --json prints the report to stdout and writes NOTHING, so skip creating the (otherwise default) .candor/ dir.
|
|
155
182
|
// The scanned package's name — the first half of the cross-package join key (SPEC §2 `hash`).
|
|
156
183
|
let pkgName = path.basename(rootDir);
|
|
157
184
|
try {
|
|
158
185
|
const pj = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
159
186
|
if (pj.name) pkgName = pj.name;
|
|
160
187
|
} catch {}
|
|
161
|
-
fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
|
|
188
|
+
if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
|
|
162
189
|
|
|
163
190
|
// A target with declared dependencies but no node_modules resolves almost nothing — the scan
|
|
164
191
|
// would "succeed" with a near-total-Unknown report a fresh user could ship (CTA-dogfood finding).
|
|
@@ -170,8 +197,8 @@ fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
|
|
|
170
197
|
const deps = JSON.parse(fs.readFileSync(pkg, "utf8")).dependencies ?? {};
|
|
171
198
|
if (Object.keys(deps).length > 0)
|
|
172
199
|
console.error("candor-ts: WARNING — the target declares dependencies but has no node_modules; " +
|
|
173
|
-
"imports
|
|
174
|
-
"Run `npm install` in the target first.");
|
|
200
|
+
"imports won't resolve, so calls into those packages read pure/invisible (not Unknown) " +
|
|
201
|
+
"and effects through them are silently dropped. Run `npm install` in the target first.");
|
|
175
202
|
} catch {}
|
|
176
203
|
}
|
|
177
204
|
// Prisma's client types are GENERATED — a project with the prisma dependency but no generated
|
|
@@ -1993,8 +2020,13 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
|
1993
2020
|
// half-written report. An in-place writeFileSync leaves a truncation window where JSON.parse throws;
|
|
1994
2021
|
// rename(2) is atomic within a filesystem, so a reader sees either the old report or the new one whole.
|
|
1995
2022
|
const writeAtomic = (file, text) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
|
|
1996
|
-
|
|
1997
|
-
|
|
2023
|
+
// --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
|
|
2024
|
+
if (wantJson) {
|
|
2025
|
+
console.log(JSON.stringify(envelope, null, 1));
|
|
2026
|
+
} else {
|
|
2027
|
+
writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
|
|
2028
|
+
writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
|
|
2029
|
+
}
|
|
1998
2030
|
// Type-hierarchy sidecar (SPEC §4 / 0.7): each project class/interface (qualified `mod.Name`, matching
|
|
1999
2031
|
// the `mod.Class.member` fn quals) -> its qualified direct supertypes/interfaces. Compact (O(types)),
|
|
2000
2032
|
// lets `callers --include-unknown` resolve whether a confirmed reacher is an override of a `dispatch:`
|
|
@@ -2018,8 +2050,21 @@ for (const sf of sources) {
|
|
|
2018
2050
|
ts.forEachChild(node, walk);
|
|
2019
2051
|
})(sf);
|
|
2020
2052
|
}
|
|
2021
|
-
|
|
2022
|
-
|
|
2053
|
+
if (!wantJson) {
|
|
2054
|
+
writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
|
|
2055
|
+
console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
|
|
2056
|
+
}
|
|
2057
|
+
{
|
|
2058
|
+
// Effect breakdown — make the result visible at a glance, not just a count + a file path.
|
|
2059
|
+
const counts = {};
|
|
2060
|
+
for (const e of functions) for (const x of e.inferred) counts[x] = (counts[x] || 0) + 1;
|
|
2061
|
+
const breakdown = ["Net", "Fs", "Db", "Exec", "Ipc", "Env", "Clipboard", "Clock", "Log", "Rand"]
|
|
2062
|
+
.filter((k) => counts[k]).map((k) => `${k} ${counts[k]}`).join(" · ");
|
|
2063
|
+
const unknown = counts.Unknown || 0;
|
|
2064
|
+
if (breakdown || unknown) {
|
|
2065
|
+
console.error(` ${breakdown}${unknown ? `${breakdown ? " · " : ""}Unknown ${unknown} (disclosed)` : ""}`);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2023
2068
|
if (unlistedSeen.size > 0) {
|
|
2024
2069
|
const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
2025
2070
|
const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
|
|
@@ -2043,7 +2088,10 @@ if (policyPath) {
|
|
|
2043
2088
|
const incompleteMap = new Map();
|
|
2044
2089
|
for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
|
|
2045
2090
|
const v = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
|
|
2046
|
-
|
|
2091
|
+
// In --json mode stdout is the §2 envelope and must stay pure JSON — route the gate's
|
|
2092
|
+
// [AS-EFF-…] violation lines to stderr so a `candor-ts --json --policy … | jq` pipe never breaks.
|
|
2093
|
+
const emitViolation = wantJson ? (l) => console.error(l) : (l) => console.log(l);
|
|
2094
|
+
for (const line of v) emitViolation(line);
|
|
2047
2095
|
if (v.length) {
|
|
2048
2096
|
console.error(`candor-ts: ${v.length} policy violation(s)`);
|
|
2049
2097
|
process.exit(1);
|