candor-ts 0.18.0 → 0.19.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/policy.mjs +135 -7
- package/query.mjs +6 -3
- package/scan.mjs +87 -8
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.19)."*
|
|
16
16
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
17
17
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
18
18
|
>
|
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
184
184
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
185
185
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
186
186
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
187
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
187
|
+
| `{ candor: { version, toolchain, spec: "0.19" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
188
188
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
189
189
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
190
190
|
|
|
@@ -202,7 +202,7 @@ read the Rust source".
|
|
|
202
202
|
|
|
203
203
|
## Status
|
|
204
204
|
|
|
205
|
-
0.18.x, speaking candor-spec 0.
|
|
205
|
+
0.18.x, speaking candor-spec 0.19: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
206
206
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
207
207
|
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
208
208
|
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.19)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/policy.mjs
CHANGED
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Llm"];
|
|
8
|
+
|
|
9
|
+
// Reason-scoped Unknown (REASON-SCOPED-UNKNOWN-DESIGN.md): the CLOSED, cross-engine reason-class set a
|
|
10
|
+
// `deny E Unknown[class…]` rule quantifies over. Must be IDENTICAL to candor-java's ReasonClass and
|
|
11
|
+
// candor-rust's — the mapping below mirrors java's prefix-based ReasonClass.classify(String).
|
|
12
|
+
export const REASON_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"];
|
|
13
|
+
// `dynamic` = every GENUINE blind-spot class (excludes `setup`), incl. `unresolved` so it never under-gates.
|
|
14
|
+
const DYNAMIC_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved"];
|
|
15
|
+
/** Map a raw `unknownWhy` token (e.g. `reflect:eval`, `callback:fetch`) to its normative reason class. */
|
|
16
|
+
export function reasonClass(why) {
|
|
17
|
+
const w = String(why).trim().toLowerCase();
|
|
18
|
+
if (w.startsWith("reflect") || w === "dynamicmemberlookup") return "reflect";
|
|
19
|
+
if (w.startsWith("native")) return "native";
|
|
20
|
+
if (w.startsWith("callback") || w.startsWith("closure") || w.startsWith("task-handoff")) return "indirect";
|
|
21
|
+
if (w.startsWith("dispatch") || w.startsWith("indy") || w.startsWith("ambiguous")) return "dispatch";
|
|
22
|
+
if (w.startsWith("missing-config") || w.startsWith("no-tsconfig") || w.startsWith("no-node_modules")) return "setup";
|
|
23
|
+
return "unresolved"; // conservative catch-all
|
|
24
|
+
}
|
|
8
25
|
// The literal surfaces `allow` can restrict. `Llm` ⟨0.13⟩ rides Net's host literal (SPEC §1) —
|
|
9
26
|
// `allow Llm <host…>` restricts which MODEL hosts a scope may reach, matched by hostname like Net.
|
|
10
27
|
const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db", "Llm"]);
|
|
@@ -14,7 +31,10 @@ const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db", "Llm"]);
|
|
|
14
31
|
// (adversarial DSL review). A non-ASCII space stays part of its token → the rule is malformed, dropped.
|
|
15
32
|
const ASCII_WS = /[ \t\n\v\f\r]+/;
|
|
16
33
|
const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
|
|
17
|
-
|
|
34
|
+
// ⟨0.19⟩ `aliases` (a Map name→class-token[], from `.candor/config` `unknown-alias`) lets an `Unknown[<name>]`
|
|
35
|
+
// filter resolve a user-defined name (SPEC §6.2). A config alias never changes what bare `deny E Unknown`
|
|
36
|
+
// means (always `Unknown[*]`), so a rule's denied set stays legible from the policy alone.
|
|
37
|
+
export function parsePolicy(text, aliases = null) {
|
|
18
38
|
const deny = [], allow = [], forbid = [];
|
|
19
39
|
// Split LINES on \n / \r\n / bare \r — the three forms Java's Files.readAllLines (the reference parser)
|
|
20
40
|
// breaks on. Splitting on \n ONLY let a classic-Mac (bare-\r) file collapse to one line: \r is also an
|
|
@@ -28,14 +48,40 @@ export function parsePolicy(text) {
|
|
|
28
48
|
if (t[0] === "deny") {
|
|
29
49
|
const effects = [];
|
|
30
50
|
let scope = "";
|
|
51
|
+
// Reason-class filter on an `Unknown` membership: empty ⇒ `Unknown[*]` (any reason — the bare
|
|
52
|
+
// form); non-empty ⇒ only those classes. `*` = all; `dynamic` = every genuine class.
|
|
53
|
+
const unknownClasses = new Set();
|
|
54
|
+
let unknownStar = false;
|
|
31
55
|
for (const tok of t.slice(1)) {
|
|
32
|
-
|
|
33
|
-
|
|
56
|
+
const m = /^Unknown\[(.*)\]$/.exec(tok);
|
|
57
|
+
if (m) {
|
|
58
|
+
effects.push("Unknown");
|
|
59
|
+
for (let cn of m[1].split(",")) {
|
|
60
|
+
cn = cn.trim();
|
|
61
|
+
if (!cn) continue;
|
|
62
|
+
if (cn === "*") unknownStar = true;
|
|
63
|
+
else if (cn === "dynamic") DYNAMIC_CLASSES.forEach((c) => unknownClasses.add(c));
|
|
64
|
+
else if (REASON_CLASSES.includes(cn)) unknownClasses.add(cn);
|
|
65
|
+
else if (aliases && aliases.has(cn)) aliases.get(cn).forEach((c) => unknownClasses.add(c)); // ⟨0.19⟩ config unknown-alias
|
|
66
|
+
else warn(`unknown reason-class/alias \`${cn}\` (known: ${REASON_CLASSES.join(",")}; aliases: dynamic,*, or a config \`unknown-alias\`)`);
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (EFFECTS.includes(tok) || tok === "Unknown") {
|
|
71
|
+
effects.push(tok);
|
|
72
|
+
if (tok === "Unknown") unknownStar = true; // bare Unknown ⇒ all classes
|
|
73
|
+
} else { scope = tok; break; }
|
|
34
74
|
}
|
|
35
75
|
if (effects.length === 0) { warn("deny names no known effect"); continue; }
|
|
36
|
-
|
|
76
|
+
// `*` (or bare Unknown) means all classes ⇒ empty filter (matches any Unknown).
|
|
77
|
+
let uc = unknownStar ? [] : [...unknownClasses].sort();
|
|
78
|
+
// A2 under-gating lint: a narrowed scope omitting `unresolved` (the catch-all for holes the engine
|
|
79
|
+
// couldn't classify) may silently tolerate exactly those — flag it (advisory, non-fatal).
|
|
80
|
+
if (uc.length && !uc.includes("unresolved"))
|
|
81
|
+
console.error(`candor: policy rule narrows \`Unknown[…]\` but omits \`unresolved\` — may UNDER-gate on holes the engine couldn't classify; add \`unresolved\` (or use \`dynamic\`): ${line}`);
|
|
82
|
+
deny.push({ effects: [...new Set(effects)].sort(), scope, unknownClasses: uc, raw: line }); // dedup: a set, like rust/java
|
|
37
83
|
} else if (t[0] === "pure") {
|
|
38
|
-
deny.push({ effects: [], scope: t[1] ?? "", raw: line });
|
|
84
|
+
deny.push({ effects: [], scope: t[1] ?? "", unknownClasses: [], raw: line });
|
|
39
85
|
} else if (t[0] === "allow") {
|
|
40
86
|
if (t.length < 3) { warn("allow names no values"); continue; }
|
|
41
87
|
if (!ALLOW_EFFECTS.has(t[1])) { warn("allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables"); continue; }
|
|
@@ -121,7 +167,30 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
121
167
|
const out = [];
|
|
122
168
|
// `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
|
|
123
169
|
const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
|
|
124
|
-
|
|
170
|
+
// §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation; omitted otherwise.
|
|
171
|
+
const push = (rule, fn, effects, detail, reasonClass) =>
|
|
172
|
+
out.push(reasonClass && reasonClass.length ? { rule, fn, effects, detail, reasonClass } : { rule, fn, effects, detail });
|
|
173
|
+
// Reason-scoped Unknown: the Unknown reason CLASS must travel the call graph the same way the Unknown
|
|
174
|
+
// EFFECT does (unknownWhy in the report is direct-only). Classify each fn's DIRECT reasons to class
|
|
175
|
+
// tokens, then propagate transitively over `callgraph` to a fixpoint — so `deny E Unknown[reflect]` at a
|
|
176
|
+
// caller inheriting Unknown from a reflect-caused callee still fires (matches java/rust reasonClassAcc).
|
|
177
|
+
const reasonAcc = new Map();
|
|
178
|
+
for (const f of functions) {
|
|
179
|
+
const cs = new Set((f.unknownWhy ?? []).map(reasonClass));
|
|
180
|
+
if (cs.size) reasonAcc.set(f.fn, cs);
|
|
181
|
+
}
|
|
182
|
+
for (let changed = true; changed; ) {
|
|
183
|
+
changed = false;
|
|
184
|
+
for (const [caller, callees] of Object.entries(callgraph)) {
|
|
185
|
+
for (const callee of callees) {
|
|
186
|
+
const cc = reasonAcc.get(callee);
|
|
187
|
+
if (!cc) continue;
|
|
188
|
+
let set = reasonAcc.get(caller);
|
|
189
|
+
if (!set) { set = new Set(); reasonAcc.set(caller, set); }
|
|
190
|
+
for (const c of cc) if (!set.has(c)) { set.add(c); changed = true; }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
125
194
|
for (const f of functions) {
|
|
126
195
|
for (const r of pol.deny) {
|
|
127
196
|
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
@@ -132,7 +201,19 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
132
201
|
const hits = r.effects.length === 0
|
|
133
202
|
? f.inferred.filter((e) => e !== "Unknown")
|
|
134
203
|
: f.inferred.filter((e) => r.effects.includes(e));
|
|
135
|
-
|
|
204
|
+
// Reason-scoped Unknown: a `deny E Unknown[classes]` keeps its Unknown hit only for a fn whose
|
|
205
|
+
// TRANSITIVE reason classes include one of those; an Unknown with no recorded reason ⇒ `unresolved`.
|
|
206
|
+
let kept = hits;
|
|
207
|
+
if (hits.includes("Unknown") && (r.unknownClasses?.length)) {
|
|
208
|
+
const cs = reasonAcc.get(f.fn);
|
|
209
|
+
const fnClasses = cs && cs.size ? [...cs] : ["unresolved"];
|
|
210
|
+
if (!fnClasses.some((c) => r.unknownClasses.includes(c))) kept = hits.filter((e) => e !== "Unknown");
|
|
211
|
+
}
|
|
212
|
+
if (kept.length) {
|
|
213
|
+
// When Unknown is denied, report ALL reason classes on the fn (transitive) — every reason the gate bit.
|
|
214
|
+
const rc = kept.includes("Unknown") ? [...(reasonAcc.get(f.fn) ?? [])].sort() : undefined;
|
|
215
|
+
push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc);
|
|
216
|
+
}
|
|
136
217
|
}
|
|
137
218
|
for (const r of pol.allow) {
|
|
138
219
|
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
@@ -199,3 +280,50 @@ export function discoverConfigPolicy(fromDir) {
|
|
|
199
280
|
dir = parent;
|
|
200
281
|
}
|
|
201
282
|
}
|
|
283
|
+
|
|
284
|
+
// ⟨0.19⟩ Discover `.candor/config` TEXT anchored at `fromDir`: $CANDOR_CONFIG if set + readable, else the
|
|
285
|
+
// nearest `.candor/config` walking UP, else null. Read-only + lenient (the caller decides fail-closed).
|
|
286
|
+
export function discoverConfigText(fromDir) {
|
|
287
|
+
const env = process.env.CANDOR_CONFIG;
|
|
288
|
+
if (env) { try { return fs.readFileSync(env, "utf8"); } catch { return null; } }
|
|
289
|
+
let dir = nodePath.resolve(fromDir);
|
|
290
|
+
for (;;) {
|
|
291
|
+
const cand = nodePath.join(dir, ".candor", "config");
|
|
292
|
+
if (fs.existsSync(cand)) { try { return fs.readFileSync(cand, "utf8"); } catch { return null; } }
|
|
293
|
+
const parent = nodePath.dirname(dir);
|
|
294
|
+
if (parent === dir) return null;
|
|
295
|
+
dir = parent;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ⟨0.19⟩ Parse `unknown-alias <name> = <class,…>` lines (SPEC §6.2) into a Map name→class-token[]. A name
|
|
300
|
+
// that shadows a built-in (`*`/`dynamic`/a class token) is warned-and-skipped, as is a no-valid-class def.
|
|
301
|
+
// Byte-shape with the java `Config.addAlias` / rust `parse_unknown_aliases`.
|
|
302
|
+
export function parseUnknownAliases(configText) {
|
|
303
|
+
const out = new Map();
|
|
304
|
+
if (!configText) return out;
|
|
305
|
+
for (const raw of configText.split(/\r?\n/)) {
|
|
306
|
+
const line = raw.split("#", 1)[0].trim();
|
|
307
|
+
if (!line) continue;
|
|
308
|
+
const m = line.match(/^(\S+)\s+(.*)$/);
|
|
309
|
+
if (!m || m[1].toLowerCase() !== "unknown-alias") continue;
|
|
310
|
+
const eq = m[2].indexOf("=");
|
|
311
|
+
if (eq < 0) { console.error(`candor: ignoring \`unknown-alias\` (want \`unknown-alias <name> = <class,…>\`): ${m[2]}`); continue; }
|
|
312
|
+
const name = m[2].slice(0, eq).trim();
|
|
313
|
+
if (!name || name === "*" || name === "dynamic" || REASON_CLASSES.includes(name)) {
|
|
314
|
+
console.error(`candor: ignoring \`unknown-alias\` with reserved/empty name \`${name}\` (may not shadow \`*\`/\`dynamic\`/a class token)`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const classes = new Set();
|
|
318
|
+
for (let cn of m[2].slice(eq + 1).split(",")) {
|
|
319
|
+
cn = cn.trim();
|
|
320
|
+
if (!cn) continue;
|
|
321
|
+
if (cn === "dynamic") DYNAMIC_CLASSES.forEach((c) => classes.add(c));
|
|
322
|
+
else if (REASON_CLASSES.includes(cn)) classes.add(cn);
|
|
323
|
+
else console.error(`candor: \`unknown-alias ${name}\` names unknown reason-class \`${cn}\` — skipped`);
|
|
324
|
+
}
|
|
325
|
+
if (classes.size === 0) console.error(`candor: ignoring \`unknown-alias ${name}\` — no valid reason-class`);
|
|
326
|
+
else out.set(name, [...classes]);
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
package/query.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import fs from "node:fs";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
|
|
26
|
-
import { parsePolicy, scopeMatches, discoverConfigPolicy } from "./policy.mjs";
|
|
26
|
+
import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, discoverConfigText } from "./policy.mjs";
|
|
27
27
|
import { hasReport } from "./query-core.mjs";
|
|
28
28
|
import { printAgents } from "./contract.mjs";
|
|
29
29
|
import { bestFinds } from "./surface.mjs";
|
|
@@ -196,7 +196,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
196
196
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
197
197
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
198
198
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
199
|
-
const SPEC_VERSION = "0.
|
|
199
|
+
const SPEC_VERSION = "0.19";
|
|
200
200
|
|
|
201
201
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
202
202
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -501,7 +501,10 @@ switch (cmd) {
|
|
|
501
501
|
console.error(`candor: policy ${args[0] ?? "(no file given)"} could not be read`);
|
|
502
502
|
process.exit(2);
|
|
503
503
|
}
|
|
504
|
-
|
|
504
|
+
// ⟨0.19⟩ config-aware: resolve `Unknown[<alias>]` via a checked-in `unknown-alias`, anchored to the
|
|
505
|
+
// policy file (or CANDOR_CONFIG) — the dump reflects real gate resolution + pins the four-way expansion.
|
|
506
|
+
const aliases = parseUnknownAliases(discoverConfigText(path.dirname(path.resolve(args[0]))));
|
|
507
|
+
emit(parsePolicy(text, aliases));
|
|
505
508
|
break;
|
|
506
509
|
}
|
|
507
510
|
case "show": {
|
package/scan.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import fs from "node:fs";
|
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { createRequire } from "node:module";
|
|
29
|
-
import { parsePolicy, evaluatePolicy, scopeMatches } from "./policy.mjs";
|
|
29
|
+
import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, discoverConfigText, reasonClass } from "./policy.mjs";
|
|
30
30
|
import { unverifiedHoleRule, ruleUpgrade } from "./query-core.mjs";
|
|
31
31
|
import { printAgents } from "./contract.mjs";
|
|
32
32
|
import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql,
|
|
@@ -41,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
41
41
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
42
42
|
// Reused, never re-littered.
|
|
43
43
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
44
|
-
const SPEC_VERSION = "0.
|
|
44
|
+
const SPEC_VERSION = "0.19";
|
|
45
45
|
|
|
46
46
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
47
47
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -294,6 +294,13 @@ if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive:
|
|
|
294
294
|
// A target with declared dependencies but no node_modules resolves almost nothing — the scan
|
|
295
295
|
// would "succeed" with a near-total-Unknown report a fresh user could ship (CTA-dogfood finding).
|
|
296
296
|
// Warn LOUDLY; the report is still written (it is sound), but the cause must be visible.
|
|
297
|
+
// ⟨0.19⟩ Also compute `declaredButUninstalled` (SPEC §6.2 §3, the setup/genuine split): a declared dep
|
|
298
|
+
// whose `node_modules/<dep>` is absent. An `Unknown` caused by a call into one of these is a SETUP hole
|
|
299
|
+
// (`no-node_modules:<pkg>` → reason class `setup`), NOT a genuine dynamic blind spot — the fix is
|
|
300
|
+
// `npm install`, not a policy decision. Tagging them separates the fatigue-vector (the referee's
|
|
301
|
+
// week-two-uninstall) from real dynamism, so a team can `Unknown[dynamic]` a strict gate AND be told
|
|
302
|
+
// exactly what to configure to shrink the rest.
|
|
303
|
+
const declaredButUninstalled = new Set();
|
|
297
304
|
{
|
|
298
305
|
// Find the nearest package.json AT OR ABOVE the scan root: scanning a `src/` subdirectory must still see
|
|
299
306
|
// the project manifest one level up, else the warning stays silent and a deps-less scan reads as a
|
|
@@ -303,14 +310,25 @@ if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive:
|
|
|
303
310
|
if (fs.existsSync(path.join(d, "package.json"))) { projDir = d; break; }
|
|
304
311
|
if (path.dirname(d) === d) break; // filesystem root
|
|
305
312
|
}
|
|
306
|
-
if (projDir
|
|
313
|
+
if (projDir) {
|
|
307
314
|
try {
|
|
308
315
|
// BOTH dependency kinds: `npm install` installs devDependencies too, and a project can import a
|
|
309
316
|
// dev/vendored package in its source (zx imports `chalk` as a devDependency) — a `dependencies`-only
|
|
310
317
|
// check left exactly that case unwarned.
|
|
311
318
|
const pj = JSON.parse(fs.readFileSync(path.join(projDir, "package.json"), "utf8"));
|
|
312
319
|
const deps = { ...(pj.dependencies ?? {}), ...(pj.devDependencies ?? {}) };
|
|
313
|
-
|
|
320
|
+
// "Installed?" follows node resolution: node_modules is searched at projDir AND every ANCESTOR, so a
|
|
321
|
+
// dep HOISTED to a monorepo/workspace root counts as installed. Checking only projDir wrongly named a
|
|
322
|
+
// hoisted-but-resolvable package in the SETUP diagnostic (review-found; cosmetic — the gate was already
|
|
323
|
+
// safe via the resolve-first ordering, but the message must not cry wolf).
|
|
324
|
+
const installed = (dep) => {
|
|
325
|
+
for (let d = projDir; ; d = path.dirname(d)) {
|
|
326
|
+
if (fs.existsSync(path.join(d, "node_modules", dep))) return true;
|
|
327
|
+
if (path.dirname(d) === d) return false;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
for (const dep of Object.keys(deps)) if (!installed(dep)) declaredButUninstalled.add(dep);
|
|
331
|
+
if (Object.keys(deps).length > 0 && !fs.existsSync(path.join(projDir, "node_modules")))
|
|
314
332
|
console.error("candor-ts: WARNING — the project declares dependencies but has no node_modules; " +
|
|
315
333
|
"imports won't resolve, so calls into those packages can't be analyzed (they read " +
|
|
316
334
|
"`Unknown`, and their types don't resolve). Run `npm install` in the project first.");
|
|
@@ -407,6 +425,33 @@ function declModule(decl) {
|
|
|
407
425
|
return f;
|
|
408
426
|
}
|
|
409
427
|
|
|
428
|
+
// ⟨0.19⟩ The bare-package ROOT of an import specifier: `@scope/pkg/sub` → `@scope/pkg`, `pkg/sub` → `pkg`,
|
|
429
|
+
// a relative/absolute path → null (not a package). Used to match an import against `declaredButUninstalled`.
|
|
430
|
+
function pkgRoot(spec) {
|
|
431
|
+
if (!spec || spec.startsWith(".") || spec.startsWith("/")) return null;
|
|
432
|
+
const seg = spec.split("/");
|
|
433
|
+
return spec.startsWith("@") ? seg.slice(0, 2).join("/") : seg[0];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ⟨0.19⟩ The import module a call's HEAD identifier binds to (`winston.info()` → head `winston`; `chalk()` →
|
|
437
|
+
// `chalk`) via its import declaration — resolvable even when the package ISN'T installed, because the import
|
|
438
|
+
// statement is syntactically present in the local file. Mirrors the specifier extraction at the κ seam.
|
|
439
|
+
// Returns the bare-package root, or null when the head isn't an imported binding.
|
|
440
|
+
function importPkgOfHead(expr) {
|
|
441
|
+
let head = expr;
|
|
442
|
+
while (head && ts.isPropertyAccessExpression(head)) head = head.expression;
|
|
443
|
+
if (!head || !ts.isIdentifier(head)) return null;
|
|
444
|
+
const sym = checker.getSymbolAtLocation(head);
|
|
445
|
+
for (const d of sym?.declarations ?? []) {
|
|
446
|
+
let spec = null;
|
|
447
|
+
if (ts.isNamespaceImport(d)) spec = d.parent?.parent?.moduleSpecifier;
|
|
448
|
+
else if (ts.isImportClause(d)) spec = d.parent?.moduleSpecifier; // default import
|
|
449
|
+
else if (ts.isImportSpecifier(d)) spec = d.parent?.parent?.parent?.moduleSpecifier; // named import
|
|
450
|
+
if (spec && ts.isStringLiteralLike(spec)) return pkgRoot(spec.text);
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
410
455
|
// SPEC §5.1 — the effect manifest. An uncurated package MAY declare its effect surface in its
|
|
411
456
|
// package.json (`"candorEffects": ["Net"]`), read as the declared-not-verified tier: it kills the
|
|
412
457
|
// silent pure/blind-spot the package would otherwise carry, exactly like a cap type (and unlike
|
|
@@ -1662,8 +1707,16 @@ function visitCalls(node) {
|
|
|
1662
1707
|
// happens in the global/builtin arm below, which fires for the same node.
|
|
1663
1708
|
} else {
|
|
1664
1709
|
rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
|
|
1665
|
-
|
|
1666
|
-
|
|
1710
|
+
// ⟨0.19⟩ SETUP split (SPEC §6.2 §3): if the callee binds to a DECLARED-but-UNINSTALLED package,
|
|
1711
|
+
// this Unknown is a mis-configuration (the pkg isn't `npm install`ed), not a genuine dynamic hole
|
|
1712
|
+
// — tag `no-node_modules:<pkg>` (reason class `setup`) so it's SEPARABLE + `npm install`-fixable.
|
|
1713
|
+
const setupPkg = declaredButUninstalled.size ? importPkgOfHead(node.expression) : null;
|
|
1714
|
+
if (setupPkg && declaredButUninstalled.has(setupPkg)) {
|
|
1715
|
+
rec.why.add(`no-node_modules:${setupPkg}`);
|
|
1716
|
+
} else {
|
|
1717
|
+
const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
|
|
1718
|
+
rec.why.add(`callback:${callee}`); // an `any`-typed/indeterminate callee (a function VALUE) — canonical `callback:`
|
|
1719
|
+
}
|
|
1667
1720
|
}
|
|
1668
1721
|
}
|
|
1669
1722
|
} else {
|
|
@@ -2620,6 +2673,30 @@ if (!wantJson) {
|
|
|
2620
2673
|
console.error(` ${breakdown}${unknown ? `${breakdown ? " · " : ""}Unknown ${unknown} (disclosed)` : ""}`);
|
|
2621
2674
|
}
|
|
2622
2675
|
}
|
|
2676
|
+
{
|
|
2677
|
+
// ⟨0.19⟩ SETUP diagnostic (SPEC §6.2 §3, the setup/genuine split): functions that read Unknown ONLY
|
|
2678
|
+
// because the scan isn't configured (a declared dep not installed → `no-node_modules:<pkg>`, reason class
|
|
2679
|
+
// `setup`) are a FIXABLE mis-configuration, not a genuine dynamic blind spot. Surface them LOUDLY with the
|
|
2680
|
+
// fix and separate from real dynamism — so a team runs `npm install` instead of disabling a strict gate on
|
|
2681
|
+
// unconfigured analysis (the referee's week-two-uninstall). `Unknown[dynamic]` EXCLUDES `setup`, so a
|
|
2682
|
+
// strict gate can bite genuine dynamism while tolerating these until the config is fixed.
|
|
2683
|
+
const setupPkgs = new Set();
|
|
2684
|
+
let setupFns = 0;
|
|
2685
|
+
for (const e of functions) {
|
|
2686
|
+
const why = e.unknownWhy ?? [];
|
|
2687
|
+
if (!why.some((w) => reasonClass(w) === "setup")) continue;
|
|
2688
|
+
setupFns++;
|
|
2689
|
+
for (const w of why) { const m = /^no-node_modules:(.+)$/.exec(w); if (m) setupPkgs.add(m[1]); }
|
|
2690
|
+
}
|
|
2691
|
+
if (setupFns > 0) {
|
|
2692
|
+
const pkgs = [...setupPkgs].sort();
|
|
2693
|
+
const shown = pkgs.slice(0, 6).join(", ") + (pkgs.length > 6 ? `, +${pkgs.length - 6} more` : "");
|
|
2694
|
+
console.error(`candor-ts: SETUP — ${setupFns} function(s) read Unknown ONLY because ${pkgs.length} declared `
|
|
2695
|
+
+ `package(s) aren't installed (${shown}); run \`npm install\`, then re-scan. These are unconfigured `
|
|
2696
|
+
+ `analysis, NOT real blind spots — a strict gate can still bite genuine dynamism with `
|
|
2697
|
+
+ `\`deny E Unknown[dynamic]\` (which tolerates \`setup\`), then shrink to zero once installed.`);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2623
2700
|
if (unlistedSeen.size > 0) {
|
|
2624
2701
|
const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
|
|
2625
2702
|
const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
|
|
@@ -2796,11 +2873,13 @@ if (policyPath !== null) {
|
|
|
2796
2873
|
// java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
|
|
2797
2874
|
const incompleteMap = new Map();
|
|
2798
2875
|
for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
|
|
2799
|
-
|
|
2876
|
+
// ⟨0.19⟩ reason-class aliases (SPEC §6.2) from `.candor/config`, so `Unknown[<alias>]` resolves at the gate.
|
|
2877
|
+
const unknownAliases = parseUnknownAliases(discoverConfigText(target));
|
|
2878
|
+
gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text, unknownAliases), functions, cg, incompleteMap));
|
|
2800
2879
|
// Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
|
|
2801
2880
|
// in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
|
|
2802
2881
|
// fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
|
|
2803
|
-
const disclosePolicy = parsePolicy(text);
|
|
2882
|
+
const disclosePolicy = parsePolicy(text, unknownAliases);
|
|
2804
2883
|
const purityHoles = [];
|
|
2805
2884
|
for (const f of functions) {
|
|
2806
2885
|
// Same predicate + upgrade as `candor-ts-query unverified` (query-core.mjs) — one source of truth.
|