candor-ts 0.8.15 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,7 +12,7 @@ chains by hand.
12
12
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
13
13
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
14
14
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
15
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.8)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.9)."*
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
  >
@@ -98,6 +98,7 @@ Q containment $P [baseline-prefix] # §6.1 boundary-effect dispersion; with a b
98
98
  Q blindspots $P # the Unknown SOURCES (fns with unknownWhy), ranked by Unknown blast radius
99
99
  Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it would violate)
100
100
  Q fix $P <fn> <Effect> <policy> # the boundary FIX: where the effect belongs + the hoist refactor
101
+ Q unverified $P <policy> [--strict] # pure/deny layers that PASS but are Unknown (not PROVABLY clean)
101
102
  Q fix-gate $P <policy> # a fix for EVERY crossing — the loop's block-message remedy
102
103
  Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
103
104
  Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
package/README.md CHANGED
@@ -183,7 +183,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
183
183
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
184
184
  | Unmatched external calls contribute nothing (curated-κ caveat) | SEMANTICS §8 C1 |
185
185
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
186
- | `{ candor: { version, toolchain, spec: "0.8" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
186
+ | `{ candor: { version, toolchain, spec: "0.9" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
187
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
188
188
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
189
189
 
@@ -201,7 +201,7 @@ read the Rust source".
201
201
 
202
202
  ## Status
203
203
 
204
- 0.8.x, speaking candor-spec 0.8: the analysis core, the gate (`--policy` / `--gate-json` /
204
+ 0.9.x, speaking candor-spec 0.9: the analysis core, the gate (`--policy` / `--gate-json` /
205
205
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
206
206
  `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
207
207
  real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
package/mcp.mjs CHANGED
@@ -215,6 +215,26 @@ const TOOLS = {
215
215
  return { ok: v.length === 0, violations: v };
216
216
  },
217
217
  },
218
+ candor_unverified: {
219
+ description: "PROVABLE-PURITY check (INSTANT): a `pure`/`deny <E>` policy layer PASSES a function that has "
220
+ + "no such effect — but if that function is Unknown (candor couldn't resolve one of its calls), "
221
+ + "the pass is UNVERIFIED: the Unknown could hide the very effect the rule forbids. The classic "
222
+ + "case is a fn/closure-injected 'port' — the domain reads as Unknown, so `deny Net domain`/`pure "
223
+ + "domain` clear it though it may reach Net at runtime. Returns each such function + the `deny <E> "
224
+ + "Unknown <scope>` upgrade that makes the layer PROVABLY clean. Uses `policy` if given, else the "
225
+ + "repo's checked-in .candor/config policy.",
226
+ schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
227
+ run: (a, p) => {
228
+ let text;
229
+ if (a.policy) text = confinedPolicyRead(a.policy, p);
230
+ else {
231
+ const cfg = configPolicy(p);
232
+ if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
233
+ text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
234
+ }
235
+ return Q.unverified(Q.loadReport(p), parsePolicy(text), scopeMatches);
236
+ },
237
+ },
218
238
  candor_containment: {
219
239
  description: "Per boundary effect (Db/Net/Exec/Fs/Ipc/Clipboard): how contained it is in one architectural layer — the dispersion diagnostic (spec §6.1). Not a score; per-effect facts.",
220
240
  schema: { type: "object", properties: { ...reportArg } },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.15",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
3
+ "version": "0.9.2",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.9)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/query-core.mjs CHANGED
@@ -620,3 +620,48 @@ export function fixGate(cg, fns, policyParsed, scopeMatches) {
620
620
  const remedies = [...plans.keys()].sort().map((k) => plans.get(k));
621
621
  return { ok: remedies.length === 0, remedies };
622
622
  }
623
+
624
+ // unverified: the PROVABLE-PURITY disclosure (eval/fixloop/DISPATCH-NOTE.md, mirrors candor-query). A
625
+ // `pure`/`deny E` layer PASSES a function that carries none of its forbidden effects — but if that function is
626
+ // `Unknown` (an unresolvable call), the pass is UNVERIFIED: the Unknown could hide the very effect the rule
627
+ // forbids (the fn/closure-port hole). Returns each such function + the `deny E Unknown <scope>` upgrade.
628
+ /** Reconstruct a rule's source form and its `Unknown`-forbidding upgrade: `[source, upgrade]`. `pure
629
+ * <scope>` → ["pure <scope>", "deny Unknown <scope>"]; `deny <E…> <scope>` → ["deny <E…> <scope>",
630
+ * "deny <E…> Unknown <scope>"]. Shared so the gate note and `unverified` name the identical upgrade. */
631
+ export function ruleUpgrade(r) {
632
+ const suffix = r.scope ? ` ${r.scope}` : "";
633
+ return r.effects.length === 0
634
+ ? [`pure${suffix}`, `deny Unknown${suffix}`]
635
+ : [`deny ${r.effects.join(" ")}${suffix}`, `deny ${r.effects.join(" ")} Unknown${suffix}`];
636
+ }
637
+
638
+ /** The single predicate for a provable-purity hole (eval/fixloop/DISPATCH-NOTE.md): a function that is
639
+ * Unknown, sits in a pure/deny scope, and PASSES that rule (carries none of its forbidden real effects) —
640
+ * so its compliance is asserted but not verified (the Unknown could hide the very effect the rule forbids;
641
+ * the classic case is a fn/closure-injected port). A *real* violation is the gate's job, not this. Returns
642
+ * the first governing rule under which the function is such a hole, or null. Shared by the gate note
643
+ * (scan.mjs) and `unverified` so "what a hole is" has ONE definition (conformance PART 12d pins agreement). */
644
+ export function unverifiedHoleRule(fn, inferred, policyParsed, scopeMatches) {
645
+ const inf = inferred ?? [];
646
+ if (!inf.includes("Unknown")) return null;
647
+ for (const r of policyParsed.deny) {
648
+ if (r.scope && !scopeMatches(fn, r.scope)) continue;
649
+ const violates = r.effects.length === 0
650
+ ? inf.some((x) => x !== "Unknown") // pure: any real effect is a violation
651
+ : inf.some((x) => r.effects.includes(x)); // deny: a named effect is a violation
652
+ if (!violates) return r; // else it's a real violation the gate already reports
653
+ }
654
+ return null;
655
+ }
656
+
657
+ export function unverified(fns, policyParsed, scopeMatches) {
658
+ const holes = [];
659
+ for (const e of fns) {
660
+ // Same predicate + upgrade as the gate note (scan.mjs) — one source of truth for a hole.
661
+ const r = unverifiedHoleRule(e.fn, e.inferred, policyParsed, scopeMatches);
662
+ if (!r) continue;
663
+ const [rule, upgrade] = ruleUpgrade(r);
664
+ holes.push({ fn: e.fn, rule, unknownWhy: e.unknownWhy ?? [], upgrade });
665
+ }
666
+ return { ok: holes.length === 0, unverified: holes };
667
+ }
package/query.mjs CHANGED
@@ -31,7 +31,7 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
31
31
  callers as coreCallers, callersFrontier, loadHierarchy,
32
32
  containment as coreContainment, diff as coreDiff,
33
33
  where as coreWhere, map as coreMap, whatif as coreWhatif,
34
- fix as coreFix, fixGate as coreFixGate,
34
+ fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
35
35
  loadReport, loadCallgraph, reportVersion } from "./query-core.mjs";
36
36
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
37
37
 
@@ -39,7 +39,7 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
39
39
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
40
40
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
41
41
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
42
- const SPEC_VERSION = "0.8";
42
+ const SPEC_VERSION = "0.9";
43
43
 
44
44
  // The full subcommand catalogue — name + one-line description (derived from the per-subcommand
45
45
  // comments + the module-doc header). The single source for the --help list AND the no-arg/unknown
@@ -60,6 +60,7 @@ const SUBCOMMANDS = [
60
60
  ["whatif", "<prefix> <fn> <Effect> [policy-file] [0|1]", "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
61
61
  ["fix", "<prefix> <fn> <Effect> <policy-file>", "the boundary fix: where the effect belongs + the hoist refactor"],
62
62
  ["fix-gate", "<prefix> <policy-file>", "a fix for EVERY boundary crossing — the loop's block-message remedy"],
63
+ ["unverified", "<prefix> <policy-file> [--strict]", "pure/deny layers that PASS but are Unknown (not PROVABLY clean)"],
63
64
  ["agents", "", "print the agent contract for this build (AGENTS.md)"],
64
65
  ];
65
66
 
@@ -284,6 +285,20 @@ switch (cmd) {
284
285
  emit(coreFixGate(cg, loadReport(prefix), parsePolicy(ptext), scopeMatches));
285
286
  break;
286
287
  }
288
+ case "unverified": {
289
+ // PROVABLE-PURITY disclosure: pure/deny layers that PASS but contain Unknown (not provably clean). A
290
+ // policy is required; `--strict` exits 1 on a hole. Advisory (exit 0) otherwise.
291
+ const strict = args.includes("--strict");
292
+ const [prefix, policyFile] = args.filter((a) => a !== "--strict");
293
+ if (!policyFile) { console.error("candor: unverified requires a policy file"); process.exit(2); }
294
+ let ptext;
295
+ try { ptext = fs.readFileSync(policyFile, "utf8"); }
296
+ catch { console.error(`candor: policy ${policyFile} could not be read`); process.exit(2); }
297
+ const r = coreUnverified(loadReport(prefix), parsePolicy(ptext), scopeMatches);
298
+ emit(r);
299
+ process.exit(strict && !r.ok ? 1 : 0);
300
+ break; // unreachable
301
+ }
287
302
  default:
288
303
  // no command (cmd === undefined) or an unknown one: the FULL usage, not the stale 6-item list.
289
304
  if (cmd !== undefined) console.error(`candor-ts-query: unknown command '${cmd}'`);
package/scan-core.mjs CHANGED
@@ -123,6 +123,18 @@ export const KAPPA_RULES = [
123
123
  [/^open$/, /^(open|openApp)$/, "Exec"],
124
124
  [/^(fs-extra|graceful-fs|rimraf|glob|chokidar)$/, null, "Fs"],
125
125
  [/^dotenv$/, null, "Env"],
126
+ // CLI-tool packages surfaced by the 0.9 dogfood on `zx` (read `invisible` before — a κ-coverage gap, not
127
+ // a cardinal sin; modeled against each package's SOURCE, not its name):
128
+ // - `which`: resolves an executable by stat-ing PATH candidates (via `isexe`) — Fs. Both the async default
129
+ // `which(cmd)` and `which.sync(cmd)` hit the filesystem; no pure member → whole-module Fs.
130
+ [/^which$/, null, "Fs"],
131
+ // - `@webpod/ps`: process listing/kill — kill/lookup/lookupSync/tree/treeSync ALL spawn the OS via
132
+ // `exec({...})` (zurk/spawn) — verified in ps.js. Uniform process surface, no pure member → Exec.
133
+ [/^@webpod\/ps$/, null, "Exec"],
134
+ // - `envapi` (a dotenv variant): MIXED — `parse`/`stringify` are pure string transforms; `load`/`loadSafe`/
135
+ // `config` READ the .env file (`fs.readFileSync`). Member-precise so `parse` never fabricates Fs (the
136
+ // argon2 curated-κ lesson: model the effectful member, never blanket-grant a mixed package).
137
+ [/^envapi$/, /^(load|loadSafe|config)$/, "Fs"],
126
138
  [/^(winston|pino|bunyan|npmlog)$/, null, "Log"],
127
139
  // nest-winston wraps winston; the injected logger's level verbs are the Log boundary (the
128
140
  // WinstonModule.createLogger/forRoot config is inert).
package/scan.mjs CHANGED
@@ -26,7 +26,8 @@ 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 } from "./policy.mjs";
29
+ import { parsePolicy, evaluatePolicy, scopeMatches } from "./policy.mjs";
30
+ import { unverifiedHoleRule, ruleUpgrade } from "./query-core.mjs";
30
31
  import { printAgents } from "./contract.mjs";
31
32
  import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql } from "./scan-core.mjs";
32
33
 
@@ -38,7 +39,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
38
39
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
39
40
  // Reused, never re-littered.
40
41
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
41
- const SPEC_VERSION = "0.8";
42
+ const SPEC_VERSION = "0.9";
42
43
 
43
44
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
44
45
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
@@ -272,14 +273,25 @@ if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive:
272
273
  // would "succeed" with a near-total-Unknown report a fresh user could ship (CTA-dogfood finding).
273
274
  // Warn LOUDLY; the report is still written (it is sound), but the cause must be visible.
274
275
  {
275
- const pkg = path.join(rootDir, "package.json");
276
- if (fs.existsSync(pkg) && !fs.existsSync(path.join(rootDir, "node_modules"))) {
276
+ // Find the nearest package.json AT OR ABOVE the scan root: scanning a `src/` subdirectory must still see
277
+ // the project manifest one level up, else the warning stays silent and a deps-less scan reads as a
278
+ // codebase full of spurious `Unknown`s (the exact trap a 0.9 dogfood fell into — scanning `zx/src`).
279
+ let projDir = null;
280
+ for (let d = rootDir; ; d = path.dirname(d)) {
281
+ if (fs.existsSync(path.join(d, "package.json"))) { projDir = d; break; }
282
+ if (path.dirname(d) === d) break; // filesystem root
283
+ }
284
+ if (projDir && !fs.existsSync(path.join(projDir, "node_modules"))) {
277
285
  try {
278
- const deps = JSON.parse(fs.readFileSync(pkg, "utf8")).dependencies ?? {};
286
+ // BOTH dependency kinds: `npm install` installs devDependencies too, and a project can import a
287
+ // dev/vendored package in its source (zx imports `chalk` as a devDependency) — a `dependencies`-only
288
+ // check left exactly that case unwarned.
289
+ const pj = JSON.parse(fs.readFileSync(path.join(projDir, "package.json"), "utf8"));
290
+ const deps = { ...(pj.dependencies ?? {}), ...(pj.devDependencies ?? {}) };
279
291
  if (Object.keys(deps).length > 0)
280
- console.error("candor-ts: WARNING — the target declares dependencies but has no node_modules; " +
281
- "imports won't resolve, so calls into those packages read pure/invisible (not Unknown) " +
282
- "and effects through them are silently dropped. Run `npm install` in the target first.");
292
+ console.error("candor-ts: WARNING — the project declares dependencies but has no node_modules; " +
293
+ "imports won't resolve, so calls into those packages can't be analyzed (they read " +
294
+ "`Unknown`, and their types don't resolve). Run `npm install` in the project first.");
283
295
  } catch {}
284
296
  }
285
297
  // Prisma's client types are GENERATED — a project with the prisma dependency but no generated
@@ -2261,6 +2273,21 @@ if (policyPath !== null) {
2261
2273
  const incompleteMap = new Map();
2262
2274
  for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
2263
2275
  gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap));
2276
+ // Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
2277
+ // in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
2278
+ // fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
2279
+ const disclosePolicy = parsePolicy(text);
2280
+ const purityHoles = [];
2281
+ for (const f of functions) {
2282
+ // Same predicate + upgrade as `candor-ts-query unverified` (query-core.mjs) — one source of truth.
2283
+ const r = unverifiedHoleRule(f.fn, f.inferred, disclosePolicy, scopeMatches);
2284
+ if (r) purityHoles.push([f.fn, ruleUpgrade(r)[1]]);
2285
+ }
2286
+ if (purityHoles.length) {
2287
+ console.error(`candor-ts: note — ${purityHoles.length} function(s) PASS the policy but are Unknown (purity NOT verified — the Unknown could hide a forbidden effect):`);
2288
+ for (const [fn, up] of purityHoles) console.error(` \`${fn}\` → add \`${up}\``);
2289
+ console.error(" (advisory; add the upgrade(s) to REQUIRE provable purity, or run `candor-ts-query unverified` for detail — the gate verdict is unchanged)");
2290
+ }
2264
2291
  }
2265
2292
  for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
2266
2293
  // --gate-json ⟨0.8⟩: the structured gate verdict { spec, ok, violations:[{rule,fn,effects,detail}] }, from