candor-ts 0.27.0 → 0.28.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 +49 -2
- package/README.md +2 -2
- package/contract.mjs +16 -2
- package/lsp.mjs +51 -3
- package/mcp.mjs +181 -21
- package/package.json +2 -2
- package/policy.mjs +43 -2
- package/query-core.mjs +335 -12
- package/query.mjs +741 -64
- package/scan.mjs +837 -27
- package/surface.mjs +15 -2
package/query-core.mjs
CHANGED
|
@@ -20,8 +20,15 @@ function siblings(prefix, predicate) {
|
|
|
20
20
|
const dir = nodePath.dirname(prefix) || ".";
|
|
21
21
|
const base = nodePath.basename(prefix);
|
|
22
22
|
try {
|
|
23
|
+
// SORTED, because readdir order is filesystem ENUMERATION order — stable on one machine, divergent
|
|
24
|
+
// across filesystems and checkouts, and everything downstream of this list inherits it: the merge
|
|
25
|
+
// order of multi-report functions, the concatenation order of the ⟨0.28⟩ `unanalyzed` disclosure,
|
|
26
|
+
// which duplicate callgraph key wins Object.assign. rust (candor-report lib.rs), java (Query.java)
|
|
27
|
+
// and swift (FixCLI.swift) all sort here; this engine was the outlier, so two engines over an
|
|
28
|
+
// IDENTICAL tree could emit differently-ordered arrays and a byte-diffing consumer read a change
|
|
29
|
+
// where there was none.
|
|
23
30
|
return fs.readdirSync(dir).filter((f) => f.startsWith(base + ".") && f.endsWith(".json") && predicate(f))
|
|
24
|
-
.map((f) => nodePath.join(dir, f));
|
|
31
|
+
.sort().map((f) => nodePath.join(dir, f));
|
|
25
32
|
} catch { return []; }
|
|
26
33
|
}
|
|
27
34
|
// A sibling filename that is a real REPORT (not a callgraph sidecar, an encountered-crate ledger, a
|
|
@@ -50,6 +57,43 @@ export function hasReport(p) {
|
|
|
50
57
|
// narrower one would answer about a report the gate never gated.
|
|
51
58
|
const reportFilesAt = (prefix) => (fs.existsSync(`${prefix}.json`) ? [`${prefix}.json`] : siblings(prefix, isReport));
|
|
52
59
|
|
|
60
|
+
// ⟨0.28⟩ SPEC §3.3.1 (3) — the FILES a gate `--report` locator names, for the verdict-sink guard:
|
|
61
|
+
// `reportFilesAt`'s expansion (the SAME enumeration `loadGateReport` below reads — kept adjacent so the
|
|
62
|
+
// guard and the loader cannot drift), plus each report's §2.2 sidecars. Exists because the guard
|
|
63
|
+
// compared the sink against the raw locator TOKEN while the loader reads the token's EXPANSION.
|
|
64
|
+
// MEASURED on this engine 2026-08-12:
|
|
65
|
+
//
|
|
66
|
+
// gate --report r --policy P --gate-json r.json
|
|
67
|
+
// → armQueryGateJson wrote the refusal placeholder OVER the operator's report, the load then
|
|
68
|
+
// failed on the wreckage ("has no functions array"), and the exit-2 refusal document was
|
|
69
|
+
// written over it AGAIN. The no-`--report` discovery spelling (sink = the discovered
|
|
70
|
+
// `.candor/report.json`) destroyed the discovered report identically.
|
|
71
|
+
//
|
|
72
|
+
// THE SIDECARS RIDE ALONG (same clause: a report locator names the PAIR). The gate opens no sidecar —
|
|
73
|
+
// that MUST NOT is `loadGateReport`'s — but a sink on the pair's other half is WORSE: the report loads
|
|
74
|
+
// fine, the gate runs, and a REAL verdict lands on the callgraph at a success exit; `callers`/`tour`
|
|
75
|
+
// then read a verdict document where the graph belongs. The segment list is the `isReport` denylist's
|
|
76
|
+
// pair-carrying members: `gate` is excluded because `<stem>.gate.json` is the verdict sink's own
|
|
77
|
+
// beside-the-report layout — the exact spelling `--gate-json` exists for, pinned by the control test —
|
|
78
|
+
// and `encountered-*` because it is engine-local scan bookkeeping no query reads. Existing files only:
|
|
79
|
+
// the guard protects data, and a sidecar not on disk has none to lose.
|
|
80
|
+
const PAIRED_SIDECAR_SEGMENTS = ["calibrated", "callgraph", "hierarchy", "layerreach", "locs"];
|
|
81
|
+
export function gateReportInputFiles(prefix) {
|
|
82
|
+
if (!prefix) return [];
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const r of reportFilesAt(prefix)) {
|
|
85
|
+
if (r.endsWith(".json")) {
|
|
86
|
+
const stem = r.slice(0, -".json".length);
|
|
87
|
+
for (const seg of PAIRED_SIDECAR_SEGMENTS) {
|
|
88
|
+
const side = `${stem}.${seg}.json`;
|
|
89
|
+
if (fs.existsSync(side)) out.push(side);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
out.push(r);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
53
97
|
// Defend the queries against a partial/old-engine/hand-edited report: the §2 required fields are
|
|
54
98
|
// defaulted, and a WRONG-TYPE field is coerced — a non-array `inferred` (e.g. the string "Net") must
|
|
55
99
|
// NOT survive, or `new Set("Net")` iterates characters into {N,e,t} (a fabricated effect set). Array
|
|
@@ -244,6 +288,48 @@ export function gainsCoverage(curPrefix, basePrefix) {
|
|
|
244
288
|
return out;
|
|
245
289
|
}
|
|
246
290
|
|
|
291
|
+
/**
|
|
292
|
+
* ⟨0.28⟩ SPEC §2 — AND THE SAME MUST CARRIES THE ⟨0.21⟩ MANIFEST, WHICH IS THE STRONGER CAVEAT.
|
|
293
|
+
*
|
|
294
|
+
* `gainsCoverage` above has carried the CURRENT report's `coverage` ledger into this verb since ⟨0.15⟩,
|
|
295
|
+
* for the reason §2 gives: *a "no gains" over an uncovered dep reads clean with false confidence*.
|
|
296
|
+
* MEASURED: the same verb, on the same report, in the same output, dropped `unanalyzed`.
|
|
297
|
+
* `coverage.uncovered` says *I could not see into this dependency*; `unanalyzed` says *I could not read
|
|
298
|
+
* this file of your own code*, and `analyzed.count: 0` says *I judged nothing at all*. The mechanism was
|
|
299
|
+
* already here and pointed at the weaker field.
|
|
300
|
+
*
|
|
301
|
+
* BOTH SIDES, DISCLOSED SEPARATELY, because a gains answer rests on TWO reports and they fail in
|
|
302
|
+
* different directions:
|
|
303
|
+
* · an incomplete CURRENT means the gained set may be SHORT — effects the reader is not being told
|
|
304
|
+
* about, which is the whole thing this alarm verb exists to name;
|
|
305
|
+
* · an incomplete BASELINE means the comparison FLOOR is soft, so the existing-vs-new `origin` split
|
|
306
|
+
* is unreliable and an effect that was always there can read as newly appeared.
|
|
307
|
+
* One combined flag would say "something here is incomplete" and leave a supply-chain reviewer unable to
|
|
308
|
+
* act on it. This is why gains does NOT use `absorbCompleteness` (which `containment <baseline>` does):
|
|
309
|
+
* that verb's answer is a single leak set the two manifests both qualify, while these two qualify
|
|
310
|
+
* different halves of the answer. The prefixed spelling mirrors the shape already used for coverage
|
|
311
|
+
* (`coverage` for the current, `coverageDelta` for the difference) rather than inventing a new one, and
|
|
312
|
+
* `incomplete`/`unanalyzed`/`baselineIncomplete`/`baselineUnanalyzed` are candor-scan's key names exactly
|
|
313
|
+
* (fe5d831) — this is a cross-engine wire surface.
|
|
314
|
+
*
|
|
315
|
+
* ONE READER (`reportCompleteness`) and ONE KEY SET (`completenessFields`), shared with the six
|
|
316
|
+
* descriptive verbs, so the ⟨0.24⟩ count-0 arm cannot be answered one way here and another there. Both
|
|
317
|
+
* halves are `{}` on a complete report, so an ordinary comparison stays byte-identical to ⟨0.27⟩.
|
|
318
|
+
*
|
|
319
|
+
* Verdict-preserving: no exit code moves. `gains` is advisory by default and `--strict` keys on the
|
|
320
|
+
* GAINED SET, which this does not touch.
|
|
321
|
+
*/
|
|
322
|
+
export const gainsCompletenessFields = (cur, base) => ({
|
|
323
|
+
...completenessFields(cur),
|
|
324
|
+
...Object.fromEntries(Object.entries(completenessFields(base))
|
|
325
|
+
.map(([k, v]) => [`baseline${k[0].toUpperCase()}${k.slice(1)}`, v])),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/** The prefix-level form — the `gainsCoverage` signature, for callers that hold two locators and no
|
|
329
|
+
* completeness objects (the MCP `candor_gains` tool; the parity rule). */
|
|
330
|
+
export const gainsCompleteness = (curPrefix, basePrefix) =>
|
|
331
|
+
gainsCompletenessFields(reportCompleteness(curPrefix), reportCompleteness(basePrefix));
|
|
332
|
+
|
|
247
333
|
// The returned array carries a non-enumerable `hardFail` flag: true iff a report file was FOUND but
|
|
248
334
|
// yielded NO trustworthy functions — a parse failure OR a malformed shape (a `null`/array/wrong-typed
|
|
249
335
|
// doc, a non-array `functions`, all-junk entries). The loud CLI wrapper (loadReportOrDie) needs it to
|
|
@@ -296,14 +382,84 @@ export const claimsToHaveJudgedNothing = (parsed, fns) => {
|
|
|
296
382
|
export function reportJudgedNothing(prefix) {
|
|
297
383
|
const files = reportFilesAt(prefix);
|
|
298
384
|
if (!files.length) return false;
|
|
299
|
-
return files.every(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
385
|
+
return files.every(fileClaimsJudgedNothing);
|
|
386
|
+
}
|
|
387
|
+
// The per-FILE question the two prefix-level readers above and below share — extracted so the boolean
|
|
388
|
+
// (the gate's ANDed "did this locator judge anything at all") and the ⟨0.28⟩ disclosure (WHICH member
|
|
389
|
+
// files judged nothing) cannot drift into two readings of the same integer.
|
|
390
|
+
function fileClaimsJudgedNothing(f) {
|
|
391
|
+
let parsed;
|
|
392
|
+
try { parsed = JSON.parse(fs.readFileSync(f, "utf8")); } catch { return true; } // unreadable → no claim
|
|
393
|
+
// The raw entries, read QUIETLY: this is an advisory, and its caller has already run the loud loader
|
|
394
|
+
// over the same bytes, so re-disclosing a malformed shape here would double every such line.
|
|
395
|
+
const raw = parsed && typeof parsed === "object" && parsed.functions !== undefined ? parsed.functions : parsed;
|
|
396
|
+
return claimsToHaveJudgedNothing(parsed, Array.isArray(raw) ? raw : []);
|
|
397
|
+
}
|
|
398
|
+
/** ⟨0.28⟩ The report FILES under a locator that say they judged nothing — one path per file, the shape
|
|
399
|
+
* the disclosure carries (see `completenessFields`). PER FILE, not the ANDed prefix answer: a locator
|
|
400
|
+
* naming several members must disclose EACH silent one by name (rust `report_completeness`, java
|
|
401
|
+
* `ReportCompleteness` — "one label per report file declaring `analyzed.count: 0`").
|
|
402
|
+
*
|
|
403
|
+
* A file that does not PARSE is excluded here and carried as `unreadable` instead — `judgedNothing` is
|
|
404
|
+
* the report's OWN `analyzed.count: 0` assertion, and a file whose bytes cannot be read asserted
|
|
405
|
+
* nothing. MEASURED (candor-spec conformance/gen_key_shapes.py corpus, 2026-08-12): over an intact
|
|
406
|
+
* report with a corrupt `.dep` sibling, rust and swift answer `incomplete: true` alone while this
|
|
407
|
+
* engine listed the corrupt file under `judgedNothing` — a fabricated claim about content nobody read,
|
|
408
|
+
* and a consumer told "re-scan, that report reached no conclusion" when the repair is "that file is
|
|
409
|
+
* corrupt". `fileClaimsJudgedNothing`'s own unreadable→true stays: `reportJudgedNothing` above is the
|
|
410
|
+
* gate's ANDed fail-closed question, where "no readable claim" must never read as "judged something". */
|
|
411
|
+
export function reportJudgedNothingFiles(prefix) {
|
|
412
|
+
return reportFilesAt(prefix).filter((f) => fileParses(f) && fileClaimsJudgedNothing(f) && !fileHasNoManifest(f));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** ⟨0.28⟩ SPEC §2 — **THE THIRD ROW IS NOT THE FIRST ROW.** §2's three-row table distinguishes
|
|
416
|
+
* `analyzed.count: 0` (row 1 — *nothing was judged*, a claim the report MAKES) from `analyzed` ABSENT
|
|
417
|
+
* (row 3 — a pre-⟨0.21⟩ producer with no manifest at all, which claims nothing). MEASURED here
|
|
418
|
+
* 2026-08-12 over `{"candor":{…},"functions":[]}` with no `analyzed` key: this engine listed the file
|
|
419
|
+
* under `judgedNothing` and its note said the report *"say[s] they JUDGED NOTHING (`analyzed.count:
|
|
420
|
+
* 0`)"*. **The report declares nothing.** The HEDGE is the right direction — row 3's own instruction is
|
|
421
|
+
* *no manifest, no claim* — but the disclosure is FALSE, and this family rates a false disclosure worse
|
|
422
|
+
* than a missing one (§3.4's `net-partner` finding: an engine reported "ignoring unknown config key"
|
|
423
|
+
* while honouring it).
|
|
424
|
+
*
|
|
425
|
+
* It is also a hole in ⟨0.28⟩'s own pin, which defines `judgedNothing` as *reports declaring
|
|
426
|
+
* `analyzed.count: 0`*: putting a row-3 report there makes the key mean two things and loses the
|
|
427
|
+
* distinction the table exists to draw. The REPAIRS differ — row 1 wants a scan that reaches a
|
|
428
|
+
* conclusion, row 3 wants a producer that emits a manifest at all.
|
|
429
|
+
*
|
|
430
|
+
* A legacy BARE ARRAY report has no envelope and therefore no manifest either, so it is row 3 too when
|
|
431
|
+
* it is empty; when it LISTS entries it is not hedging at all (`claimsToHaveJudgedNothing` is false) and
|
|
432
|
+
* never reaches this filter. Asked of the same parsed bytes as its two siblings so the three cannot
|
|
433
|
+
* drift into three readings of one file. */
|
|
434
|
+
const fileHasNoManifest = (f) => {
|
|
435
|
+
let parsed;
|
|
436
|
+
try { parsed = JSON.parse(fs.readFileSync(f, "utf8")); } catch { return false; } // unreadable → `unreadable`
|
|
437
|
+
if (Array.isArray(parsed)) return true; // legacy bare array: no envelope, no manifest
|
|
438
|
+
return !!parsed && typeof parsed === "object" && !("analyzed" in parsed);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/** ⟨0.28⟩ The consulted report FILES carrying no `analyzed` key at all — SPEC §2's row 3, pinned to its
|
|
442
|
+
* own key (`noManifest`) in the rung that introduced it. Only the files that are also HEDGING: a row-3
|
|
443
|
+
* report that LISTS functions demonstrably judged units and said so the only way it could, and keeps the
|
|
444
|
+
* standing it has always had (`claimsToHaveJudgedNothing`'s manifest-absent row). */
|
|
445
|
+
export function reportNoManifestFiles(prefix) {
|
|
446
|
+
return reportFilesAt(prefix).filter((f) => fileParses(f) && fileHasNoManifest(f) && fileClaimsJudgedNothing(f));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** ⟨0.28⟩ Does the file's TEXT parse at all — the line between "this report says X" and "this report
|
|
450
|
+
* says nothing readable". Parse-only on purpose: shape defects inside a parsed document are judged per
|
|
451
|
+
* key role (SPEC §2 ⟨0.24⟩) by the loaders, not here. */
|
|
452
|
+
const fileParses = (f) => {
|
|
453
|
+
try { JSON.parse(fs.readFileSync(f, "utf8")); return true; } catch { return false; }
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** ⟨0.28⟩ The report FILES under a locator that could not be read at all — the THIRD cause, and it is a
|
|
457
|
+
* cause of `incomplete` with NO wire key of its own (rust and swift both answer `incomplete: true` and
|
|
458
|
+
* name the file on the human channel only; matching them is the point — one wire shape per state).
|
|
459
|
+
* Before this arm existed, `reportUnanalyzed` skipped an unparseable member with a bare `catch` and the
|
|
460
|
+
* corrupt file surfaced only through the judged-nothing mislabel above. */
|
|
461
|
+
export function reportUnreadableFiles(prefix) {
|
|
462
|
+
return reportFilesAt(prefix).filter((f) => !fileParses(f));
|
|
307
463
|
}
|
|
308
464
|
|
|
309
465
|
// Load ONE report file → { entries, hardFail }. A read/parse throw, or an empty result over a doc that
|
|
@@ -356,6 +512,123 @@ export function reportUnanalyzed(prefix) {
|
|
|
356
512
|
return out;
|
|
357
513
|
}
|
|
358
514
|
|
|
515
|
+
/**
|
|
516
|
+
* ⟨0.28⟩ SPEC §2 — THE SAME MANIFEST, READ FOR A *DESCRIPTIVE* VERB. `reportUnanalyzed` above was written
|
|
517
|
+
* for the ADVISORY verbs, because the clause it implements was written over the instance it was found in:
|
|
518
|
+
* "a report-consuming verb whose VERDICT could change". ⟨0.28⟩ corrects the clause to the condition that
|
|
519
|
+
* makes it true — the obligation binds **any verb whose output could be read as a negative finding about
|
|
520
|
+
* the code: a verdict, an EMPTY RESULT SET, or a ZERO COUNT**. MEASURED on this engine over the standard
|
|
521
|
+
* post-⟨0.28⟩ artifact (`analyzed.count: 0` + a non-empty `unanalyzed`, what an armed run leaves on disk):
|
|
522
|
+
*
|
|
523
|
+
* where Fs {"effect":"Fs","directly":[],"inherited":[]} exit 0, no hedge
|
|
524
|
+
* map {} exit 0, no hedge
|
|
525
|
+
* blindspots {"sources":[],"totalUnknown":0} exit 0, no hedge
|
|
526
|
+
* reachable {"entryPoints":0,"effects":{}} exit 0, no hedge
|
|
527
|
+
* containment {"contained":[],"ambient":{}} exit 0, no hedge
|
|
528
|
+
* tour {"reaches":[]} exit 0, no hedge
|
|
529
|
+
*
|
|
530
|
+
* "no blind spots" out of a report whose own manifest names a file candor could not read. A consumer
|
|
531
|
+
* cannot distinguish *nobody performs Fs* from *nothing was examined*.
|
|
532
|
+
*
|
|
533
|
+
* ONE READER, TWO CAUSES, and the second cause is why this is a function rather than a call to
|
|
534
|
+
* `reportUnanalyzed` at six new sites:
|
|
535
|
+
*
|
|
536
|
+
* · `unanalyzed` non-empty — candor could not READ a file of the target's own code;
|
|
537
|
+
* · `analyzed.count: 0` — candor read what it read and JUDGED NOTHING. A report in that state carries
|
|
538
|
+
* no `unanalyzed` at all (there is no unread FILE to name), so a manifest-only reader sees a complete
|
|
539
|
+
* report and every verb answers `{}` over it just the same. SPEC §2 names both: *"a report-consuming
|
|
540
|
+
* verb MUST re-disclose a non-empty `unanalyzed`, **and an `analyzed.count` of 0**, on the same
|
|
541
|
+
* terms"*. Decided by `reportJudgedNothing` — the SAME predicate `gate --report`, the MCP gate and the
|
|
542
|
+
* LSP already ask — so a report cannot be judged-nothing on one route and not on another.
|
|
543
|
+
*
|
|
544
|
+
* A report that is BOTH complete and has judged something yields `mustHedge() === false`, and every
|
|
545
|
+
* consumer below is then a no-op: an ordinary run stays byte-identical. That is not a nicety — a hedge on
|
|
546
|
+
* every run trains the reader to ignore it, the same reason ⟨0.15⟩ omits `coverage` when nothing is
|
|
547
|
+
* uncovered.
|
|
548
|
+
*/
|
|
549
|
+
export function reportCompleteness(prefix) {
|
|
550
|
+
// `judgedNothing` is the PER-FILE list, not the ANDed boolean the gate asks: the disclosure names
|
|
551
|
+
// WHICH report judged nothing, so a locator with one silent member among several still hedges — the
|
|
552
|
+
// semantics rust and java pin, and a repair the reader can aim (that file, not "somewhere here").
|
|
553
|
+
// ⟨0.28⟩ `unreadable`: the third cause, split out of the judged-nothing mislabel — see
|
|
554
|
+
// `reportUnreadableFiles`. It hedges the answer and names the file on the human channel only.
|
|
555
|
+
// ⟨0.28⟩ `noManifest`: SPEC §2's row 3, split out of the `judgedNothing` mislabel — see
|
|
556
|
+
// `reportNoManifestFiles`. It hedges like the others and carries its own wire key, because the two
|
|
557
|
+
// want different repairs and because `judgedNothing` is PINNED to "reports declaring `analyzed.count:
|
|
558
|
+
// 0`", which a row-3 report is not.
|
|
559
|
+
return { unanalyzed: reportUnanalyzed(prefix), judgedNothing: reportJudgedNothingFiles(prefix),
|
|
560
|
+
noManifest: reportNoManifestFiles(prefix), unreadable: reportUnreadableFiles(prefix) };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* ⟨0.28⟩ Is there anything to disclose? **The trigger for an ANSWER, where a non-empty `unanalyzed` alone
|
|
565
|
+
* is the trigger for a VERDICT** — and the difference is an exit code, not a mood.
|
|
566
|
+
*
|
|
567
|
+
* `unverified --strict` / `fix-gate --strict` answer 2 ("the gate refuses over these bytes, so do I") off
|
|
568
|
+
* the `unanalyzed` arm — and ⟨0.28⟩ off the `unreadable` arm too, because `gate --report` REFUSES over a
|
|
569
|
+
* corrupt member (measured, exit 2) and §3.2's relation binds the exit as much as the document. ⟨0.24⟩
|
|
570
|
+
* ruled the count-0 arm explicitly the other way for exactly those bytes: it is *a disclosure, not an
|
|
571
|
+
* exit code* — `gate --report` exits 0 over a judged-nothing report, and a verb exiting 2 there would
|
|
572
|
+
* claim it got LESS far than the gate on identical input, the mirror of the over-claim the strict exit
|
|
573
|
+
* exists to prevent. So count-0 reaches both DISCLOSURE channels through this predicate and stops at the
|
|
574
|
+
* exit code; see `advisoryAnswer`, whose exit-bearing callers key `--strict` on manifest + unreadable.
|
|
575
|
+
*/
|
|
576
|
+
export const mustHedge = (c) => !!(c && (c.unanalyzed?.length || c.judgedNothing?.length
|
|
577
|
+
|| c.noManifest?.length || c.unreadable?.length));
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* ⟨0.28⟩ The disclosure KEYS, defined ONCE, for spreading into a verb's answer document — `{}` when there
|
|
581
|
+
* is nothing to disclose, so `{ ...data, ...completenessFields(c) }` is byte-identical to `data` on a
|
|
582
|
+
* complete report. (JS objects preserve insertion order, so the verb's own keys keep their pinned order
|
|
583
|
+
* and the caveat lands after them. candor-rust's first draft of this rung routed two verbs through a
|
|
584
|
+
* BTreeMap-backed serialiser to reach the same place and silently RE-SORTED both documents on ordinary
|
|
585
|
+
* runs — a disclosure rung must not reformat the answers it is disclosing about.)
|
|
586
|
+
*
|
|
587
|
+
* `incomplete: true` is the flag EITHER cause raises, so a consumer that only branches on it is safe under
|
|
588
|
+
* both; `judgedNothing`/`unanalyzed` name WHICH, because the two want different repairs — one wants a scan
|
|
589
|
+
* that can READ a file, the other a scan that reached a conclusion. Each is omitted when it does not
|
|
590
|
+
* apply, so a document raised by `unanalyzed` alone stays byte-identical to the pre-⟨0.28⟩ advisory shape.
|
|
591
|
+
*
|
|
592
|
+
* `judgedNothing` is the ARRAY OF REPORT PATHS, the shape rust, java and swift all emit — this engine
|
|
593
|
+
* first shipped `judgedNothing: true`, reusing the MCP gate tool's spelling, and a consumer doing
|
|
594
|
+
* `doc.judgedNothing.length` got a TypeError here while one doing `=== true` missed the other three.
|
|
595
|
+
* The array also answers a question the boolean cannot: WHICH report judged nothing, over a multi-report
|
|
596
|
+
* locator. The two spellings now deliberately differ because they are different surfaces: THIS key rides
|
|
597
|
+
* answer/advisory documents and is the cross-engine wire shape; the MCP gate tool's `judgedNothing: true`
|
|
598
|
+
* (mcp.mjs) is a flag on ONE gate verdict about ONE locator, where "which file" is carried by the
|
|
599
|
+
* adjacent ⟨0.24⟩ prose and no sibling engine serves that tool — the wire shape governs everywhere the
|
|
600
|
+
* engines can be diffed.
|
|
601
|
+
*/
|
|
602
|
+
export function completenessFields(c) {
|
|
603
|
+
if (!mustHedge(c)) return {};
|
|
604
|
+
// `unreadable` raises the flag and adds NO key of its own — measured, rust and swift answer
|
|
605
|
+
// `incomplete: true` alone over a corrupt sibling, and a third spelling here would be the
|
|
606
|
+
// judgedNothing three-way split again, minted by the engine that was fixing it.
|
|
607
|
+
// ⟨0.28⟩ `noManifest` is the third NAMED cause, `omitted when empty` like the other two, and it RAISES
|
|
608
|
+
// `incomplete` exactly as they do — the flag stays the one key a consumer may branch on alone and be
|
|
609
|
+
// safe under every cause. It is separate from `judgedNothing` because that key is pinned to "reports
|
|
610
|
+
// declaring `analyzed.count: 0`" and a row-3 report declares nothing: merging them would make one key
|
|
611
|
+
// mean two things and lose the distinction §2's three-row table exists to draw.
|
|
612
|
+
return { incomplete: true,
|
|
613
|
+
...(c.unanalyzed?.length ? { unanalyzed: c.unanalyzed } : {}),
|
|
614
|
+
...(c.judgedNothing?.length ? { judgedNothing: c.judgedNothing } : {}),
|
|
615
|
+
...(c.noManifest?.length ? { noManifest: c.noManifest } : {}) };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* ⟨0.28⟩ Union in a SECOND locator's manifest — for `containment <baseline>`, whose answer is a DIFFERENCE
|
|
620
|
+
* and is therefore unsound if EITHER side is partial, in opposite directions: a leak living in an unread
|
|
621
|
+
* file of the CURRENT tree is missed (a false all-clear), while one living in an unread file of the
|
|
622
|
+
* BASELINE reads as newly appeared (a fabricated leak, at exit 1). One merged object rather than two
|
|
623
|
+
* notes, because the keys are fixed and a second write would overwrite the first side's manifest.
|
|
624
|
+
*/
|
|
625
|
+
export const absorbCompleteness = (a, b) => ({
|
|
626
|
+
unanalyzed: [...(a.unanalyzed ?? []), ...(b.unanalyzed ?? [])],
|
|
627
|
+
judgedNothing: [...(a.judgedNothing ?? []), ...(b.judgedNothing ?? [])],
|
|
628
|
+
noManifest: [...(a.noManifest ?? []), ...(b.noManifest ?? [])],
|
|
629
|
+
unreadable: [...(a.unreadable ?? []), ...(b.unreadable ?? [])],
|
|
630
|
+
});
|
|
631
|
+
|
|
359
632
|
/**
|
|
360
633
|
* ⟨0.24⟩ SPEC §3.2 — THE OMIT-`ok` RULE, IN ONE PLACE so `unverified`, `fix-gate`, `whatif` and any later
|
|
361
634
|
* sibling cannot drift apart on it. Over a report declaring `unanalyzed`, an advisory verb emits
|
|
@@ -387,13 +660,38 @@ export function reportUnanalyzed(prefix) {
|
|
|
387
660
|
* the BODY rather than taken as a third parameter, so a verb that computes the disclosure cannot forget to
|
|
388
661
|
* declare it — the ⟨0.24⟩ measurement that the CLI and MCP had drifted on exactly this began with a second
|
|
389
662
|
* channel forgetting to pass an argument.
|
|
663
|
+
*
|
|
664
|
+
* ⟨0.28⟩ THE THIRD TRIGGER: `judgedNothing` — SPEC §2's `analyzed.count: 0` row, which this function did
|
|
665
|
+
* not read. MEASURED: over a report that judged nothing, `unverified` answered `{ok: true, unverified: []}`
|
|
666
|
+
* — the verb whose whole job is *"your green gate is not provably green"* certifying a package it never
|
|
667
|
+
* examined, from a report that names no unread FILE to trip the manifest arm. `ok` goes for the same
|
|
668
|
+
* reason it goes above: neither boolean is honest, and `if (r.ok)` must fail safe.
|
|
669
|
+
*
|
|
670
|
+
* AND THE EXIT CODE DOES NOT MOVE, which is the load-bearing half. Both `--strict` callers compute their
|
|
671
|
+
* exit from the MANIFEST alone, never from this document, because ⟨0.24⟩ ruled count-0 explicitly the other
|
|
672
|
+
* way: *"A DISCLOSURE, NOT AN EXIT CODE"*. `gate --report` exits 0 over these bytes, and an advisory verb
|
|
673
|
+
* exiting 2 there would claim it got LESS far than the gate on identical input — the mirror of the
|
|
674
|
+
* over-claim the strict exit exists to prevent. (`mustHedge` is the same distinction, stated for a verb
|
|
675
|
+
* that has no exit code for it to matter to.)
|
|
390
676
|
*/
|
|
391
|
-
export function advisoryAnswer(body, unanalyzed) {
|
|
677
|
+
export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable = [], noManifest = []) {
|
|
392
678
|
const unevaluated = body?.unevaluated;
|
|
393
|
-
if (!unanalyzed?.length && !unevaluated?.length
|
|
679
|
+
if (!unanalyzed?.length && !unevaluated?.length && !judgedNothing?.length && !unreadable?.length
|
|
680
|
+
&& !noManifest?.length)
|
|
681
|
+
return body; // COMPLETE: unchanged, byte for byte, `ok` and all.
|
|
394
682
|
const { ok, ...rest } = body; // eslint-disable-line no-unused-vars -- omitted BY DESIGN
|
|
683
|
+
// The array of report paths, same key and same shape as `completenessFields` — ONE wire spelling for
|
|
684
|
+
// this key across the answer and advisory documents (see the shape ruling there). `unreadable` withdraws
|
|
685
|
+
// `ok` and raises `incomplete` with NO key of its own, exactly as `completenessFields` rules it.
|
|
686
|
+
// ⟨0.28⟩ `noManifest` DOES get a key of its own — SPEC §2 pins it — and it rides here for the same
|
|
687
|
+
// reason `judgedNothing` does: an advisory verb's `ok` is a claim about the CODE, and a report that
|
|
688
|
+
// never emitted a manifest cannot support it (row 3: *no manifest, no claim*).
|
|
689
|
+
const judged = { ...(judgedNothing?.length ? { judgedNothing } : {}),
|
|
690
|
+
...(noManifest?.length ? { noManifest } : {}) };
|
|
395
691
|
// Key order matches the gate's verdict document: the finding, then `unevaluated`, then the manifest.
|
|
396
|
-
|
|
692
|
+
if (unanalyzed?.length) return { ...rest, incomplete: true, unanalyzed, ...judged };
|
|
693
|
+
return (judgedNothing?.length || noManifest?.length || unreadable?.length)
|
|
694
|
+
? { ...rest, incomplete: true, ...judged } : rest;
|
|
397
695
|
}
|
|
398
696
|
|
|
399
697
|
export function loadReport(prefix) {
|
|
@@ -546,6 +844,31 @@ export function loadCallgraph(prefix) {
|
|
|
546
844
|
return tagPartial(norm(cg), partial);
|
|
547
845
|
}
|
|
548
846
|
|
|
847
|
+
/**
|
|
848
|
+
* ⟨0.28⟩ The call graph EMBEDDED IN THE REPORT — each entry's §2 `calls` edges, keyed by fn. The
|
|
849
|
+
* fallback the graph verbs (`callers`/`impact`/`path`, CLI and MCP) run over when the §2.2 sidecar is
|
|
850
|
+
* absent, exactly as rust (callers.rs: "Fallback (no call-graph sidecar): build a graph from the
|
|
851
|
+
* report's effect-relevant `calls` edges and run the SAME query") and java do. Without it, a VALID
|
|
852
|
+
* report queried without its sidecar — a single hand-copied `report.json`, a locator §3.3.1 supports —
|
|
853
|
+
* answered `unanswerable` at exit 2 here while rust and java answered real callers at exit 0.
|
|
854
|
+
* `unanswerable` is for a graph that is genuinely ABSENT, not for one present by another route.
|
|
855
|
+
*
|
|
856
|
+
* EVERY entry is a key, even one with no calls — a leaf that performs its effect directly must still
|
|
857
|
+
* resolve as a target (rust's fallback map collects every entry). Same-named entries across merged
|
|
858
|
+
* workspace siblings UNION their edges (the effectsByFn lesson: last-wins silently drops a member's
|
|
859
|
+
* rows). Effect-relevant edges ONLY, so this graph under-approximates the sidecar: a pure fn appears at
|
|
860
|
+
* most as a CALLEE, never a key — which is why a no-match over the fallback is INCONCLUSIVE, not proof
|
|
861
|
+
* of absence (the callers verb keeps that distinction; rust corpus-audit #5).
|
|
862
|
+
*/
|
|
863
|
+
export function reportCallsGraph(fns) {
|
|
864
|
+
const g = {};
|
|
865
|
+
for (const e of fns) {
|
|
866
|
+
const prev = g[e.fn];
|
|
867
|
+
g[e.fn] = prev ? [...new Set([...prev, ...(e.calls ?? [])])] : [...(e.calls ?? [])];
|
|
868
|
+
}
|
|
869
|
+
return g;
|
|
870
|
+
}
|
|
871
|
+
|
|
549
872
|
// ---- the §3.1 match ladder: exact > segment-suffix > substring ------------------------------------
|
|
550
873
|
function matchTier(name, q) {
|
|
551
874
|
if (name === q) return 3;
|