candor-ts 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cases.ts ADDED
@@ -0,0 +1,77 @@
1
+ // The candor-spec conformance cases in TypeScript — paired by bare function name with the Rust and
2
+ // Java fixtures; the expected effect sets are conformance/expected.json (the SAME oracle).
3
+ import * as fsm from "node:fs";
4
+ import * as netm from "node:net";
5
+ import * as cp from "node:child_process";
6
+ import * as cryptom from "node:crypto";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import * as winstonm from "winston";
9
+
10
+ // --- one function per std-only effect ---
11
+ export function fs_read(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
12
+ export function net_connect(): void { try { netm.connect(1, "h"); } catch {} }
13
+ export function exec_spawn(): void { try { cp.spawn("x"); } catch {} }
14
+ // Exec-cliff refinement (spec §4 ⟨0.5⟩): a known literal head adds its effect; all engines must agree.
15
+ export function exec_curl(): void { try { cp.spawn("curl"); } catch {} }
16
+ // Exec-refinement reads the HEAD (argv[0]) only: a dynamic program with a literal ARGUMENT keeps the
17
+ // bare cliff — "curl" in the args array must NOT fabricate Net (spec §4 ⟨0.5⟩: the head is argv[0]).
18
+ export function exec_dyn_head(tool: string): void { try { cp.spawn(tool, ["curl"]); } catch {} }
19
+ export function env_read(): void { void process.env.X; }
20
+ export function clock_now(): void { void Date.now(); }
21
+ // Rand: node:crypto's CSPRNG. candor-ts is syntactic (AST), so the builtin need not resolve at scan time.
22
+ export function rand_gen(): void { void cryptom.randomBytes(16); }
23
+ // Db: node:sqlite's DatabaseSync.exec is the store round-trip (named import — candor-ts tracks the symbol).
24
+ export function db_query(): void { void new DatabaseSync(":memory:").exec("SELECT 1"); }
25
+ export function log_msg(): void { winstonm.info("m"); }
26
+
27
+ // --- purity (negative) ---
28
+ export function pure_fn(): number { return 1 + 2; }
29
+
30
+ // --- the Unknown trust contract: a function-valued field the engine cannot see through ---
31
+ class Holder { cb: () => void = () => {}; }
32
+ const h = new Holder();
33
+ export function unknown_dyn(): void { const cb = h.cb; cb(); }
34
+
35
+ // --- multi-effect union in one body ---
36
+ export function combined(): void { try { fsm.readFileSync("/tmp/x"); netm.connect(1, "h"); } catch {} }
37
+
38
+ // --- transitive propagation across a call ---
39
+ export function transitive_leaf(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
40
+ export function transitive_caller(): void { transitive_leaf(); }
41
+
42
+ // --- an effect inside a closure attributes to the enclosing function (SEMANTICS §2) ---
43
+ export function closure_effect(): void { const f = () => { try { fsm.readFileSync("/tmp/x"); } catch {} }; f(); }
44
+
45
+ // --- Unknown propagates like an effect ---
46
+ export function unknown_propagates(): void { unknown_dyn(); }
47
+
48
+ // --- mixed: a concrete effect AND an Unknown in one transitive set ---
49
+ export function mixed_unknown(): void { try { fsm.readFileSync("/tmp/x"); } catch {} unknown_dyn(); }
50
+
51
+ // --- a 3-hop chain a -> b -> c(Net) ---
52
+ export function hop_c(): void { try { netm.connect(1, "h"); } catch {} }
53
+ export function hop_b(): void { hop_c(); }
54
+ export function hop_a(): void { hop_b(); }
55
+
56
+ // --- a caller unions the effects of two distinct callees ---
57
+ export function union_b(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
58
+ export function union_c(): void { try { netm.connect(1, "h"); } catch {} }
59
+ export function union_a(): void { union_b(); union_c(); }
60
+
61
+ // --- recursion: the fixpoint must terminate AND keep the effect ---
62
+ export function recurse(n: number): void { if (n > 0) { void process.env.X; recurse(n - 1); } }
63
+
64
+ // --- an effect in one branch only is still inferred (over-approximation) ---
65
+ export function conditional(b: boolean): void { if (b) { try { cp.spawn("x"); } catch {} } }
66
+
67
+ // --- transitive purity: a -> b -> c, all pure, stays pure (negative) ---
68
+ export function pure_c(): number { return 3; }
69
+ export function pure_b(): number { return pure_c(); }
70
+ export function pure_a(): number { return pure_b(); }
71
+
72
+ // --- a method call on a concrete LOCAL-type receiver propagates the method's effect ---
73
+ export class Svc { act(): void { try { fsm.readFileSync("/tmp/x"); } catch {} } }
74
+ export function method_call(s: Svc): void { s.act(); }
75
+
76
+ // --- scheduler attribution: an effect inside a scheduled task attributes to the SCHEDULER ---
77
+ export function sched(): void { setTimeout(() => { try { fsm.readFileSync("/tmp/x"); } catch {} }, 0); }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.6",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
3
+ "version": "0.8.7",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
@@ -40,6 +40,7 @@
40
40
  "node": ">=20"
41
41
  },
42
42
  "files": [
43
+ "Cases.ts",
43
44
  "scan.mjs",
44
45
  "query.mjs",
45
46
  "policy.mjs",
package/policy.mjs CHANGED
@@ -120,7 +120,13 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
120
120
  for (const f of functions) {
121
121
  for (const r of pol.deny) {
122
122
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
123
- const hits = r.effects.length === 0 ? f.inferred : f.inferred.filter((e) => r.effects.includes(e));
123
+ // `pure` (empty forbidden set) forbids every EFFECT — not `Unknown`, which is the §4 trust
124
+ // marker, not an effect (AS-EFF-003's concern; `deny Unknown <scope>` is the explicit knob).
125
+ // The reference engine (candor-java) and the rust deep engine exclude it identically; candor-ts
126
+ // wrongly counted an Unknown-only fn as a `pure` violation until 2026-07-09.
127
+ const hits = r.effects.length === 0
128
+ ? f.inferred.filter((e) => e !== "Unknown")
129
+ : f.inferred.filter((e) => r.effects.includes(e));
124
130
  if (hits.length) push("AS-EFF-006", f.fn, hits, `\`${f.fn}\` performs { ${hits.join(", ")} }, forbidden by policy: \`${r.raw}\``);
125
131
  }
126
132
  for (const r of pol.allow) {
package/scan.mjs CHANGED
@@ -498,6 +498,27 @@ function moduleOf(sf) {
498
498
  const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
499
499
  return rel.split(path.sep).join(".");
500
500
  }
501
+ // Enclosing `namespace`/`module` blocks are NAME SEGMENTS (the family ruling: §6.2 scope segments
502
+ // split on the same boundaries as the §3.1 query name ladder, and a namespace is a segment — rust
503
+ // modules and swift enum-namespaces already qualify this way). A unit declared in
504
+ // `export namespace app { … }` is `mod.app.fn`, so a layer policy authored against namespace layers
505
+ // (`forbid app -> repo`, `deny Db app`) bites in TS instead of being silently inert. Returns the
506
+ // dotted prefix ("app." / "a.b.") or "". Dotted (`namespace a.b`) and nested forms both contribute
507
+ // each identifier segment; ambient string-named modules (`declare module "x"`) and `declare global`
508
+ // augmentations contribute nothing (not lexical layers of THIS module).
509
+ function namespacePrefixOf(node) {
510
+ const segs = [];
511
+ for (let p = node.parent; p && !ts.isSourceFile(p); p = p.parent) {
512
+ if (!ts.isModuleBlock(p)) continue;
513
+ // `namespace a.b { … }` nests ModuleDeclarations (a -> b -> block); walk the chain so every
514
+ // dotted segment lands, innermost-first up.
515
+ for (let d = p.parent; d && ts.isModuleDeclaration(d); d = ts.isModuleDeclaration(d.parent) ? d.parent : null) {
516
+ if (d.name && ts.isIdentifier(d.name) && !(d.flags & ts.NodeFlags.GlobalAugmentation))
517
+ segs.unshift(d.name.text);
518
+ }
519
+ }
520
+ return segs.length ? `${segs.join(".")}.` : "";
521
+ }
501
522
  // Is `node` (a function-expression / method-declaration / arrow) the `get` or `set` member of an
502
523
  // accessor DESCRIPTOR object passed to `Object.defineProperty(target, key, desc)` /
503
524
  // `Object.defineProperties(target, { key: desc, … })` / `Object.create(proto, { key: desc, … })`?
@@ -698,7 +719,7 @@ for (const sf of sources) {
698
719
  }
699
720
  }
700
721
  }
701
- const ctorQual = `${mod}.${node.name.text}.constructor`;
722
+ const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
702
723
  if (!fns.has(ctorQual)) {
703
724
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
704
725
  fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
@@ -719,7 +740,11 @@ for (const sf of sources) {
719
740
  // shared entry — FABRICATING them onto a pure caller. `nodeName` is keyed by NODE identity, so a
720
741
  // per-node-unique key keeps resolution exact; only TOP-LEVEL units need the stable bare name a
721
742
  // consumer's hash-join targets (a function-scoped local is never an export, so nothing joins to it).
722
- const qual = isFunctionScoped(node) ? `${mod}.${n}#${line + 1}:${character + 1}` : `${mod}.${n}`;
743
+ // Namespace segments go in the QUAL only; `local` (and so the §2 hash `pkg#local`) stays the
744
+ // bare name — a consumer's cross-package join resolves the callee's own name, never the
745
+ // producer's namespace nesting, so widening the hash would break report chaining.
746
+ const nsp = namespacePrefixOf(node);
747
+ const qual = isFunctionScoped(node) ? `${mod}.${nsp}${n}#${line + 1}:${character + 1}` : `${mod}.${nsp}${n}`;
723
748
  fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
724
749
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
725
750
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
@@ -1486,7 +1511,7 @@ function visitCalls(node) {
1486
1511
  // dispatch-frontier (callers --include-unknown) can resolve overrides against the
1487
1512
  // hierarchy sidecar. Bare `decl.parent.name` would not match a reacher's declaringType.
1488
1513
  const tn = decl.parent?.name
1489
- ? `${moduleOf(decl.parent.getSourceFile())}.${decl.parent.name.getText()}`
1514
+ ? `${moduleOf(decl.parent.getSourceFile())}.${namespacePrefixOf(decl.parent)}${decl.parent.name.getText()}`
1490
1515
  : "type";
1491
1516
  const mn = decl.name?.getText?.() ?? "member";
1492
1517
  rec.why.add(`dispatch:${tn}.${mn}`); // resolution landed on a type, not a body — canonical `dispatch:OWNER.member` (frontier-relevant)
@@ -2112,10 +2137,10 @@ for (const sf of sources) {
2112
2137
  let sym = checker.getSymbolAtLocation(t.expression);
2113
2138
  if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
2114
2139
  const d = (sym?.declarations ?? []).find((x) => ts.isClassDeclaration(x) || ts.isInterfaceDeclaration(x));
2115
- supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${d.name.getText()}` : t.expression.getText());
2140
+ supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${namespacePrefixOf(d)}${d.name.getText()}` : t.expression.getText());
2116
2141
  }
2117
2142
  }
2118
- if (supers.length) hierarchy[`${mod}.${node.name.getText()}`] = supers;
2143
+ if (supers.length) hierarchy[`${mod}.${namespacePrefixOf(node)}${node.name.getText()}`] = supers;
2119
2144
  }
2120
2145
  ts.forEachChild(node, walk);
2121
2146
  })(sf);
package/watch.mjs CHANGED
@@ -121,6 +121,14 @@ async function main() {
121
121
  // NO .unref() — the interval is the ONLY thing keeping the process alive; unref'ing it made Node exit
122
122
  // ~0.6s after the startup scan, so the watcher did ONE scan and died while printing "Watching…" (the
123
123
  // whole feature was silently broken, and test-watch.mjs only tests the helpers, never the live loop).
124
+
125
+ // GRACEFUL stop: the documented quit is Ctrl-C, but the default SIGINT/SIGTERM handler TERMINATES —
126
+ // exit hooks never run, the stop reads as a signal death (no exit code) to a supervisor, and a child
127
+ // instrumented with NODE_V8_COVERAGE discards its coverage (the TESTING.md §6 flush rule — this made
128
+ // the live loop measure 0% while actually exercised). Handle both: announce, exit 0.
129
+ for (const sig of ["SIGINT", "SIGTERM"]) {
130
+ process.on(sig, () => { console.error(`candor-ts-watch: ${sig} — stopping`); process.exit(0); });
131
+ }
124
132
  }
125
133
 
126
134
  if (path.resolve(process.argv[1] || "") === path.resolve(fileURLToPath(import.meta.url))) main();