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 +2 -1
- package/README.md +2 -2
- package/mcp.mjs +20 -0
- package/package.json +2 -2
- package/query-core.mjs +45 -0
- package/query.mjs +17 -2
- package/scan-core.mjs +12 -0
- package/scan.mjs +35 -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.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.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
281
|
-
"imports won't resolve, so calls into those packages
|
|
282
|
-
"and
|
|
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
|