candor-ts 0.28.0 → 0.28.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/README.md CHANGED
@@ -210,7 +210,7 @@ read the Rust source".
210
210
 
211
211
  ## Status
212
212
 
213
- 0.19.x, speaking candor-spec 0.28: the analysis core, the gate (`--policy` / `--gate-json` /
213
+ 0.28.0, speaking candor-spec 0.28: the analysis core, the gate (`--policy` / `--gate-json` /
214
214
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
215
215
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
216
216
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/contract.mjs CHANGED
@@ -21,7 +21,34 @@ export function printAgents() {
21
21
  // and drains on the way out, scan.mjs exits. THE SIBLING ROUTE AGAIN — sharing the PRINTER does not
22
22
  // share the EXIT, and the divergence lived in the caller the shared function was meant to protect.
23
23
  // Fixing it here rather than in scan.mjs is deliberate: the next caller inherits the fix.
24
+ //
25
+ // Two failure modes the first version of this did not handle, both specific to a PIPE, which is the
26
+ // case it exists for:
27
+ // EPIPE — the reader stopped early (`| head -n5`, `| grep -m1`, a consumer that closed). The old
28
+ // console.log path exited 0 silently; a bare writeSync raises, and a print-and-exit mode
29
+ // answering a contract request with a Node stack trace and exit 1 is a worse regression
30
+ // than the truncation this replaced. Swallowed — the reader left, that is not our error.
31
+ // EAGAIN — once stdout has been initialised, libuv puts the pipe in non-blocking mode and writeSync
32
+ // THROWS rather than short-writing as soon as the payload exceeds the 64 KiB pipe buffer.
33
+ // The contract is 24 KiB today, so this is latent, not live — and it would come back as
34
+ // exactly the truncation-plus-noise this function was written to remove. Retry with a
35
+ // small backoff (Atomics.wait is the only synchronous sleep available here).
24
36
  let off = 0;
25
37
  const buf = Buffer.from(out, "utf8");
26
- while (off < buf.length) off += fs.writeSync(1, buf, off, buf.length - off); // a short write is legal
38
+ const idle = new Int32Array(new SharedArrayBuffer(4));
39
+ try {
40
+ while (off < buf.length) {
41
+ try { off += fs.writeSync(1, buf, off, buf.length - off); } // a short write is legal
42
+ catch (e) { if (e.code !== "EAGAIN") throw e; Atomics.wait(idle, 0, 0, 1); }
43
+ }
44
+ } catch (e) {
45
+ if (e.code !== "EPIPE") throw e;
46
+ // SWALLOWED, BUT NOT SILENT. Exiting non-zero would make `--agents | head` a failure, which it is
47
+ // not. But a truncated contract with exit 0 and an empty stderr is verbatim the defect this function
48
+ // was rewritten to remove ("an agent piping candor-ts --agents into its context silently read a
49
+ // third of its own instructions") — so the reader-left case has to be STATED. stderr may be closed
50
+ // too; that write is best-effort by construction.
51
+ try { fs.writeSync(2, `candor-ts: --agents output was cut short at ${off} of ${buf.length} bytes `
52
+ + `— the reader closed the pipe. This contract is INCOMPLETE.\n`); } catch { /* nothing left to tell */ }
53
+ }
27
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.28.0",
3
+ "version": "0.28.2",
4
4
  "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.28)",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "scripts": {
22
22
  "lint": "eslint *.mjs",
23
- "test": "npm run lint && node --test test-unit.mjs && node test.mjs && node test-mcp.mjs && node test-lsp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
23
+ "test": "npm run lint && node --test test-unit.mjs && node test.mjs --parallel && node test-mcp.mjs && node test-lsp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
24
24
  "test:unit": "node --test test-unit.mjs",
25
25
  "test:probe": "node fabrication_probe.mjs",
26
26
  "test:fuzz": "node fuzz.mjs",
package/scan.mjs CHANGED
@@ -2829,11 +2829,13 @@ for (const sf of sources) {
2829
2829
  endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
2830
2830
  nodeName.set(node, qual);
2831
2831
  // Record a unit minted for a declaration with NO BODY, for the §3 disclosure pass below (see it
2832
- // for the argument). MIRROR `fns.set`'s last-write-wins EXACTLY — `.delete` on the bodied write is
2833
- // load-bearing, not tidiness: an overload set is N body-less signatures followed by the
2834
- // implementation under the SAME qual, so a plain `.add` would mark every real overloaded function
2835
- // in the project unanalysable and charge its callers Unknown. That is the over-charge half of the
2836
- // fix, and it is pinned by a fixture (`over`/`callsOver`, still `Fs` after this pass).
2832
+ // for the argument). The `.delete` mirrors `fns.set`'s last-write-wins for the MODULE-LEVEL
2833
+ // overload set (N body-less signatures then the implementation, all under one qual) — but it is
2834
+ // NOT sufficient on its own, and assuming it was is how this fix shipped an over-charge: a
2835
+ // FUNCTION-SCOPED unit gets `#line:col` appended to its qual, so each signature holds a distinct
2836
+ // key, the implementation's delete never reaches them, and every signature was charged
2837
+ // `Unknown[native:…]` over code that is fully visible. The disclosure pass below therefore asks
2838
+ // the SYMBOL — which every overload in a set shares, at any scope — and that is the real guard.
2837
2839
  {
2838
2840
  const hasBodySlot = ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)
2839
2841
  || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node)
@@ -5440,10 +5442,57 @@ function resolveDepEntryKey(pkg, subpath) {
5440
5442
  // zod's `ZodType._parse` one — excluding them takes zod's delta to +0 and hono's from +18 to +9, and
5441
5443
  // every one of the nine that remain is a true positive (`Deno.mkdir`/`writeFile` are Fs, `Deno.
5442
5444
  // upgradeWebSocket` and `FetcherLike.fetch` are Net, all declared in local `.d.ts` shims with no body).
5445
+ // Does a LOCAL body answer this declaration? Climbs `classOverrides` TRANSITIVELY: that index records
5446
+ // DIRECT-subclass overrides only, so `Base (abstract) → Mid (abstract) → Impl (bodied)` resolved to
5447
+ // `Mid.hook` — no body — and charged `Base.hook` Unknown even though `Impl.hook` is the sole concrete
5448
+ // implementation and fully analyzed. The two-level fixture could not see it, which is also why the
5449
+ // measured corpus deltas characterise one-level hierarchies only.
5450
+ // DIRECT overrides only — the transitive climb was REVERTED, and the reason is worth keeping. Climbing
5451
+ // removed the base member's Unknown for `Base → Mid (re-declares abstract) → Impl`, but the dispatch-site
5452
+ // fan-out (see `classOverrides` at the call site) is still ONE level, so nothing then carried Impl's
5453
+ // effect caller-ward: the caller vanished from the report entirely, which under SPEC §2 rule 3 is a
5454
+ // positive purity claim. `deny Unknown` AND `deny Fs` both passed over a real Fs. The Unknown was
5455
+ // COMPENSATING for the non-transitive fan-out, and the climb deleted the compensation without supplying
5456
+ // the thing it compensated for. Over-charging that shape is the survivable direction; under-reporting it
5457
+ // is not. The real fix is to build `classOverrides` transitively once, as `classDescendants` already is —
5458
+ // filed, not attempted here under a shipped-regression clock.
5443
5459
  const answeredByLocalBody = (node) => (classOverrides.get(node) ?? []).some((om) => !!om.body);
5460
+ // The IMPLEMENTATION of the overload set this body-less signature belongs to, if we analyzed it.
5461
+ // Keyed on the SYMBOL, not the unit name: every overload shares one symbol at any scope, where the
5462
+ // qual does not (a function-scoped unit carries a `#line:col` suffix to keep sibling scopes apart).
5463
+ // `!!d.body` ALONE IS NOT "is an implementation" — a ts.ModuleDeclaration has a `.body` too (its
5464
+ // ModuleBlock), so `declare function f(); declare namespace f {}` — the UMD/ambient shape of jQuery,
5465
+ // lodash, moment, chalk — matched the namespace as f's implementation, dropped the Unknown, and
5466
+ // reported the caller PURE. That is the axios cardinal sin reopened BY the pass that closed it, and it
5467
+ // shipped in 0.28.1. The kind guard is the fix: only a declaration form that can carry a FUNCTION body
5468
+ // can be an overload implementation.
5469
+ const canCarryABody = (d) => ts.isFunctionDeclaration(d) || ts.isMethodDeclaration(d)
5470
+ || ts.isConstructorDeclaration(d) || ts.isGetAccessorDeclaration(d) || ts.isSetAccessorDeclaration(d);
5471
+ const overloadImplOf = (node) => {
5472
+ const sym = node.name ? checker.getSymbolAtLocation(node.name) : undefined;
5473
+ return (sym?.declarations ?? []).find((d) => d !== node && canCarryABody(d) && !!d.body);
5474
+ };
5444
5475
  for (const [qual, meta] of bodylessDecls) {
5445
5476
  const rec = fns.get(qual);
5446
5477
  if (!rec || answeredByLocalBody(meta.node)) continue;
5478
+ // An overload SIGNATURE with a local implementation is not unanswerable — but neither is it empty,
5479
+ // and skipping it silently was this fix's first mistake in the other direction. At MODULE level every
5480
+ // overload shares one qual, so a call resolving to the signature lands on the implementation's unit
5481
+ // by construction. FUNCTION-SCOPED, the quals differ, the checker resolves the call to the SIGNATURE,
5482
+ // and the caller reached an empty unit and read PURE — a silent under-report that predates this pass
5483
+ // (`outer` calling a nested overloaded `inner` that writes a file was pure before any of this). Edge
5484
+ // the signature to the implementation and the existing fixpoint carries the real effect through.
5485
+ // The `continue` must depend on an EDGE BEING FORMED, not merely on finding a declaration. `nodeName`
5486
+ // is minted only for units in `projectFiles`, while the symbol's declarations span the whole program —
5487
+ // a `/// <reference>` pull-in, a file outside the tsconfig `include`, a `declare module` augmentation.
5488
+ // Skipping on a target we could not resolve drops the charge AND forms no edge: the disclosure pass
5489
+ // failing OPEN. Fall through to the Unknown instead, which is what "we could not follow this" means.
5490
+ const implDecl = overloadImplOf(meta.node);
5491
+ const target = implDecl ? nodeName.get(implDecl) : undefined;
5492
+ if (target) {
5493
+ if (target !== qual) rec.edges.add(target);
5494
+ continue;
5495
+ }
5447
5496
  rec.direct.add("Unknown");
5448
5497
  // §4's dividing line, same as everywhere else in this file. An `abstract` member IS an unresolved
5449
5498
  // DISPATCH — owner type and member are both nameable, which is what `dispatch:` reserves itself for,