candor-ts 0.8.7 → 0.8.9
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 +37 -15
- package/PROVE-IT.md +3 -1
- package/README.md +41 -12
- package/lsp.mjs +125 -13
- package/package.json +1 -1
- package/scan-core.mjs +3 -3
- package/scan.mjs +84 -13
package/AGENTS.md
CHANGED
|
@@ -12,16 +12,16 @@ 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
|
|
16
|
-
> build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.8)."*
|
|
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
|
>
|
|
19
19
|
> **Staying current is your job, not candor's.** candor never phones home — it audits and denies the
|
|
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
|
|
24
|
-
> (you're on
|
|
23
|
+
> npm. If it's behind, **ask the user before upgrading** — e.g. *"candor-ts `<latest>` is available
|
|
24
|
+
> (you're on `<installed>`) — 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.
|
|
@@ -41,19 +41,32 @@ npx -y candor-ts <dir> --allow-js # also analyze .js/.mjs sources (walks t
|
|
|
41
41
|
|
|
42
42
|
This writes `<project-dir>/.candor/report.json` and `.candor/report.callgraph.json` (override
|
|
43
43
|
with `--out <prefix>`). **Install the TARGET's dependencies first** (`npm install` in the project)
|
|
44
|
-
— without node_modules, imports don't resolve and most functions
|
|
44
|
+
— without node_modules, imports don't resolve and most functions read `Unknown` (disclosed; the
|
|
45
45
|
scanner warns loudly). Add `--policy <file>` (or set `CANDOR_POLICY`) to enforce a §6.2 policy over the
|
|
46
46
|
scan: exit 1 on violation, exit 2 LOUDLY if the policy file is unreadable. `--gate-json <file|->`
|
|
47
47
|
additionally writes the structured verdict `{spec, ok, violations:[{rule,fn,effects,detail}]}`
|
|
48
48
|
(spec §3.3) — the machine-readable form CI/SARIF converters consume, from the SAME violations that
|
|
49
|
-
set the exit code. A checked-in `.candor/config` (spec §3.4; `policy <file>` / `
|
|
50
|
-
key per line, discovered walking UP from the scan target, relative values
|
|
51
|
-
repo) is the no-env-wiring floor; flag → env → config → default.
|
|
49
|
+
set the exit code. A checked-in `.candor/config` (spec §3.4; `policy <file>` / `baseline <report>` /
|
|
50
|
+
`deps <paths>`, one key per line, discovered walking UP from the scan target, relative values
|
|
51
|
+
anchored to the config's repo) is the no-env-wiring floor; flag → env → config → default.
|
|
52
|
+
|
|
53
|
+
**The AS-EFF-005 baseline guard** (spec §7): set `CANDOR_BASELINE=<saved report.json>` (or the
|
|
54
|
+
config `baseline` key) and the scan compares per function — an EXISTING function that gained an
|
|
55
|
+
effect versus the baseline fails the run (exit 1, `[AS-EFF-005]` lines, records join `--gate-json`);
|
|
56
|
+
new functions are exempt. Fail-closed: an unparseable baseline, or one from a different engine
|
|
57
|
+
build, is invalid gate input — exit 2 WITHOUT evaluating (never a silent skip); an absent file is a
|
|
58
|
+
note and the guard is inactive. `query diff` is the read-only twin: it DISCLOSES a build mismatch
|
|
59
|
+
(⚠, exit 0) instead of failing — use the scan-time guard, not `diff`, as the CI gate. Semantics
|
|
60
|
+
match the reference engine (candor-java).
|
|
52
61
|
|
|
53
62
|
**Report shape:** the file is `{ "candor": {version, toolchain, spec}, "functions": [...] }`;
|
|
54
63
|
`functions` is an **array** of entries (not a map — don't index it by name), each carrying **`fn`**
|
|
55
64
|
— module-qualified, `.`-separated
|
|
56
|
-
(`src.db.save` for `save()` in `src/db.ts`; class methods are `src.api.Client.send
|
|
65
|
+
(`src.db.save` for `save()` in `src/db.ts`; class methods are `src.api.Client.send`; a function
|
|
66
|
+
declared inside a TS `namespace` carries the namespace segments too — `src.util.Ns.helper` — in
|
|
67
|
+
`fn` AND the callgraph/hierarchy keys, while its `hash` keeps the bare local name for cross-package
|
|
68
|
+
joining; builds before 0.8.7 omitted the namespace segments, so an engine upgrade across that line
|
|
69
|
+
is baseline-invalidating — regenerate saved reports) — with
|
|
57
70
|
`inferred` (the full transitive set) / `direct` / `unresolved` / optional `hosts`/`cmds`/`paths`/
|
|
58
71
|
`tables` (the literal surfaces). **Only effectful-or-unresolved functions appear in the report;
|
|
59
72
|
pure functions are omitted** — a function present in the callgraph sidecar but absent from
|
|
@@ -61,7 +74,7 @@ pure functions are omitted** — a function present in the callgraph sidecar but
|
|
|
61
74
|
(a test file? an unexported arrow inside an object literal?) — conclude nothing.
|
|
62
75
|
|
|
63
76
|
A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) carries
|
|
64
|
-
`unitKind: "export"` (spec 0.
|
|
77
|
+
`unitKind: "export"` (spec 0.8, informative); ordinary functions omit the field.
|
|
65
78
|
|
|
66
79
|
**Multi-package (monorepos / private deps):** point `CANDOR_DEPS` at the dependencies' reports
|
|
67
80
|
(a path list, or a directory of `*.json`); an unclassified call into a package with a loaded
|
|
@@ -94,7 +107,11 @@ And as an MCP server, so an agent pulls these as tools instead of shelling out:
|
|
|
94
107
|
`CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…,
|
|
95
108
|
plus `candor_gate`/`candor_whatif` — a given-but-unreadable `policy` is a loud tool error, never a
|
|
96
109
|
clean verdict). `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and reports the
|
|
97
|
-
edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP editor
|
|
110
|
+
edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP editor,
|
|
111
|
+
plus the pre-edit whatif as a code action (`candor: what if <fn> performed <E>?` → the
|
|
112
|
+
`candor.whatif` command: the query-core whatif's verdict + blast radius as a showMessage and a
|
|
113
|
+
transient diagnostic, cleared on the file's next open/save — plain LSP, so helix/neovim/VS
|
|
114
|
+
Code/JetBrains-via-LSP4IJ all get it without client code).
|
|
98
115
|
CAVEAT — the MCP/LSP gate verdicts are computed FROM THE REPORT: the engine's own `--policy` /
|
|
99
116
|
`--gate-json` run additionally fails an allow rule whose literal surface is incomplete (a masked
|
|
100
117
|
endpoint — internal state, not a report field), so treat a report-side green as advisory and the
|
|
@@ -117,16 +134,17 @@ want-JSON flag.
|
|
|
117
134
|
- **Arrow-const functions are first-class**: `export const f = async () => …` is analyzed and named
|
|
118
135
|
like a declaration; calls to it are edges. An arrow assigned inside a function body becomes its
|
|
119
136
|
own unit (`src.x.helper`) — effects still propagate to the enclosing caller through the edge.
|
|
120
|
-
- **The classifier is curated**
|
|
121
|
-
|
|
137
|
+
- **The classifier is curated** — the node builtins plus a growing npm tier; the README's
|
|
138
|
+
"classifier" paragraph is the ONE current list (this file deliberately doesn't duplicate it — a
|
|
139
|
+
vendored copy here drifted a full generation once).
|
|
122
140
|
An unlisted package contributes nothing — an effect through it is invisible, not `Unknown`. The
|
|
123
141
|
scanner **names these per scan**: the receipt's `κ doesn't know N packages…` line lists every npm
|
|
124
142
|
package the code demonstrably calls that κ neither classifies nor has reviewed-pure — read it
|
|
125
143
|
before concluding "no effect" through anything it names.
|
|
126
144
|
- **`process.env.X` reads are `Env`** (a property read, not a call); `Date.now()` is `Clock`.
|
|
127
|
-
- **DI-style code reads `Unknown` a lot,
|
|
145
|
+
- **DI-style code reads `Unknown` a lot, by design**: a function-typed parameter or field being
|
|
128
146
|
called is genuinely indeterminate (rimraf's injected-fs style yields many `Unknown`s — that's the
|
|
129
|
-
§4 contract, not noise). When every visible call site passes a *named* function, the callback
|
|
147
|
+
§4 disclosure contract, not noise). When every visible call site passes a *named* function, the callback
|
|
130
148
|
resolves instead. And a method call on a **local-interface-typed value** (`store.save()` where
|
|
131
149
|
`class PgStore implements Store`) resolves to the local implementors when the dispatch is narrow
|
|
132
150
|
(≤12 classes) — the layered-DI pattern carries its real effects; only an interface with no
|
|
@@ -147,6 +165,10 @@ allow Db in db orders ledger.* # the db module touches ONLY these tables
|
|
|
147
165
|
forbid domain -> infra
|
|
148
166
|
```
|
|
149
167
|
|
|
168
|
+
Note the `pure` semantics: it forbids every *effect* but NOT `Unknown` — the §4 trust marker is
|
|
169
|
+
uncertainty, not an effect (matching the reference engine, candor-java). Where a boundary must also
|
|
170
|
+
exclude the unverifiable case, say so explicitly: `deny Unknown <scope>` is the knob.
|
|
171
|
+
|
|
150
172
|
## The trust rule — do not skip this
|
|
151
173
|
|
|
152
174
|
`inferred` is authoritative for what candor-ts resolved. When `unresolved` is true (or `Unknown` is
|
package/PROVE-IT.md
CHANGED
|
@@ -30,7 +30,9 @@ TRANSITIVE caller, across all files?" Work as you normally would (grep, read). W
|
|
|
30
30
|
list to ./candor-manual-<target>.txt in the repo root (NOT a fixed /tmp name — repeated runs must
|
|
31
31
|
not cross-contaminate) — one function per line, named the way the callgraph keys them:
|
|
32
32
|
module-qualified with "." segments (src.db.save for save() in src/db.ts; class members
|
|
33
|
-
src.api.Client.send, constructors src.api.Client.constructor; a
|
|
33
|
+
src.api.Client.send, constructors src.api.Client.constructor; a function declared inside a TS
|
|
34
|
+
namespace carries the namespace segments — src.util.Ns.helper for helper() in namespace Ns — only
|
|
35
|
+
the report's `hash` field keeps the bare local name; a NESTED named function is keyed flat
|
|
34
36
|
under its module, while an anonymous arrow — including one wrapped in a cast — folds into its
|
|
35
37
|
enclosing function). Also note roughly how
|
|
36
38
|
many file-reads/searches it took you.
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
**candor for TypeScript: per-function side effects, transitively, with a deterministic policy
|
|
6
6
|
gate.** candor-ts resolves every call through the TypeScript compiler API and reports, for each
|
|
7
7
|
function in your project, which effects it can reach — `Net`, `Fs`, `Db`, `Exec`, `Env`, `Clock`,
|
|
8
|
-
… — **including effects inherited through any chain of calls across files**, with
|
|
8
|
+
… — **including effects inherited through any chain of calls across files**, with a disclosed
|
|
9
9
|
`Unknown` wherever resolution fails (a callback value, an `any`-typed callee — never silently
|
|
10
10
|
pure). A [candor-spec](https://github.com/tombaldwin/candor-spec) implementation, sibling of the
|
|
11
11
|
[Rust](https://github.com/tombaldwin/candor-rust) and
|
|
@@ -20,6 +20,8 @@ node scan.mjs <project-dir> # tsconfig.json honored; tests exclu
|
|
|
20
20
|
# <dir>/.candor/report.json + .callgraph.json
|
|
21
21
|
node scan.mjs . --policy .candor/policy # the §6.2 gate: exit 1 on violation, 2 if unreadable
|
|
22
22
|
node scan.mjs . --gate-json gate.json # + the structured verdict {spec, ok, violations} (§3.3)
|
|
23
|
+
CANDOR_BASELINE=saved.json node scan.mjs . # AS-EFF-005 guard: exit 1 if an existing fn GAINED an
|
|
24
|
+
# effect vs the saved report; 2 if it can't evaluate
|
|
23
25
|
|
|
24
26
|
node scan.mjs --version # installed build + spec contract (offline), + upgrade line
|
|
25
27
|
|
|
@@ -35,14 +37,31 @@ node query.mjs diff .candor/report baseline 1 # per-function effect delta (
|
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
A checked-in **`.candor/config`** (spec §3.4) replaces the env wiring — `policy arch.policy` /
|
|
38
|
-
`deps <report paths>` one per line, discovered by walking up from the
|
|
39
|
-
resolve against the config's repo, so CI is "point at the repo". A
|
|
40
|
-
config/policy fails loud (exit 2), never silently gateless.
|
|
40
|
+
`baseline <report.json>` / `deps <report paths>` one per line, discovered by walking up from the
|
|
41
|
+
scan target; relative values resolve against the config's repo, so CI is "point at the repo". A
|
|
42
|
+
configured-but-unusable config/policy/baseline fails loud (exit 2), never silently gateless.
|
|
43
|
+
|
|
44
|
+
The scan-time **baseline guard** (AS-EFF-005, spec §7) makes effect *regressions* un-shippable:
|
|
45
|
+
point `CANDOR_BASELINE` (or the config's `baseline` key) at a saved report, and any existing
|
|
46
|
+
function that **gained** an effect fails the scan — exit 1, the records join the `--gate-json`
|
|
47
|
+
verdict. New functions are exempt (reviewed as new code, not a regression). The guard is
|
|
48
|
+
fail-closed like the policy gate: a present-but-unparseable baseline, or one produced by a
|
|
49
|
+
different engine build (§2.1 — an engine upgrade is baseline-invalidating), exits 2 **without
|
|
50
|
+
evaluating**; only a genuinely absent file is a one-line note (guard not active). Keep the two
|
|
51
|
+
surfaces straight: `query diff` is the read-only comparison — it *discloses* a producing-build
|
|
52
|
+
mismatch (⚠, exit 0) and informs; the scan-time guard is the gate-grade fail-closed surface, the
|
|
53
|
+
one CI should hold. Semantics mirror the reference engine (candor-java) exactly.
|
|
41
54
|
|
|
42
55
|
**Staying current:** check your installed version and upgrade — [candor/AGENTS.md §2a](https://github.com/tombaldwin/candor/blob/main/AGENTS.md#2a-staying-current--check-the-version-upgrade). `npx -y candor-ts --version` prints the build, the spec, and the upgrade one-liner (offline; candor never phones home).
|
|
43
56
|
|
|
44
57
|
Function names are module-qualified with `.` segments (`src.db.save`), so policy scopes read
|
|
45
|
-
naturally
|
|
58
|
+
naturally. A function declared inside a TS `namespace` carries the namespace segments in `fn` and
|
|
59
|
+
the callgraph keys (`src.util.Ns.helper`) — so layer policies on namespaces bite — while the §2
|
|
60
|
+
`hash` join key keeps the bare local name; builds before 0.8.7 omitted the segments, so crossing
|
|
61
|
+
that line invalidates saved baselines (regenerate them). A `pure <scope>` rule forbids every
|
|
62
|
+
*effect* but not `Unknown` — the §4 trust marker is uncertainty, not an effect (matching the
|
|
63
|
+
reference engine, candor-java); `deny Unknown <scope>` is the explicit knob for boundaries that
|
|
64
|
+
must also exclude the unverifiable case.
|
|
46
65
|
|
|
47
66
|
```text
|
|
48
67
|
# .candor/policy
|
|
@@ -104,6 +123,14 @@ engine's report — and never scans. (Both report-computed gates are advisory: t
|
|
|
104
123
|
`--gate-json` run additionally fails masked/incomplete literal surfaces and is the authoritative
|
|
105
124
|
CI form.)
|
|
106
125
|
|
|
126
|
+
It also answers the pre-edit question in place: inside a function, a code action per boundary
|
|
127
|
+
effect the fn doesn't yet perform — `candor: what if handler performed Net?` — runs the same
|
|
128
|
+
whatif as `candor-ts-query whatif`/`candor_whatif` (blast radius + the policy rule that WOULD
|
|
129
|
+
fire) and shows the verdict as a message plus a transient diagnostic at the function (cleared on
|
|
130
|
+
the file's next open/save; with no policy discovered it says so and reports the radius alone).
|
|
131
|
+
Plain `textDocument/codeAction` + `workspace/executeCommand` (`candor.whatif`) — it works
|
|
132
|
+
unmodified in helix, neovim, VS Code, and JetBrains via LSP4IJ.
|
|
133
|
+
|
|
107
134
|
**The live loop** — `candor-ts-watch` keeps the report fresh as the agent edits, so the answers are
|
|
108
135
|
about the *current* code, not a stale snapshot:
|
|
109
136
|
|
|
@@ -132,16 +159,17 @@ a κ-ledger blind spot. A name outside the §1 vocabulary voids the declaration
|
|
|
132
159
|
silently narrow a surface). And `candor-ts-query gains <cur> <base>` flags the **supply-chain**
|
|
133
160
|
delta — the effects a surface *gained* between two reports.
|
|
134
161
|
Real-world consequence, measured on [rimraf](https://github.com/isaacs/rimraf) (50 files, 55
|
|
135
|
-
functions analyzed): its DI-style fs injection means many functions
|
|
162
|
+
functions analyzed): its DI-style fs injection means many functions read `Unknown`, disclosed —
|
|
136
163
|
that's the contract working, not noise. The report says "can reach", never "does"; an absent
|
|
137
164
|
literal is never a claim of absence.
|
|
138
165
|
|
|
139
166
|
## Cross-engine consistency — machine-checked
|
|
140
167
|
|
|
141
|
-
candor-ts
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
168
|
+
candor-ts is one of the **four code engines** (with the reference engine candor-java, the Rust
|
|
169
|
+
engines, and candor-swift) held together by the spec's **16-part conformance suite**: the shared
|
|
170
|
+
effect-set oracle, the §6.2 policy-grammar battery (including `allow Db`), the §3.1 query-shape
|
|
171
|
+
and match-ladder checks, the gate exit-code contracts, and the newer parts up through the
|
|
172
|
+
pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on every push to the spec.
|
|
145
173
|
|
|
146
174
|
## What the analysis core implements (and where the spec told it how)
|
|
147
175
|
|
|
@@ -176,13 +204,14 @@ read the Rust source".
|
|
|
176
204
|
0.8.x, speaking candor-spec 0.8: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
177
205
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
178
206
|
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
179
|
-
real, behaviorally tested (`npm test` —
|
|
207
|
+
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
|
180
208
|
with verified teeth** (`node fuzz.mjs` — spec §7.13: generated effect chains through every encoded
|
|
181
209
|
call form, any silent-pure = red), and conformance-held against the Rust/JVM/Swift engines. The
|
|
182
210
|
npm classifier tier is deliberately curated and keeps growing case-by-case. Entry points
|
|
183
211
|
(Nest/Next populations), `unknownWhy` origins, `reachable`, cross-package inheritance
|
|
184
212
|
(`CANDOR_DEPS` + the spec §2 `hash`, version-trusted per §2.1), and `--allow-js` are all in.
|
|
185
|
-
On npm: `npx -y candor-ts <dir>`.
|
|
213
|
+
On npm: `npx -y candor-ts <dir>`. Per-release detail (⚠ marks report/verdict-affecting changes):
|
|
214
|
+
[CHANGELOG.md](CHANGELOG.md).
|
|
186
215
|
|
|
187
216
|
## Development
|
|
188
217
|
|
package/lsp.mjs
CHANGED
|
@@ -13,6 +13,18 @@
|
|
|
13
13
|
* red in CI. The engine's --gate-json is the authoritative form (same caveat as MCP candor_gate).
|
|
14
14
|
* • Hover: effect PROVENANCE — for each inherited effect, the `path` hop chain to the function that
|
|
15
15
|
* performs it directly ("Net via mid → leaf (source)"), plus unknownWhy when the fn discloses opacity.
|
|
16
|
+
* • CodeAction (pre-edit whatif): inside a function the report knows, one action per BOUNDARY effect
|
|
17
|
+
* the fn does NOT already perform — `candor: what if <fn> performed Net?`. Each resolves to the
|
|
18
|
+
* `candor.whatif` workspace/executeCommand, answered server-side with the SAME query-core whatif the
|
|
19
|
+
* CLI and MCP use (single-source): a window/showMessage one-liner (the policy rule that WOULD fire +
|
|
20
|
+
* the blast radius; no policy discovered → radius only, said so) and a transient Information
|
|
21
|
+
* diagnostic at the fn's line carrying the detail (rule + first callers), cleared on the next
|
|
22
|
+
* didOpen/didSave/didChange of that file or replaced by re-running the action. Plain LSP — works in
|
|
23
|
+
* helix/neovim/VS Code/JetBrains-via-LSP4IJ without client-side code.
|
|
24
|
+
*
|
|
25
|
+
* Perf (measured on the 5k-fn synthetic fixture in test-lsp.mjs — 50 files × 100 fns, one 5k-deep
|
|
26
|
+
* call chain, worst-case doc): codeLens ≈ 63ms, codeAction ≈ 5ms per request, INCLUDING the
|
|
27
|
+
* per-request report re-read. No caching layer — the freshness contract stays "re-read per request".
|
|
16
28
|
*
|
|
17
29
|
* The server is a pure CONSUMER of the spec report envelope + callgraph sidecar (any engine — JVM /
|
|
18
30
|
* Rust / TS / Swift / agents; the same read layer as candor-mcp), and it never scans (the analyzer
|
|
@@ -33,7 +45,7 @@ import { createRequire } from "node:module";
|
|
|
33
45
|
import nodePath from "node:path";
|
|
34
46
|
import { fileURLToPath } from "node:url";
|
|
35
47
|
import * as Q from "./query-core.mjs";
|
|
36
|
-
import { discoverConfigPolicy, evaluatePolicy, parsePolicy } from "./policy.mjs";
|
|
48
|
+
import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches } from "./policy.mjs";
|
|
37
49
|
|
|
38
50
|
// Version: from the sibling package.json when running inside the npm package; a single-file BUNDLE of
|
|
39
51
|
// this server (the IDE-plugin embedding) has no sibling package.json — fall back rather than crash.
|
|
@@ -110,17 +122,22 @@ function codeLenses(docPath) {
|
|
|
110
122
|
});
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
// The entry ENCLOSING a line: the report pins each fn at its declaration line, so the match is the
|
|
126
|
+
// greatest entry line ≤ the cursor (functions are sequential in a file — a sound approximation that
|
|
127
|
+
// needs no parser). Shared by hover and codeAction — one rule for "which function is the cursor in".
|
|
128
|
+
function enclosingEntry(docPath, line, fns = null) {
|
|
129
|
+
const found = entriesInDoc(docPath, fns);
|
|
130
|
+
if (!found || !found.length) return null;
|
|
131
|
+
return found.filter((x) => x.line <= line).sort((a, b) => b.line - a.line)[0] ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
113
134
|
// ---- Hover: effect provenance at the cursor ----------------------------------------------------------
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// needs no parser). For each inferred effect: direct → "performed here"; inherited → the §3.1 `path`
|
|
117
|
-
// chain to the direct source. unknownWhy rides along when the fn introduces opacity.
|
|
135
|
+
// For each inferred effect: direct → "performed here"; inherited → the §3.1 `path` chain to the direct
|
|
136
|
+
// source. unknownWhy rides along when the fn introduces opacity.
|
|
118
137
|
function hoverAt(docPath, line) {
|
|
119
138
|
if (!hasReport(reportPrefix)) return null;
|
|
120
|
-
const fns = Q.loadReport(reportPrefix); // ONE load per request (
|
|
121
|
-
const
|
|
122
|
-
if (!found || !found.length) return null;
|
|
123
|
-
const at = found.filter((x) => x.line <= line).sort((a, b) => b.line - a.line)[0];
|
|
139
|
+
const fns = Q.loadReport(reportPrefix); // ONE load per request (enclosingEntry reuses it)
|
|
140
|
+
const at = enclosingEntry(docPath, line, fns);
|
|
124
141
|
if (!at) return null;
|
|
125
142
|
const { entry } = at;
|
|
126
143
|
const cg = Q.loadCallgraph(reportPrefix);
|
|
@@ -188,12 +205,90 @@ function publishDiagnostics(uri) {
|
|
|
188
205
|
let docPath;
|
|
189
206
|
try { docPath = fileURLToPath(uri); } catch { return; }
|
|
190
207
|
try {
|
|
191
|
-
|
|
208
|
+
const diags = diagnosticsFor(docPath).concat(transient.get(uri) ?? []);
|
|
209
|
+
send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri, diagnostics: diags } });
|
|
192
210
|
} catch (e) {
|
|
193
211
|
logMessage(`candor-lsp: diagnostics failed for ${uri}: ${e.message}`);
|
|
194
212
|
}
|
|
195
213
|
}
|
|
196
214
|
function logMessage(message) { send({ jsonrpc: "2.0", method: "window/logMessage", params: { type: 2, message } }); }
|
|
215
|
+
function showMessage(type, message) { send({ jsonrpc: "2.0", method: "window/showMessage", params: { type, message } }); }
|
|
216
|
+
|
|
217
|
+
// ---- CodeAction: the pre-edit whatif (spec §3.1 whatif, rendered as an editor action) -----------------
|
|
218
|
+
// From a position inside a function the report knows, offer "what if <fn> performed <E>?" for each
|
|
219
|
+
// BOUNDARY effect (Q.CONTAINED — ambient effects gate nothing) the fn does not already carry. The action
|
|
220
|
+
// carries a plain `command` (no client-side resolve, no edit) so it works in any LSP client verbatim.
|
|
221
|
+
const WHATIF_COMMAND = "candor.whatif";
|
|
222
|
+
function codeActions(docPath, uri, range) {
|
|
223
|
+
const at = enclosingEntry(docPath, range?.start?.line ?? 0);
|
|
224
|
+
if (!at) return []; // a fn the report doesn't know → no actions, never an error
|
|
225
|
+
const have = new Set(at.entry.inferred || []);
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const eff of Q.CONTAINED) { // ≤6 boundary effects — the natural cap
|
|
228
|
+
if (have.has(eff)) continue;
|
|
229
|
+
out.push({
|
|
230
|
+
title: `candor: what if ${at.entry.fn} performed ${eff}?`,
|
|
231
|
+
command: {
|
|
232
|
+
title: `candor: what if ${at.entry.fn} performed ${eff}?`,
|
|
233
|
+
command: WHATIF_COMMAND,
|
|
234
|
+
arguments: [{ fn: at.entry.fn, effect: eff, uri, line: at.line }],
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Transient whatif diagnostics (Information severity, appended to the gate diagnostics on publish):
|
|
242
|
+
// uri -> Diagnostic[]. Cleared on the next didOpen/didSave/didChange of that file; re-running the
|
|
243
|
+
// action replaces the previous answer (one live whatif overlay per file, not an accumulating pile).
|
|
244
|
+
const transient = new Map();
|
|
245
|
+
function clearTransient(uri) {
|
|
246
|
+
if (transient.delete(uri)) publishDiagnostics(uri); // republish without the overlay
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The candor.whatif command: the SAME query-core whatif the CLI (`query.mjs whatif`) and MCP
|
|
250
|
+
// (`candor_whatif`) run — blast radius over the callgraph + the deny rules that WOULD fire, against the
|
|
251
|
+
// live policy (CANDOR_POLICY / .candor/config discovery, same source as the diagnostics). Everything is
|
|
252
|
+
// re-read per call (the freshness contract). Malformed args → logMessage + null, never a throw.
|
|
253
|
+
function runWhatif(a) {
|
|
254
|
+
if (!a || typeof a !== "object" || typeof a.fn !== "string" || typeof a.effect !== "string") {
|
|
255
|
+
logMessage(`candor-lsp: ${WHATIF_COMMAND} called with malformed arguments (expected [{ fn, effect, uri?, line? }]) — ignored`);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (!hasReport(reportPrefix)) {
|
|
259
|
+
showMessage(2, "candor: no report found — scan first (candor-ts <dir> --out .candor/report)");
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
const policyText = activePolicy();
|
|
263
|
+
const r = Q.whatif(Q.loadCallgraph(reportPrefix), a.fn, a.effect,
|
|
264
|
+
policyText === null ? null : parsePolicy(policyText), scopeMatches);
|
|
265
|
+
if (r === null) {
|
|
266
|
+
showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
|
|
270
|
+
const rules = [...new Set(r.violations.map((v) => v.rule))];
|
|
271
|
+
const verdict = policyText === null
|
|
272
|
+
? `candor: no policy discovered — blast radius only: ${callers.length} caller(s) would inherit ${a.effect}`
|
|
273
|
+
: rules.length
|
|
274
|
+
? `✗ ${rules[0]} would fire — ${callers.length} caller(s) inherit ${a.effect}`
|
|
275
|
+
: `✓ no policy rule fires — ${callers.length} caller(s) would inherit ${a.effect}`;
|
|
276
|
+
showMessage(rules.length ? 2 : 3, verdict); // warning when a rule fires, info otherwise
|
|
277
|
+
if (typeof a.uri === "string" && Number.isInteger(a.line)) { // the detail, pinned at the fn's line
|
|
278
|
+
const head = callers.slice(0, 10);
|
|
279
|
+
const lines = [`what if ${r.of.join(", ")} performed ${a.effect}? ${verdict}`];
|
|
280
|
+
if (rules.length > 1) lines.push(`rules: ${rules.join("; ")}`);
|
|
281
|
+
lines.push(head.length
|
|
282
|
+
? `callers: ${head.join(", ")}${callers.length > head.length ? ` +${callers.length - head.length} more` : ""}`
|
|
283
|
+
: "no callers — the blast radius is the function itself");
|
|
284
|
+
transient.set(a.uri, [{
|
|
285
|
+
range: { start: { line: a.line, character: 0 }, end: { line: a.line, character: 200 } },
|
|
286
|
+
severity: 3, source: "candor", code: "whatif", message: lines.join("\n"),
|
|
287
|
+
}]);
|
|
288
|
+
publishDiagnostics(a.uri);
|
|
289
|
+
}
|
|
290
|
+
return r; // the raw whatif result rides back as the executeCommand result (a thick client can render it)
|
|
291
|
+
}
|
|
197
292
|
|
|
198
293
|
// ---- the LSP method surface ---------------------------------------------------------------------------
|
|
199
294
|
function handle(msg) {
|
|
@@ -211,14 +306,19 @@ function handle(msg) {
|
|
|
211
306
|
textDocumentSync: { openClose: true, save: true, change: 0 }, // report-backed: buffer edits don't move the map
|
|
212
307
|
codeLensProvider: { resolveProvider: false },
|
|
213
308
|
hoverProvider: true,
|
|
309
|
+
codeActionProvider: { resolveProvider: false }, // actions carry their command inline
|
|
310
|
+
executeCommandProvider: { commands: [WHATIF_COMMAND] },
|
|
214
311
|
},
|
|
215
312
|
serverInfo: { name: "candor-lsp", version: VERSION },
|
|
216
313
|
});
|
|
217
314
|
}
|
|
218
315
|
if (method === "initialized" || method === "$/cancelRequest" || method === "$/setTrace") return;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
316
|
+
// didOpen/didSave/didChange drop the file's transient whatif overlay — a fresh look at the file (or an
|
|
317
|
+
// edit) invalidates a hypothetical answered against the previous state. didChange is not negotiated
|
|
318
|
+
// (change: 0) but is handled defensively for clients that send it anyway.
|
|
319
|
+
if (method === "textDocument/didOpen") { transient.delete(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
|
|
320
|
+
if (method === "textDocument/didSave") { transient.delete(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
|
|
321
|
+
if (method === "textDocument/didChange") return clearTransient(params.textDocument.uri);
|
|
222
322
|
if (method === "textDocument/didClose")
|
|
223
323
|
return send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri: params.textDocument.uri, diagnostics: [] } });
|
|
224
324
|
if (method === "textDocument/hover") {
|
|
@@ -229,6 +329,18 @@ function handle(msg) {
|
|
|
229
329
|
try { return result(id, codeLenses(fileURLToPath(params.textDocument.uri))); }
|
|
230
330
|
catch { return result(id, []); } // a non-file URI / unreadable report → no lenses, never a crash
|
|
231
331
|
}
|
|
332
|
+
if (method === "textDocument/codeAction") {
|
|
333
|
+
try { return result(id, codeActions(fileURLToPath(params.textDocument.uri), params.textDocument.uri, params.range)); }
|
|
334
|
+
catch { return result(id, []); } // unknown fn / non-file URI / unreadable report → no actions, never an error
|
|
335
|
+
}
|
|
336
|
+
if (method === "workspace/executeCommand") {
|
|
337
|
+
if (params?.command !== WHATIF_COMMAND) {
|
|
338
|
+
logMessage(`candor-lsp: unknown command \`${params?.command}\` — this server provides only ${WHATIF_COMMAND}`);
|
|
339
|
+
return result(id, null);
|
|
340
|
+
}
|
|
341
|
+
try { return result(id, runWhatif(params?.arguments?.[0])); }
|
|
342
|
+
catch (e) { logMessage(`candor-lsp: ${WHATIF_COMMAND} failed: ${e.message}`); return result(id, null); }
|
|
343
|
+
}
|
|
232
344
|
if (method === "shutdown") return result(id, null);
|
|
233
345
|
if (method === "exit") process.exit(0);
|
|
234
346
|
if (id !== undefined) error(id, -32601, `method not found: ${method}`);
|
package/package.json
CHANGED
package/scan-core.mjs
CHANGED
|
@@ -36,11 +36,11 @@ export const KAPPA_RULES = [
|
|
|
36
36
|
// 0/4/6 (or a boolean) with no socket, no fd, no syscall — pure functions. The whole-module Net rule
|
|
37
37
|
// once fabricated Net onto them; a real-world sweep on node-fetch caught it (its trustworthy URL
|
|
38
38
|
// predicates isOriginPotentiallyTrustworthy/isUrlPotentiallyTrustworthy call isIP() and inherited a
|
|
39
|
-
// FABRICATED Net — the
|
|
39
|
+
// FABRICATED Net — the precision failure — purely from this classification, with no local Net edge). Only
|
|
40
40
|
// these three named validators are freed; every genuine verb (connect/createConnection/createServer…)
|
|
41
41
|
// stays Net (the matcher excludes ONLY new + the three validators, nothing else).
|
|
42
42
|
// ALSO exempt the pure CONFIG/METADATA members the whole-module rule fabricated Net on (sweep [9], the
|
|
43
|
-
//
|
|
43
|
+
// precision failure — none touch a socket/fd/syscall): tls.getCiphers/createSecureContext/checkServerIdentity
|
|
44
44
|
// (cipher-list + cert helpers), http.validateHeaderName/validateHeaderValue (string validators, like
|
|
45
45
|
// isIP), and a Socket/Server's setKeepAlive/setNoDelay/ref/unref/address (TCP-option + bound-address
|
|
46
46
|
// metadata — no I/O). Every genuine verb still classifies; only these proven-pure names are freed.
|
|
@@ -51,7 +51,7 @@ export const KAPPA_RULES = [
|
|
|
51
51
|
// reverse query DNS servers directly). Was unclassified, so a `dns.resolve(...)` read silently pure.
|
|
52
52
|
// Same construction-and-pure-accessor carve-out as the net cluster: `new dns.Resolver()` ("new") is
|
|
53
53
|
// inert, and the SERVER-CONFIG accessors getServers/setServers/get|setDefaultResultOrder touch no
|
|
54
|
-
// network (in-process config) — classifying them Net would
|
|
54
|
+
// network (in-process config) — classifying them Net would be a FABRICATION (the precision failure). Every genuine
|
|
55
55
|
// resolver verb (lookup/resolve4/resolveMx/reverse/…) stays Net. Covers node:dns/promises too.
|
|
56
56
|
[/^(node:)?dns(\/promises)?$/,
|
|
57
57
|
/^(?!(new|getServers|setServers|getDefaultResultOrder|setDefaultResultOrder)$)/, "Net"],
|
package/scan.mjs
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* `any`-typed callee or a function-valued parameter/field IS the "could not resolve" case), and
|
|
14
14
|
* emit the §2 report envelope + the §2.2 call-graph sidecar (every analyzed function a key). With
|
|
15
15
|
* --policy (or CANDOR_POLICY), evaluate the §6.2 gate (AS-EFF-006/008/009) over the result: exit 1
|
|
16
|
-
* on violation, exit 2 LOUDLY on an unreadable policy.
|
|
16
|
+
* on violation, exit 2 LOUDLY on an unreadable policy. With CANDOR_BASELINE (or a config `baseline`
|
|
17
|
+
* key), run the AS-EFF-005 regression guard against a saved report: an existing fn gaining an effect
|
|
18
|
+
* is a violation (exit 1); an unparseable or different-build baseline is invalid gate input (exit 2).
|
|
17
19
|
*
|
|
18
20
|
* Usage: node scan.mjs <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>]
|
|
19
21
|
* node scan.mjs <file.ts> <out-prefix> (legacy positional form)
|
|
@@ -65,6 +67,10 @@ USAGE: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--po
|
|
|
65
67
|
-V, --version print the build and spec version (offline)
|
|
66
68
|
-h, --help show this help
|
|
67
69
|
|
|
70
|
+
CANDOR_BASELINE=<report.json> (or a .candor/config \`baseline\` key) runs the AS-EFF-005 regression
|
|
71
|
+
guard against a saved same-build report: exit 1 when an existing function gained an effect, exit 2
|
|
72
|
+
on an unparseable or different-build baseline (never evaluated), a stderr note when absent.
|
|
73
|
+
|
|
68
74
|
See https://github.com/tombaldwin/candor`);
|
|
69
75
|
process.exit(0);
|
|
70
76
|
}
|
|
@@ -168,6 +174,11 @@ const candorConfig = loadCandorConfig(target);
|
|
|
168
174
|
// precedence: the --policy flag / CANDOR_POLICY env already populated policyPath; the config is the floor.
|
|
169
175
|
// A BARE `policy` line ("" value) means configured-with-empty → the unreadable-policy path fails loud.
|
|
170
176
|
if (policyPath === null && candorConfig.policy !== undefined) policyPath = candorConfig.policy;
|
|
177
|
+
// baseline (the AS-EFF-005 regression guard, SPEC §7 item 5): CANDOR_BASELINE env → config `baseline`
|
|
178
|
+
// (path-valued keys are already resolved against the config's anchor above). No CLI flag — matching
|
|
179
|
+
// candor-java, the reference engine (env/config only). A BARE `baseline` line ("") fails loud below.
|
|
180
|
+
let baselinePath = process.env.CANDOR_BASELINE ?? null;
|
|
181
|
+
if (baselinePath === null && candorConfig.baseline !== undefined) baselinePath = candorConfig.baseline;
|
|
171
182
|
|
|
172
183
|
// ---- project discovery (a dir, a single file, or a tsconfig) --------------------------------------
|
|
173
184
|
let rootDir, fileNames, compilerOptions = {
|
|
@@ -1013,7 +1024,7 @@ function enclosing(node) {
|
|
|
1013
1024
|
// the decorated declaration's body. The parent chain of a decorator's expression is
|
|
1014
1025
|
// CallExpression → Decorator → MethodDeclaration/ClassDeclaration/Parameter, so `enclosing` otherwise
|
|
1015
1026
|
// lands on the decorated unit and FABRICATES the factory's effects onto that method/class/param and
|
|
1016
|
-
// every transitive caller (a
|
|
1027
|
+
// every transitive caller (a fabrication — @Entity/@Injectable factories that touch I/O would
|
|
1017
1028
|
// poison every decorated handler). Stop at the Decorator: the factory's own effects live in its own
|
|
1018
1029
|
// function unit; the application site attributes to nothing (load-time, like a no-arg decorator).
|
|
1019
1030
|
if (ts.isDecorator(p)) return null;
|
|
@@ -1220,7 +1231,7 @@ function noteOpaqueIteration(node, iterExpr, localResolved) {
|
|
|
1220
1231
|
// Callee names that INVOKE a function/method argument (so a fn-reference passed to one is reachable
|
|
1221
1232
|
// through it). Array/iterable HOFs, the timer/microtask schedulers, and Promise continuations. A
|
|
1222
1233
|
// STORE/compare/log sink (`set`/`push`/`add`/`includes`/`indexOf`/`concat`/`log`/`stringify`/…) is
|
|
1223
|
-
// deliberately ABSENT — edging there would fabricate the fn's effects on a pure path (the
|
|
1234
|
+
// deliberately ABSENT — edging there would fabricate the fn's effects on a pure path (the precision failure).
|
|
1224
1235
|
const HOF_INVOKERS = new Set([
|
|
1225
1236
|
"map", "forEach", "filter", "reduce", "reduceRight", "find", "findIndex", "findLast", "findLastIndex",
|
|
1226
1237
|
"some", "every", "flatMap", "sort", "group", "groupBy", "partition", "mapValues", "flatMapDeep",
|
|
@@ -1302,7 +1313,7 @@ function visitCalls(node) {
|
|
|
1302
1313
|
// on a non-local callee so a local callee that merely STORES (never invokes) keeps its precision.
|
|
1303
1314
|
// ONLY a callee that actually INVOKES its fn argument makes the reference reachable here. The
|
|
1304
1315
|
// earlier version edged for ANY non-local callee — fabricating the fn's effects onto a pure path
|
|
1305
|
-
// (the
|
|
1316
|
+
// (the precision failure) for STORE/compare/log sinks that never call it (`map.set(k, fn)`,
|
|
1306
1317
|
// `arr.push(fn)`, `arr.includes(fn)`, `console.log(fn)`, `[fn]`). Gate on a known INVOKING HOF by
|
|
1307
1318
|
// callee name; a custom non-local HOF that invokes its arg is an honest under-report (sound),
|
|
1308
1319
|
// never a fabrication. (A LOCAL callee keeps its precise callback-flow below.)
|
|
@@ -1617,7 +1628,7 @@ function visitCalls(node) {
|
|
|
1617
1628
|
// "Console" effect in §1, so it must be PURE. Suppress the fabricated effect for these receivers
|
|
1618
1629
|
// (a real `net.Socket` you constructed and `.write()` to still classifies Net — only the three
|
|
1619
1630
|
// std streams are freed). Real-world sweep: nanoid/commander(×43)/bunyan/pino fabricated Net
|
|
1620
|
-
// purely from a `process.stdout.write` — the
|
|
1631
|
+
// purely from a `process.stdout.write` — the precision failure.
|
|
1621
1632
|
if (eff && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
|
|
1622
1633
|
&& rootsAtStdStream(node.expression.expression))
|
|
1623
1634
|
eff = null;
|
|
@@ -2168,12 +2179,74 @@ if (unlistedSeen.size > 0) {
|
|
|
2168
2179
|
+ `effects through ${top.length === 1 ? "it are" : "them are"} INVISIBLE (not Unknown): ${shown}${more}`);
|
|
2169
2180
|
}
|
|
2170
2181
|
|
|
2182
|
+
// ---- the gate surfaces: the AS-EFF-005 baseline guard + the standing §6.2 policy gate --------------
|
|
2183
|
+
// When stdout carries a JSON document — the §2 envelope (--json) OR the streamed gate verdict
|
|
2184
|
+
// (--gate-json -) — it must stay pure JSON: route the gate's [AS-EFF-…] violation lines to stderr so
|
|
2185
|
+
// a `… | jq` / `… | candor-sarif` pipe never breaks.
|
|
2186
|
+
const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
|
2187
|
+
let gateViolations = [];
|
|
2188
|
+
|
|
2189
|
+
// ---- the AS-EFF-005 baseline guard (CANDOR_BASELINE / config `baseline`; SPEC §7 item 5) -----------
|
|
2190
|
+
// Semantics mirror the reference engine (candor-java Policy.checkBaseline) exactly:
|
|
2191
|
+
// · ABSENT file → one stderr note, guard inactive (ratchet not adopted; exit unchanged).
|
|
2192
|
+
// · PRESENT but unparseable (corrupt/truncated/not-a-report) → exit 2 WITHOUT evaluating — the guard
|
|
2193
|
+
// must never silently pass on unreadable gate input (the unreadable-policy class, §6.2).
|
|
2194
|
+
// · A missing provenance header (legacy bare array) OR a producing `candor.version` ≠ this build →
|
|
2195
|
+
// exit 2 WITHOUT evaluating (§2.1: a baseline is comparable only to its OWN producing version —
|
|
2196
|
+
// evaluating a stale one yields a bogus AS-EFF-005 wave; skipping is an unbounded fail-open window).
|
|
2197
|
+
// The read-only `diff`/`gains` QUERIES disclose a mismatch instead of failing — a comparison the
|
|
2198
|
+
// user explicitly asked for should inform; this scan-time guard is the gate and fails closed.
|
|
2199
|
+
// · Valid + same build → per-fn compare: an EXISTING fn gaining an effect is an [AS-EFF-005]
|
|
2200
|
+
// violation (exit 1, joins --gate-json); a fn absent from the baseline is NEW code, reviewed as
|
|
2201
|
+
// such, not a regression. Baselines omit pure fns (spec §2), so absent-prior means no prior claim.
|
|
2202
|
+
if (baselinePath !== null) {
|
|
2203
|
+
const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
|
|
2204
|
+
if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
|
|
2205
|
+
console.error(`candor-ts: CANDOR_BASELINE ${baselinePath} does not exist — the regression guard is `
|
|
2206
|
+
+ `not active (record one: candor-ts <target> --out <prefix>, then point at the report .json).`);
|
|
2207
|
+
} else {
|
|
2208
|
+
let root = null;
|
|
2209
|
+
try { root = JSON.parse(fs.readFileSync(baselinePath, "utf8")); } catch { /* root stays null → exit 2 */ }
|
|
2210
|
+
const arr = Array.isArray(root) ? root : (root && typeof root === "object" ? root.functions : null);
|
|
2211
|
+
if (!Array.isArray(arr)) {
|
|
2212
|
+
console.error(`candor-ts: baseline ${shownB} exists but could not be parsed (corrupt/truncated?) — `
|
|
2213
|
+
+ `failing (exit 2); the guard must not silently pass on an unreadable baseline. Regenerate it with this build.`);
|
|
2214
|
+
process.exit(2);
|
|
2215
|
+
}
|
|
2216
|
+
const baseVersion = !Array.isArray(root) && root.candor && typeof root.candor === "object"
|
|
2217
|
+
&& typeof root.candor.version === "string" ? root.candor.version : null;
|
|
2218
|
+
if (baseVersion === null) {
|
|
2219
|
+
console.error(`candor-ts: the baseline ${shownB} has no provenance header (a legacy/bare-array report) — `
|
|
2220
|
+
+ `a baseline is comparable only to its producing build (§2.1). Failing (exit 2); regenerate it with this build.`);
|
|
2221
|
+
process.exit(2);
|
|
2222
|
+
}
|
|
2223
|
+
if (baseVersion !== ENGINE_VERSION) {
|
|
2224
|
+
console.error(`candor-ts: the baseline ${shownB} was produced by engine build ${baseVersion} but this is `
|
|
2225
|
+
+ `build ${ENGINE_VERSION} — an engine swap is baseline-invalidating and the gate cannot evaluate `
|
|
2226
|
+
+ `(exit 2; never a silent skip, never a bogus AS-EFF-005 wave). Regenerate deliberately with this build.`);
|
|
2227
|
+
process.exit(2);
|
|
2228
|
+
}
|
|
2229
|
+
const base = new Map();
|
|
2230
|
+
for (const e of arr) {
|
|
2231
|
+
if (e && typeof e.fn === "string" && e.fn) base.set(e.fn, new Set(Array.isArray(e.inferred) ? e.inferred : []));
|
|
2232
|
+
}
|
|
2233
|
+
for (const name of [...inferred.keys()].sort()) {
|
|
2234
|
+
const prior = base.get(name);
|
|
2235
|
+
if (prior === undefined) continue; // new function — not a regression
|
|
2236
|
+
const gained = [...inferred.get(name)].filter((x) => !prior.has(x)).sort();
|
|
2237
|
+
if (gained.length) {
|
|
2238
|
+
gateViolations.push({ rule: "AS-EFF-005", fn: name, effects: gained,
|
|
2239
|
+
detail: `\`${name}\` gained effect { ${gained.join(", ")} } not present in the baseline` });
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2171
2245
|
// ---- the standing §6.2 gate (--policy / CANDOR_POLICY) --------------------------------------------
|
|
2172
2246
|
// `!== null`, not truthiness: a CONFIGURED-but-EMPTY policy (a bare `policy` config line, a set-but-
|
|
2173
2247
|
// empty CANDOR_POLICY) is "" — falsy, so a truthy check silently skipped the gate, the exact quiet
|
|
2174
2248
|
// drop the config comment above promises fails loud. "" now reaches the read, which fails → exit 2
|
|
2175
2249
|
// (the Rust engine's behavior on the same input).
|
|
2176
|
-
let gateViolations = [];
|
|
2177
2250
|
if (policyPath !== null) {
|
|
2178
2251
|
let text;
|
|
2179
2252
|
try {
|
|
@@ -2187,13 +2260,9 @@ if (policyPath !== null) {
|
|
|
2187
2260
|
// java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
|
|
2188
2261
|
const incompleteMap = new Map();
|
|
2189
2262
|
for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
|
|
2190
|
-
gateViolations = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
|
|
2191
|
-
// When stdout carries a JSON document — the §2 envelope (--json) OR the streamed gate verdict
|
|
2192
|
-
// (--gate-json -) — it must stay pure JSON: route the gate's [AS-EFF-…] violation lines to stderr so
|
|
2193
|
-
// a `… | jq` / `… | candor-sarif` pipe never breaks.
|
|
2194
|
-
const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
|
2195
|
-
for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
|
|
2263
|
+
gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap));
|
|
2196
2264
|
}
|
|
2265
|
+
for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
|
|
2197
2266
|
// --gate-json ⟨0.8⟩: the structured gate verdict { spec, ok, violations:[{rule,fn,effects,detail}] }, from
|
|
2198
2267
|
// the SAME gateViolations that set the exit code (so it can't disagree). Written whenever the flag is set —
|
|
2199
2268
|
// ok:true,[] when no gate is configured. Must precede the exit(1) below.
|
|
@@ -2207,8 +2276,10 @@ if (gateJsonPath) {
|
|
|
2207
2276
|
catch (e) { console.error(`candor-ts: could not write --gate-json ${gateJsonPath}: ${e.message}`); }
|
|
2208
2277
|
}
|
|
2209
2278
|
}
|
|
2210
|
-
|
|
2279
|
+
// gateViolations is non-empty only when a gate surface (policy / baseline) was active and fired.
|
|
2280
|
+
if (gateViolations.length) {
|
|
2211
2281
|
console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
|
|
2212
2282
|
process.exit(1);
|
|
2213
2283
|
}
|
|
2214
2284
|
if (policyPath !== null) console.error("candor-ts: policy ✓");
|
|
2285
|
+
if (baselinePath !== null && fs.existsSync(baselinePath)) console.error("candor-ts: baseline ✓"); // absent = inactive (noted above)
|