candor-ts 0.26.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 +134 -6
- package/query-core.mjs +335 -12
- package/query.mjs +879 -57
- package/scan-core.mjs +39 -0
- package/scan.mjs +1330 -39
- package/surface.mjs +15 -2
package/query.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
|
|
26
26
|
import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, discoverConfigText,
|
|
27
27
|
evaluatePolicy, reportNetClasses, resolveReasonClasses, discoverConfigPath,
|
|
28
|
-
policyVocabularyAnchor, policyErrorText,
|
|
28
|
+
policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules,
|
|
29
29
|
fatalPolicyErrors, refusalVerdict,
|
|
30
30
|
unanswerableScoped } from "./policy.mjs";
|
|
31
31
|
import { hasReport } from "./query-core.mjs";
|
|
@@ -42,9 +42,11 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
42
42
|
containment as coreContainment, diff as coreDiff,
|
|
43
43
|
where as coreWhere, map as coreMap, whatif as coreWhatif,
|
|
44
44
|
fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
|
|
45
|
-
matches as coreMatches, gainsCoverage, parseClassFilter, ClassFilterError,
|
|
46
|
-
loadReport, loadCallgraph,
|
|
47
|
-
|
|
45
|
+
matches as coreMatches, gainsCoverage, gainsCompletenessFields, parseClassFilter, ClassFilterError,
|
|
46
|
+
loadReport, loadCallgraph, reportCallsGraph, loadGateReport, gateReportInputFiles,
|
|
47
|
+
reportVersion, reportPackage,
|
|
48
|
+
advisoryAnswer,
|
|
49
|
+
reportCompleteness, mustHedge, completenessFields, absorbCompleteness } from "./query-core.mjs";
|
|
48
50
|
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
49
51
|
// ⟨0.24⟩ SPEC §3.2 — THE OTHER CHANNEL. `advisoryAnswer` withdraws the claim from the JSON; this withdraws
|
|
50
52
|
// it from the one a human reads, and the spec requires both because a test that reads one channel is
|
|
@@ -71,6 +73,79 @@ const advisoryUnevaluatedNote = (verb, unevaluated, tail) => {
|
|
|
71
73
|
console.error(` ${tail}`);
|
|
72
74
|
};
|
|
73
75
|
const UNEVAL_TAIL_STRICT = "(`ok` is OMITTED — neither value is a statement this input licenses; no remedy is offered for a boundary the gate could not adjudicate; `--strict` exits 2, the could-not-evaluate code)";
|
|
76
|
+
|
|
77
|
+
// ---- ⟨0.28⟩ SPEC §2 — AN ADVISORY VERB OVER A CONFIGURED ZERO-RULE POLICY ANSWERS WITH THE CAVEAT
|
|
78
|
+
// DOCUMENT, RESULT KEYS WITHHELD, EXIT UNCHANGED.
|
|
79
|
+
//
|
|
80
|
+
// §6.2 makes the same condition an exit-2 REFUSAL for the GATE, on the ground that `ok: true` is a claim
|
|
81
|
+
// about the code no such run is entitled to make. These verbs share that loader and were not touched by
|
|
82
|
+
// the rung. They are ADVISORY — they set no verdict, so the gate's refusal posture is the wrong import.
|
|
83
|
+
// What they DO produce is an answer *relative to a policy*, and relative to no rules that answer is not a
|
|
84
|
+
// finding, it is an absence of questions. MEASURED here 2026-08-12 over `# no rules yet`:
|
|
85
|
+
//
|
|
86
|
+
// whatif {"of":[…],"affected":[…],"violations":[],"ok":true} exit 0
|
|
87
|
+
// fix {"crossing":false,"reason":"not-forbidden"} exit 0
|
|
88
|
+
// fix-gate {"ok":true,"remedies":[]} exit 0 (also --strict)
|
|
89
|
+
// unverified {"ok":true,"unverified":[]} exit 0 (also --strict)
|
|
90
|
+
//
|
|
91
|
+
// `not-forbidden` by a policy that forbids nothing is vacuously true — an all-clear produced by deleting
|
|
92
|
+
// the question. So the result keys are withheld: `unverified` does not emit an empty `unverified` list
|
|
93
|
+
// over a policy that asked nothing, for the same reason ⟨0.27⟩'s refusal document must not carry
|
|
94
|
+
// `violations`. And `fix` emits NO `crossing` KEY — ⟨0.28⟩ pins that key as present exactly when the verb
|
|
95
|
+
// answered, and here it did not.
|
|
96
|
+
//
|
|
97
|
+
// `fix` IS IN THE LIST BECAUSE THE LIST IS A CONDITION, NOT AN ENUMERATION. §2 names `whatif`/`fix-gate`/
|
|
98
|
+
// `unverified` because those were the three in front of the author, and records the divergence that
|
|
99
|
+
// created: candor-rust extended the rule to `fix` and flagged it, candor-swift read the list as closed and
|
|
100
|
+
// did not. Every verb that answers relative to a CONFIGURED policy takes this rule.
|
|
101
|
+
//
|
|
102
|
+
// NO NEW KEY. The document carries `unevaluated` with one entry naming the whole policy, in the EXACT
|
|
103
|
+
// spelling this engine's own gate routes already use for their zero-rule refusal (`policyZeroRules`, one
|
|
104
|
+
// builder, shared) — so the gate and the advisory verbs say the same thing about the same policy in the
|
|
105
|
+
// same words, which is what makes a cross-engine consumer possible at all.
|
|
106
|
+
//
|
|
107
|
+
// A policy that is NOT CONFIGURED is untouched: that remains the honest way to say "I am not gating"
|
|
108
|
+
// (§6.2), and it is exactly why a configured zero-rule policy is never a legitimate expression of it.
|
|
109
|
+
const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length;
|
|
110
|
+
const emitZeroRuleCaveat = (verb, policyFile, comp) => {
|
|
111
|
+
const { unevaluated } = policyZeroRules(policyFile);
|
|
112
|
+
console.error(`candor-ts: ${verb}: the policy at ${policyFile} yielded NO RULES — every line was ignored `
|
|
113
|
+
+ `(see the \`ignoring policy rule\` warnings above), the file is empty, or it holds only comments. A `
|
|
114
|
+
+ `policy with no rules ASKS NOTHING, so this verb has no answer to give relative to it: the result `
|
|
115
|
+
+ `keys are WITHHELD and this caveat stands in their place (SPEC §2 ⟨0.28⟩). \`gate\` refuses outright `
|
|
116
|
+
+ `over this policy (exit 2). If you did not mean to gate, remove the policy configuration rather than `
|
|
117
|
+
+ `pointing it at a file with no rules in it.`);
|
|
118
|
+
// The report-completeness caveat rides the SAME document when it applies: the two disclosures are
|
|
119
|
+
// independent — one says the policy asked nothing, the other that the report could not see everything —
|
|
120
|
+
// and each says something the other does not.
|
|
121
|
+
emit({ unevaluated, ...completenessFields(comp) });
|
|
122
|
+
};
|
|
123
|
+
// ⟨0.28⟩ SPEC §2's OTHER cause on the ADVISORY channel — `analyzed.count: 0`, which `advisoryIncompleteNote`
|
|
124
|
+
// above could not say because it is written around an unread FILE and this report names none. Same two
|
|
125
|
+
// channels, different sentence, and the tail is the OPPOSITE one: the gate exits 0 over these bytes
|
|
126
|
+
// (⟨0.24⟩: a disclosure, not an exit code), so `--strict` does not move either and this note is all there is.
|
|
127
|
+
// ⟨0.28⟩ The THIRD cause on the advisory channel — a report file under the locator whose bytes could not
|
|
128
|
+
// be parsed. Its own sentence because the repair differs (fix or re-write that file, not "re-scan the
|
|
129
|
+
// sources"), and the gate tail is the `unanalyzed` one: `gate --report` REFUSES over a corrupt member
|
|
130
|
+
// (measured, exit 2), so `--strict`'s exit is bounded by the gate exactly as for an unread source file.
|
|
131
|
+
const advisoryUnreadableNote = (verb, unreadable) => {
|
|
132
|
+
console.error(`candor-ts: ${verb} could NOT fully evaluate — ${unreadable.length} report file(s) under this locator could not be parsed, and whatever they say is not in this answer:`);
|
|
133
|
+
for (const f of unreadable) console.error(` ${f}`);
|
|
134
|
+
console.error(" (`ok` is OMITTED — neither value is a statement this input licenses; `gate --report` exits 2 over these bytes. Fix or regenerate the corrupt report.)");
|
|
135
|
+
};
|
|
136
|
+
// ⟨0.28⟩ SPEC §2's THIRD ROW on the ADVISORY channel. Its own sentence beside the judged-nothing one
|
|
137
|
+
// below, for the reason `incompleteAnswerNote` gives: that note asserts `analyzed.count: 0`, which a
|
|
138
|
+
// row-3 report never said, and the repair is a producer that emits a manifest rather than a scan that
|
|
139
|
+
// reaches a conclusion.
|
|
140
|
+
const advisoryNoManifestNote = (verb, files) => {
|
|
141
|
+
console.error(`candor-ts: ${verb} could NOT fully evaluate — ${files.length} report(s) under this locator carry NO \`analyzed\` manifest at all (SPEC §2 row 3, a pre-⟨0.21⟩ producer), so they make no claim about what was judged and their silence licenses none either:`);
|
|
142
|
+
for (const f of files) console.error(` ${f}`);
|
|
143
|
+
console.error(" (`ok` is OMITTED — neither value is a statement this input licenses. `gate --report` exits 0 over these bytes, so this note is the whole of the warning. Re-scan with a current engine so the report carries its manifest.)");
|
|
144
|
+
};
|
|
145
|
+
const advisoryJudgedNothingNote = (verb) => {
|
|
146
|
+
console.error(`candor-ts: ${verb} could NOT fully evaluate — the report(s) under this locator say they JUDGED NOTHING (⟨0.24⟩ \`analyzed.count\` is 0, absent with no entries, or unreadable), so absence from \`functions\` licenses no purity claim about any unit and there is nothing here to certify`);
|
|
147
|
+
console.error(" (`ok` is OMITTED — neither value is a statement this input licenses. NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU: `gate --report` exits 0 over a judged-nothing report and `--strict` does not move either, so this note is the whole of the warning. Re-scan the sources you meant to check.)");
|
|
148
|
+
};
|
|
74
149
|
// The §6 effect vocabulary — used to reject a typo'd effect name in `where` (corpus-audit #3). Kept in step
|
|
75
150
|
// with SPEC §6 / the umbrella's list; an unknown name PRESENT in a report (a spec extension) is still allowed.
|
|
76
151
|
const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
|
|
@@ -95,26 +170,167 @@ const wantJsonOut = (a) =>
|
|
|
95
170
|
a.includes("--json") || (!a.includes("--text") && !a.includes("--human") && !process.stdout.isTTY);
|
|
96
171
|
// Emit the pinned JSON, or render prose via proseFn(data). Returns data so the caller can still exit on it.
|
|
97
172
|
const put = (a, data, proseFn) => { if (!proseFn || wantJsonOut(a)) emit(data); else proseFn(data); return data; };
|
|
173
|
+
|
|
174
|
+
// ---- ⟨0.28⟩ SPEC §2 — THE COMPLETENESS CAVEAT ON A *DESCRIPTIVE* VERB. `advisoryAnswer` +
|
|
175
|
+
// `advisoryIncompleteNote` above are the two channels for a verb that renders a VERDICT; the clause they
|
|
176
|
+
// implement was scoped to verdicts, and ⟨0.28⟩ widens it to "any verb whose output could be read as a
|
|
177
|
+
// negative finding about the code — a verdict, an empty result set, or a zero count". These three helpers
|
|
178
|
+
// are the SAME two channels for an ANSWER. One reader (`reportCompleteness`), one key set
|
|
179
|
+
// (`completenessFields`), one trigger (`mustHedge`) — a second mechanism is how the family ended up with
|
|
180
|
+
// two element rules for the manifest reader, and how one channel goes quiet while the other is asserted on.
|
|
181
|
+
|
|
182
|
+
// What `gate --report` does over THESE SAME BYTES, as one sentence — a function and not a constant because
|
|
183
|
+
// the two causes get OPPOSITE answers. §3.3 makes an incomplete analysis of the target's own code an exit-2
|
|
184
|
+
// gate cause, so "the gate refuses too" is true of `unanalyzed`; ⟨0.24⟩ ruled count-0 the other way ("a
|
|
185
|
+
// disclosure, not an exit code"), so the gate exits 0 there. A note that sends the reader to a CI job which
|
|
186
|
+
// then passes teaches them the warning is noise — the disclosure discrediting itself. The count-0 sentence
|
|
187
|
+
// is also the more urgent one and says so: nothing downstream fails closed on those bytes.
|
|
188
|
+
const gateLine = (comp) => (comp.unanalyzed.length || comp.unreadable?.length
|
|
189
|
+
? "`gate --report` exits 2 over these bytes."
|
|
190
|
+
: "NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU — `gate --report` exits 0 over a judged-nothing report (⟨0.24⟩: a disclosure, not an exit code), so this note is the whole of the warning.");
|
|
191
|
+
|
|
192
|
+
// The HUMAN half. A no-op when there is nothing to disclose, so an ordinary run stays byte-identical, and
|
|
193
|
+
// printed BEFORE the answer because it qualifies a NON-EMPTY result as much as an empty one: a function in
|
|
194
|
+
// an unread file performs the effect or does not, and no list below can say which.
|
|
195
|
+
//
|
|
196
|
+
// ON STDOUT, WITH THE ANSWER IT QUALIFIES — this engine had it on stderr, alone against three: java
|
|
197
|
+
// documents stdout as deliberate ("a caveat on the other stream is one `2>/dev/null` from gone"), and
|
|
198
|
+
// swift's log records catching and REVERTING exactly this stderr choice after diffing against rust. The
|
|
199
|
+
// caveat and the answer must travel the same pipe, or a `2>/dev/null` consumer keeps the reassurance and
|
|
200
|
+
// loses its withdrawal. Every caller is on the PROSE branch (`putAnswer`'s else-arm, `tour`'s human arm,
|
|
201
|
+
// `gains`' else-arm), so stdout is never carrying a JSON document when this prints — JSON-mode runs take
|
|
202
|
+
// the machine half (`completenessFields`) instead and this function is not called at all.
|
|
203
|
+
const incompleteAnswerNote = (comp, soWhat, tail) => {
|
|
204
|
+
if (!mustHedge(comp)) return;
|
|
205
|
+
// Three causes, one sentence each, and only true ones: `unreadable` is a report file whose BYTES could
|
|
206
|
+
// not be parsed — not "declared unanalyzed units" and not "judged nothing", both of which would send
|
|
207
|
+
// the reader to the wrong repair (rust names it separately for the same reason).
|
|
208
|
+
const causes = [];
|
|
209
|
+
if (comp.unanalyzed.length)
|
|
210
|
+
causes.push(`declare ${comp.unanalyzed.length} unit(s) candor could not analyze`);
|
|
211
|
+
if (comp.judgedNothing.length)
|
|
212
|
+
causes.push("say they JUDGED NOTHING (`analyzed.count: 0`)");
|
|
213
|
+
// ⟨0.28⟩ SPEC §2's THIRD ROW, and it gets its OWN sentence because the one above was FALSE for it:
|
|
214
|
+
// this engine told the reader a row-3 report "says it judged nothing (`analyzed.count: 0`)" when the
|
|
215
|
+
// report declares nothing at all, and this family rates a false disclosure worse than a missing one.
|
|
216
|
+
// The repairs differ too — row 1 wants a scan that reaches a conclusion, row 3 wants a producer that
|
|
217
|
+
// emits a manifest — so a reader given the wrong one goes to the wrong place.
|
|
218
|
+
if (comp.noManifest?.length)
|
|
219
|
+
causes.push(`include ${comp.noManifest.length} report(s) carrying NO \`analyzed\` manifest at all`);
|
|
220
|
+
if (comp.unreadable?.length)
|
|
221
|
+
causes.push(`include ${comp.unreadable.length} file(s) that could not be parsed at all`);
|
|
222
|
+
console.log(`candor-ts: ⚠ INCOMPLETE — the report(s) under this locator ${causes.join(", and ")}, so ${soWhat}:`);
|
|
223
|
+
for (const u of comp.unanalyzed) console.log(` ${u.path}${u.reason ? ` (${u.reason})` : ""}`);
|
|
224
|
+
if (comp.judgedNothing.length)
|
|
225
|
+
console.log(" (a report that judged nothing names no function at all — its silence is not a purity claim about any unit)");
|
|
226
|
+
for (const f of comp.noManifest ?? [])
|
|
227
|
+
console.log(` ${f} — no \`analyzed\` manifest (a pre-⟨0.21⟩ producer): it makes no claim about what was judged, so its silence licenses none either. Re-scan with a current engine.`);
|
|
228
|
+
for (const f of comp.unreadable ?? [])
|
|
229
|
+
console.log(` ${f} — could not be parsed (corrupt or mid-write), so whatever it says is not in this answer`);
|
|
230
|
+
console.log(` ${tail} ${gateLine(comp)}`);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// The MACHINE half. Spread LAST so the verb's own pinned key order is untouched (JS objects keep insertion
|
|
234
|
+
// order), and `{}` on a complete report — the whole document is then byte-identical to a pre-⟨0.28⟩ one.
|
|
235
|
+
//
|
|
236
|
+
// THE COLLISION GUARD THIS USED TO CARRY IS GONE, because ⟨0.28⟩ Rung A removed the only shape that could
|
|
237
|
+
// construct it. `map` was the one caller whose top level is a USER NAMESPACE, and it answered by MERGING
|
|
238
|
+
// the hedge over a module literally named `incomplete` and disclosing the loss on stderr — a lost row the
|
|
239
|
+
// operator was told about, which was the best available answer while the caveat had to ride the result.
|
|
240
|
+
// It no longer does: `map` takes `putCaveatInstead` below, where the caveat REPLACES the document and
|
|
241
|
+
// nothing is displaced. Every remaining caller has a FIXED key set of its own (`where`
|
|
242
|
+
// {effect,directly,inherited}, `reachable` {entryPoints,effects}, `containment` {contained,ambient},
|
|
243
|
+
// `blindspots` {sources,totalUnknown}), so a collision here is not constructible from any report.
|
|
244
|
+
// Deleted rather than kept as a dormant guard: a check whose condition cannot arise reads as coverage.
|
|
245
|
+
const withCompleteness = (data, comp) => ({ ...data, ...completenessFields(comp) });
|
|
246
|
+
|
|
247
|
+
// `put`, plus the caveat on BOTH channels from ONE trigger — a caller cannot get the JSON half and the prose
|
|
248
|
+
// half to disagree, which is exactly the mutant (`ec1a441`) that survived a whole suite in candor-rust.
|
|
249
|
+
// `proseFn` receives the hedge flag as its second argument so it can WITHDRAW its reassuring sentence; the
|
|
250
|
+
// note alone is not enough, because "no Unknown sources ✓" IS the prose spelling of the empty JSON.
|
|
251
|
+
const putAnswer = (a, data, proseFn, comp, soWhat, tail) => {
|
|
252
|
+
if (!proseFn || wantJsonOut(a)) { emit(withCompleteness(data, comp)); return data; }
|
|
253
|
+
incompleteAnswerNote(comp, soWhat, tail);
|
|
254
|
+
proseFn(data, mustHedge(comp));
|
|
255
|
+
return data;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// ---- ⟨0.28⟩ RUNG A — SPEC §2: "A VERB WHOSE PINNED SHAPE CANNOT CARRY THE CAVEAT MUST EMIT THE CAVEAT
|
|
259
|
+
// DOCUMENT INSTEAD OF ITS RESULT DOCUMENT." Not a result document with the caveat omitted, and not an
|
|
260
|
+
// empty result of the pinned shape. `putAnswer` above SPREADS the caveat into the answer, which works for
|
|
261
|
+
// every verb with a fixed key set; two verbs have no such place:
|
|
262
|
+
//
|
|
263
|
+
// show pinned to a TOP-LEVEL ARRAY — nowhere to put a key at all. MEASURED here 2026-08-12 over a
|
|
264
|
+
// report declaring one `unanalyzed` unit: `[]`, exit 0, no caveat on ANY channel. *Nothing
|
|
265
|
+
// performs this effect*, asserted about code nobody examined.
|
|
266
|
+
// map keyed by the operator's own MODULE names. MEASURED: the caveat keys merged INTO the module
|
|
267
|
+
// namespace, and the merged shape disclosed the collision loudly while still dropping the row.
|
|
268
|
+
//
|
|
269
|
+
// AND THE RULING NAMES THIS ENGINE FOR WHY THE `@`-PREFIX ESCAPE IS NOT AVAILABLE: §2.2's convention is
|
|
270
|
+
// airtight for a sidecar because a `@`-key cannot collide with a TYPE name — but an npm scoped package is
|
|
271
|
+
// spelled `@scope/name`, so `@incomplete` is a key a real ts module could own. "A convention that is
|
|
272
|
+
// airtight in one namespace and merely unlikely in another is not a convention; it is a deferred
|
|
273
|
+
// collision."
|
|
274
|
+
//
|
|
275
|
+
// THE TYPE CHANGE IS THE POINT. A consumer doing `for (const x of doc)` over `show` gets a TypeError
|
|
276
|
+
// rather than a silent zero-iteration loop — the one case where breaking a consumer is the CORRECT
|
|
277
|
+
// outcome, because the consumer was being lied to. The exit does not move (⟨0.24⟩: a disclosure, not an
|
|
278
|
+
// exit code), and HEALTHY OUTPUT IS BYTE-IDENTICAL: the shape changes only on the hedge path.
|
|
279
|
+
//
|
|
280
|
+
// The PROSE arm is `putAnswer`'s, verbatim — prose has no shape problem, and the clause is about the
|
|
281
|
+
// machine document. Sharing the note + renderer call keeps the two channels from drifting.
|
|
282
|
+
//
|
|
283
|
+
// THE HEALTHY JSON ARM MUST NOT GO THROUGH `withCompleteness`, and the first draft of this helper did:
|
|
284
|
+
// it delegated to `putAnswer`, whose `{ ...data, ...{} }` turned `show`'s ARRAY into `{"0": {…}}` — the
|
|
285
|
+
// pinned top-level array destroyed on the very path this rung promises to leave byte-identical. Caught by
|
|
286
|
+
// the intact-input control before it left the machine. So the healthy arm emits `data` itself.
|
|
287
|
+
const putCaveatInstead = (a, data, proseFn, comp, soWhat, tail) => {
|
|
288
|
+
if (!proseFn || wantJsonOut(a)) { emit(mustHedge(comp) ? completenessFields(comp) : data); return data; }
|
|
289
|
+
incompleteAnswerNote(comp, soWhat, tail);
|
|
290
|
+
proseFn(data, mustHedge(comp));
|
|
291
|
+
return data;
|
|
292
|
+
};
|
|
293
|
+
// The one sentence every hedged prose arm ends with, so the six cannot drift into six wordings.
|
|
294
|
+
const NOT_A = (claim) => `— but see the INCOMPLETE note above; this is NOT "${claim}"`;
|
|
98
295
|
const csv = (xs) => (xs && xs.length ? xs.join(", ") : "none");
|
|
99
296
|
const rows = (xs, pre = " ") => { for (const x of xs) console.log(pre + x); };
|
|
100
297
|
// Per-verb prose renderers. Read the SAME shapes query-core returns (so JSON and prose can't drift); kept
|
|
101
298
|
// terse and scannable, in candor's voice (cf. the existing `tour`/`path` human forms).
|
|
102
299
|
const P = {
|
|
103
|
-
|
|
300
|
+
// ⟨0.28⟩ `hedge` is `mustHedge(comp)` (see `putAnswer`): the report could not support a determined
|
|
301
|
+
// negative, so the reassuring sentence — which IS the prose spelling of the empty JSON — is withdrawn.
|
|
302
|
+
// Never a manufactured finding in its place: the answer stands, its STANDING is what changes.
|
|
303
|
+
where: (d, hedge) => {
|
|
104
304
|
const n = d.directly.length + d.inherited.length;
|
|
105
|
-
if (n === 0) {
|
|
305
|
+
if (n === 0) {
|
|
306
|
+
console.log(hedge
|
|
307
|
+
? `candor: 0 functions candor COULD SEE perform ${d.effect} ${NOT_A(`nothing performs ${d.effect}`)}.`
|
|
308
|
+
: `candor: 0 functions perform ${d.effect} in this report.`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
106
311
|
console.log(`candor where ${d.effect} — ${n} function${n === 1 ? "" : "s"}:`);
|
|
107
312
|
if (d.directly.length) { console.log(` perform it directly (${d.directly.length}):`); rows(d.directly); }
|
|
108
313
|
if (d.inherited.length) { console.log(` reach it transitively (${d.inherited.length}):`); rows(d.inherited); }
|
|
109
314
|
},
|
|
110
315
|
callers: (d) => {
|
|
316
|
+
// ⟨0.28⟩ UNREACHABLE FROM THE CLI since the callers verb split the empty-`of` case into its two real
|
|
317
|
+
// causes below (no graph at all -> `unanswerable`, exit 2; a name absent from a real graph -> "no
|
|
318
|
+
// function matching", exit 2). Kept as a renderer guard only; do NOT route a new caller through it —
|
|
319
|
+
// this sentence is a determined negative and is the wrong answer when there is no call graph.
|
|
111
320
|
if (!d.of.length) { console.log("candor: no function in the call graph matches that name."); return; }
|
|
112
321
|
console.log(`candor callers — who reaches \`${d.of.join("`, `")}\`:`);
|
|
113
322
|
console.log(` direct callers (${d.direct.length}): ${csv(d.direct)}`);
|
|
114
323
|
console.log(` transitive callers (${d.transitive.length}): ${csv(d.transitive)}`);
|
|
115
324
|
},
|
|
116
|
-
show: (d) => {
|
|
117
|
-
|
|
325
|
+
show: (d, hedge) => {
|
|
326
|
+
// ⟨0.28⟩ the empty sentence stops citing the ⟨0.21⟩ purity convention when the report cannot back it:
|
|
327
|
+
// over these bytes an absent function is not evidence of purity, it is evidence of nothing.
|
|
328
|
+
if (!d.length) {
|
|
329
|
+
console.log(hedge
|
|
330
|
+
? `candor: no effectful function candor COULD SEE matches that name ${NOT_A("this function is pure")} — absence from this report licenses no purity claim here.`
|
|
331
|
+
: "candor: no effectful function matches that name (pure functions are omitted from the report).");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
118
334
|
d.forEach((e, i) => {
|
|
119
335
|
if (i) console.log("");
|
|
120
336
|
console.log(`${e.fn}`);
|
|
@@ -125,30 +341,54 @@ const P = {
|
|
|
125
341
|
if (e.tables?.length) console.log(` tables: ${e.tables.join(", ")}`);
|
|
126
342
|
});
|
|
127
343
|
},
|
|
128
|
-
map: (d) => {
|
|
344
|
+
map: (d, hedge) => {
|
|
129
345
|
const mods = Object.entries(d);
|
|
130
|
-
if (!mods.length) {
|
|
346
|
+
if (!mods.length) {
|
|
347
|
+
console.log(hedge
|
|
348
|
+
? `candor: no effectful module candor COULD SEE ${NOT_A("the code performs no effects")}.`
|
|
349
|
+
: "candor: no effectful modules in this report.");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
131
352
|
console.log("candor map — effects by module:");
|
|
132
353
|
for (const [m, v] of mods) console.log(` ${m} — ${csv(v.effects)} (${v.functions} fn${v.functions === 1 ? "" : "s"})`);
|
|
133
354
|
},
|
|
134
|
-
containment: (d) => {
|
|
355
|
+
containment: (d, hedge) => {
|
|
135
356
|
if ("leaks" in d) { // ratchet (a baseline was given)
|
|
136
|
-
|
|
357
|
+
// The ✓ is withdrawn from BOTH directions here, not just the empty one: this answer is a DIFFERENCE,
|
|
358
|
+
// so a partial side is unsound two ways — a leak in an unread CURRENT file is missed, one in an unread
|
|
359
|
+
// BASELINE file reads as newly appeared (a fabricated leak, at exit 1).
|
|
360
|
+
if (!d.leaks.length)
|
|
361
|
+
console.log(hedge
|
|
362
|
+
? `candor containment — no boundary effect candor COULD SEE reached a new layer vs the baseline ${NOT_A("nothing leaked")}.`
|
|
363
|
+
: "candor containment — no boundary effect reached a new layer vs the baseline. ✓");
|
|
137
364
|
else { console.log(`candor containment — ${d.leaks.length} boundary effect(s) reached a NEW layer (leak):`); rows(d.leaks); }
|
|
138
365
|
if (d.cleanups && d.cleanups.length) { console.log(` no longer present (${d.cleanups.length}):`); rows(d.cleanups); }
|
|
139
366
|
return;
|
|
140
367
|
}
|
|
141
|
-
if (!d.contained.length && !Object.keys(d.ambient).length) {
|
|
368
|
+
if (!d.contained.length && !Object.keys(d.ambient).length) {
|
|
369
|
+
console.log(hedge
|
|
370
|
+
? `candor containment — no boundary effect candor COULD SEE ${NOT_A("there are no boundary effects")}.`
|
|
371
|
+
: "candor containment — no boundary effects in this report.");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
142
374
|
console.log("candor containment — how well each boundary effect stays in one layer:");
|
|
143
375
|
for (const c of d.contained)
|
|
144
376
|
console.log(` ${c.effect}: ${c.containmentPct}% in \`${c.owner}\` (spread across ${c.layers} layer${c.layers === 1 ? "" : "s"})`);
|
|
145
377
|
const amb = Object.entries(d.ambient);
|
|
146
378
|
if (amb.length) console.log(` ambient (reported, not scored): ${amb.map(([e, n]) => `${e}×${n}`).join(", ")}`);
|
|
147
379
|
},
|
|
148
|
-
reachable: (d) => {
|
|
380
|
+
reachable: (d, hedge) => {
|
|
149
381
|
const effs = Object.entries(d.effects);
|
|
150
382
|
console.log(`candor reachable — what the ${d.entryPoints} entry point${d.entryPoints === 1 ? "" : "s"} do at runtime:`);
|
|
151
|
-
|
|
383
|
+
// "the program performs no effect at runtime" is the strongest claim this tool can make, and it stays a
|
|
384
|
+
// DETERMINED negative on good data (a library has no entry points) — which is exactly why the caveat
|
|
385
|
+
// must be said, rather than left for a reader to infer from the emptiness.
|
|
386
|
+
if (!effs.length) {
|
|
387
|
+
console.log(hedge
|
|
388
|
+
? ` no effect reaches an entry point candor COULD SEE ${NOT_A("the program performs no effect at runtime")}.`
|
|
389
|
+
: " no effect reaches an entry point.");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
152
392
|
for (const [e, v] of effs) console.log(` ${e}: ${v.count} (via ${csv(v.via)})`);
|
|
153
393
|
},
|
|
154
394
|
impact: (d) => {
|
|
@@ -157,19 +397,38 @@ const P = {
|
|
|
157
397
|
if (d.affected.length) rows(d.affected);
|
|
158
398
|
if (d.entryPoints.length) { console.log(` reachable from ${d.entryPoints.length} entry point(s):`); rows(d.entryPoints.map((ep) => `${ep.fn} [${csv(ep.inferred)}]`)); }
|
|
159
399
|
},
|
|
160
|
-
blindspots: (d) => {
|
|
161
|
-
if (!d.sources.length) {
|
|
400
|
+
blindspots: (d, hedge) => {
|
|
401
|
+
if (!d.sources.length) {
|
|
402
|
+
console.log(hedge
|
|
403
|
+
? `candor blindspots — no Unknown source candor COULD SEE ${NOT_A("there are no blind spots")}.`
|
|
404
|
+
: `candor blindspots — no Unknown sources${d.totalUnknown ? " (all Unknown here is inherited, not rooted in a call)" : ""}. ✓`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
162
407
|
console.log(`candor blindspots — ${d.sources.length} Unknown source${d.sources.length === 1 ? "" : "s"} (of ${d.totalUnknown} function(s) carrying Unknown), most-smearing first:`);
|
|
163
408
|
for (const s of d.sources) console.log(` \`${s.fn}\` — ${csv(s.why)}; reaches ${s.reaches} caller(s)`);
|
|
164
409
|
},
|
|
165
|
-
blindspotsStats: (d) => {
|
|
166
|
-
if (!d.sources) {
|
|
410
|
+
blindspotsStats: (d, hedge) => {
|
|
411
|
+
if (!d.sources) {
|
|
412
|
+
console.log(hedge
|
|
413
|
+
? `candor blindspots --stats — no Unknown source candor COULD SEE (nothing to classify) ${NOT_A("there are no blind spots")}.`
|
|
414
|
+
: "candor blindspots --stats — no Unknown sources (nothing to classify). ✓");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
167
417
|
console.log(`candor blindspots --stats — ${d.sources} Unknown source(s) by reason class (of ${d.totalUnknown} function(s) carrying Unknown) — size the blind-spot cost before \`deny E Unknown[…]\`:`);
|
|
168
418
|
Object.entries(d.byClass).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
|
|
169
419
|
.forEach(([k, v]) => console.log(` ${k.padEnd(12)} ${String(v).padStart(4)}${k === "setup" ? " ← fixable: the scan isn't configured, not a real blind spot" : ""}`));
|
|
170
420
|
},
|
|
171
|
-
gains
|
|
172
|
-
|
|
421
|
+
// ⟨0.28⟩ `hedge` is true when EITHER side's report is incomplete (see the gains case). "No newly-reached
|
|
422
|
+
// effects ✓" IS the prose spelling of `gained: []`, and it is the determined negative this alarm verb
|
|
423
|
+
// exists to license — so it is withdrawn, not decorated. A non-empty list is left standing: it may be
|
|
424
|
+
// short, which the stderr note above says, but every entry in it was measured.
|
|
425
|
+
gains: (d, hedge) => {
|
|
426
|
+
if (!d.gained.length) {
|
|
427
|
+
console.log(hedge
|
|
428
|
+
? `candor gains — no newly-reached effects candor COULD SEE vs the baseline ${NOT_A("this bump gained nothing")}.`
|
|
429
|
+
: "candor gains — no newly-reached effects vs the baseline. ✓");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
173
432
|
console.log(`candor gains — the surface newly reaches: ${d.gained.join(", ")}`);
|
|
174
433
|
for (const g of d.byFunction) console.log(` \`${g.fn}\` gained ${g.effect}${g.origin ? ` (${g.origin})` : ""}`);
|
|
175
434
|
},
|
|
@@ -180,6 +439,18 @@ const P = {
|
|
|
180
439
|
},
|
|
181
440
|
};
|
|
182
441
|
|
|
442
|
+
// The fn-name UNIVERSE a `<fn>` target resolves against: the callgraph keys UNIONed with the report's fn
|
|
443
|
+
// names. Not a new set — this is byte-for-byte the one mcp.mjs's fn-existence guard already builds, so
|
|
444
|
+
// the CLI and the MCP surface refuse exactly the same names (a name the agent tool calls nonexistent must
|
|
445
|
+
// not be one the CLI silently answers `0` for). Used by the `impact`/`path` bad-target gates below.
|
|
446
|
+
//
|
|
447
|
+
// THE UNION IS THE SAFE DIRECTION. §2.2 makes every fn a callgraph key and the report is the effectful
|
|
448
|
+
// SUBSET, so today the union IS the keys; the union is what keeps that an observation rather than an
|
|
449
|
+
// assumption. Refusing only a name that resolves in NEITHER set means no answer these verbs can actually
|
|
450
|
+
// compute is ever withdrawn — gating on one set alone would be the ⟨0.24⟩ count-0 mirror defect the day
|
|
451
|
+
// the two disagreed.
|
|
452
|
+
const knownFnNames = (cg, fns) => [...new Set([...Object.keys(cg), ...fns.map((e) => e.fn)])];
|
|
453
|
+
|
|
183
454
|
// Render `path` in HUMAN (non-`--json`) form — the indented provenance chain, BYTE-IDENTICAL to the
|
|
184
455
|
// Rust reference (candor-query/src/callers.rs) and the Java port (Query.java). The `--json` shape is
|
|
185
456
|
// UNTOUCHED (conformance PART 5 pins `{effect, fn, path:[{fn,loc,source}]}` four-way): this path is
|
|
@@ -232,7 +503,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
232
503
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
233
504
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
234
505
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
235
|
-
const SPEC_VERSION = "0.
|
|
506
|
+
const SPEC_VERSION = "0.28";
|
|
236
507
|
|
|
237
508
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
238
509
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -313,15 +584,26 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
|
|
|
313
584
|
const positionals = [];
|
|
314
585
|
let reportLocator = null, policyFile = null, wantStrict = false, wantIncludeUnknown = false;
|
|
315
586
|
let sawClass = false; // ⟨0.24⟩ `--class` takes ONE list and is NOT repeatable (SPEC §6.2)
|
|
587
|
+
// SPEC §3.2 ⟨0.28⟩ (every value-taking flag below): "given no value" MEANS the next token is
|
|
588
|
+
// flag-shaped, or the clause is unimplementable — consuming the token as the value made this very
|
|
589
|
+
// diagnostic unreachable and silently reinterpreted a flag. Measured on this file: `where Fs
|
|
590
|
+
// --report --json` diagnosed the wrong cause ("no report files at prefix '--json'"), and on a
|
|
591
|
+
// NON-policy verb `--policy --json` consumed-and-DISCARDED the next flag at exit 0 — a silently
|
|
592
|
+
// different command than the one on screen, the §6.2 unknown-flag reinterpretation one position
|
|
593
|
+
// over. A bare `-` stays a value and fails loud downstream (an unreadable file / unknown class);
|
|
594
|
+
// `./--weird` spells a file genuinely named like a flag.
|
|
595
|
+
const flagShaped = (v) => v !== undefined && v !== "-" && v.startsWith("-") && v.length > 1;
|
|
316
596
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
317
597
|
const a = rawArgs[i];
|
|
318
598
|
if (a === "--report") {
|
|
319
599
|
// A `--report` with no following value is a LOUD usage error (exit 2), never a silent fall-back to
|
|
320
600
|
// discovery and never an uncaught `locatorToPrefix(undefined)` TypeError (`where Fs --report`).
|
|
601
|
+
if (flagShaped(rawArgs[i + 1])) { console.error(`candor-ts: --report was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a locator (a path really named that is spelled ./${rawArgs[i + 1]})`); process.exit(2); }
|
|
321
602
|
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --report requires a <locator> value (a directory, a .json report path, or a prefix)"); process.exit(2); }
|
|
322
603
|
reportLocator = rawArgs[++i]; continue;
|
|
323
604
|
}
|
|
324
605
|
if (a === "--policy") { // consumed for EVERY verb (a valid candor flag); used only by policy verbs
|
|
606
|
+
if (flagShaped(rawArgs[i + 1])) { console.error(`candor-ts: --policy was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a path (a file really named that is spelled ./${rawArgs[i + 1]})`); process.exit(2); }
|
|
325
607
|
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
|
|
326
608
|
const v = rawArgs[++i]; if (policy) policyFile = v; continue;
|
|
327
609
|
}
|
|
@@ -331,6 +613,7 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
|
|
|
331
613
|
if (a === "--include-unknown") { if (includeUnknown) wantIncludeUnknown = true; continue; } // used only by the verb that reads it
|
|
332
614
|
if (a === "--stats") { continue; } // ⟨0.20⟩ tolerated everywhere; read by the `blindspots` case via args.includes
|
|
333
615
|
if (a === "--class") { // ⟨0.20⟩ value flag; the value is read by the `blindspots` and `unverified` cases
|
|
616
|
+
if (flagShaped(rawArgs[i + 1])) { console.error(`candor-ts: --class was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a <class,…> list`); process.exit(2); }
|
|
334
617
|
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --class requires a <class,…> value (reflect,dispatch,indirect,native,unresolved,setup; aliases: dynamic,*)"); process.exit(2); }
|
|
335
618
|
// ⟨0.24⟩ SPEC §6.2's VALUE GRAMMAR, validated HERE — the one place every verb's args pass through, so
|
|
336
619
|
// no verb can accept a value the filter will not honour. Two rules, one reason (see parseClassFilter):
|
|
@@ -437,16 +720,222 @@ function resolveGateVerb(rawArgs, { strict = false } = {}) {
|
|
|
437
720
|
// deprecated-alias machinery, because it has NO POSITIONALS: a stray argument is a usage error, never
|
|
438
721
|
// probed as a report or a policy. Kept out of parseCanonical for that reason — the peel helpers exist to
|
|
439
722
|
// accept the old grammar, and there is no old grammar for a verb introduced at ⟨0.24⟩.
|
|
723
|
+
// ── SPEC §3.3.1 ⟨0.27⟩ sink-arming helpers, shared by the gate verb. The scan entry point has its own
|
|
724
|
+
// copies (scan.mjs) because it must not import from this file; the RULES are the spec's, not shared code.
|
|
725
|
+
|
|
726
|
+
/** Learn `--gate-json` and `--policy` from a verb's argv with NO side effects. */
|
|
727
|
+
function preScanGateArgs(av) {
|
|
728
|
+
let gate = null, policy = null, report = null;
|
|
729
|
+
for (let i = 0; i < av.length; i++) {
|
|
730
|
+
const a = av[i], v = av[i + 1];
|
|
731
|
+
if (a !== "--gate-json" && a !== "--policy" && a !== "--report") continue;
|
|
732
|
+
if (v === undefined || (v.startsWith("-") && v !== "-")) continue;
|
|
733
|
+
if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v; else report = v;
|
|
734
|
+
i++;
|
|
735
|
+
}
|
|
736
|
+
return { gate, policy, report };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Artifact identity, not string identity — `--policy /w/P --gate-json ./P` from /w is one file. */
|
|
740
|
+
function sameArtifactPath(a, b) {
|
|
741
|
+
if (!a || !b || a === "-" || b === "-") return false;
|
|
742
|
+
const resolve = (p) => {
|
|
743
|
+
try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
|
|
744
|
+
try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); }
|
|
745
|
+
catch { return null; }
|
|
746
|
+
};
|
|
747
|
+
const x = resolve(a);
|
|
748
|
+
return x !== null && x === resolve(b);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** The files the CWD-discovered \`.candor/config\` names, resolved as the loader resolves them. */
|
|
752
|
+
function configDeclaredInputs() {
|
|
753
|
+
const out = [];
|
|
754
|
+
try {
|
|
755
|
+
const disc = discoverConfigPolicy(process.cwd());
|
|
756
|
+
if (disc?.policyPath) out.push([disc.policyPath, "the config policy key"]);
|
|
757
|
+
const cfgPath = discoverConfigPath(process.cwd());
|
|
758
|
+
if (cfgPath) out.push([cfgPath, 'the discovered .candor/config']);
|
|
759
|
+
} catch { /* lenient: the real load refuses on its own terms */ }
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Refuse a sink that names an input of this run, having written nothing. */
|
|
764
|
+
function refuseGateJsonOverInput(gate, other, flag) {
|
|
765
|
+
if (!sameArtifactPath(gate, other)) return;
|
|
766
|
+
console.error(`candor-ts-query: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
|
|
767
|
+
+ `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy and `
|
|
768
|
+
+ `then gate on the wreckage. Nothing was written; give the verdict its own path.`);
|
|
769
|
+
process.exit(2);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** `.candor/config` is never a verdict sink, wherever it is. */
|
|
773
|
+
function refuseGateJsonAtConfig(gate) {
|
|
774
|
+
if (!gate || gate === "-") return;
|
|
775
|
+
const abs = path.resolve(gate);
|
|
776
|
+
if (path.basename(abs) !== "config" || path.basename(path.dirname(abs)) !== ".candor") return;
|
|
777
|
+
console.error(`candor-ts-query: --gate-json ${gate} is a .candor/config — refusing (exit 2). This would `
|
|
778
|
+
+ `destroy the config that configures this run. Nothing was written; give the verdict its own path.`);
|
|
779
|
+
process.exit(2);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Write the fail-closed refusal every later exit inherits unless a real verdict replaces it. */
|
|
783
|
+
// ⟨0.28⟩ RESOLVE THE SINK TO ITS FINAL ARTIFACT BEFORE WRITING, and preserve the operator's layout.
|
|
784
|
+
// `renameSync` REPLACES a symlink rather than following it, so an `artifacts/verdict.json` linked into a
|
|
785
|
+
// shared directory kept a previous run's `{"ok": true}` while this run's document landed on the link — a
|
|
786
|
+
// stale green with a single `--gate-json` and no operator mistake. And rename gives the destination a NEW
|
|
787
|
+
// inode, so a multiply-linked target strands its other name with the previous document; there the write
|
|
788
|
+
// goes in place, trading the atomicity window for not publishing a stale verdict at a name the operator
|
|
789
|
+
// wired up. SPEC §3.3.1 states identity about ARTIFACTS; this family had it in the comparison only.
|
|
790
|
+
function resolveSinkArtifact(p) {
|
|
791
|
+
let cur = p;
|
|
792
|
+
for (let i = 0; i < 32; i++) {
|
|
793
|
+
let st;
|
|
794
|
+
try { st = fs.lstatSync(cur); } catch { return cur; }
|
|
795
|
+
if (!st.isSymbolicLink()) return cur;
|
|
796
|
+
let t;
|
|
797
|
+
try { t = fs.readlinkSync(cur); } catch { return cur; }
|
|
798
|
+
cur = path.isAbsolute(t) ? t : path.join(path.dirname(cur), t);
|
|
799
|
+
}
|
|
800
|
+
return cur;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function writeSinkAtomic(p, text) {
|
|
804
|
+
const target = resolveSinkArtifact(p);
|
|
805
|
+
try {
|
|
806
|
+
if (fs.statSync(target).nlink > 1) { fs.writeFileSync(target, text); return; }
|
|
807
|
+
} catch { /* not there yet — the ordinary temp+rename path is right */ }
|
|
808
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
809
|
+
fs.writeFileSync(tmp, text);
|
|
810
|
+
fs.renameSync(tmp, target);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function armQueryGateJson(p) {
|
|
814
|
+
try {
|
|
815
|
+
writeSinkAtomic(p, JSON.stringify(
|
|
816
|
+
refusalVerdict(SPEC_VERSION, "the gate did not complete — this document was written when the run "
|
|
817
|
+
+ "STARTED and was never replaced by a verdict, so the run failed, crashed or was killed before "
|
|
818
|
+
+ "it could decide. It is NOT a verdict about the code; see the run's stderr for the cause."),
|
|
819
|
+
null, 1) + "\n");
|
|
820
|
+
} catch (e) {
|
|
821
|
+
console.error(`candor-ts-query: could not arm --gate-json ${p} fail-closed (${e.message})`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
440
825
|
function resolveGateReportVerb(rawArgs) {
|
|
441
826
|
const usageLine = "usage: candor-ts-query gate --report <locator> --policy <file> [--json] [--gate-json <file>]";
|
|
442
827
|
let reportLocator = null, policyFile = null, gateJsonPath = null, json = false;
|
|
828
|
+
// SPEC §3.3.1 ⟨0.27⟩ — ARM FIRST, AND NEVER OVER AN INPUT. A pre-pass with no side effects, so both
|
|
829
|
+
// the collision refusal and the arming precede every exit in the loop below. See the note where the
|
|
830
|
+
// arming used to live for why the previous ordering was wrong.
|
|
831
|
+
{
|
|
832
|
+
const { gate, policy, report } = preScanGateArgs(rawArgs);
|
|
833
|
+
if (gate) {
|
|
834
|
+
// THE STREAM HOOK IS INSTALLED FIRST, before anything that can exit. It WRITES NOTHING until the
|
|
835
|
+
// process exits, so unlike the file arming below it cannot land on an input and has no reason to
|
|
836
|
+
// wait behind the collision checks.
|
|
837
|
+
//
|
|
838
|
+
// It used to be installed after them, and `configDeclaredInputs()` — one of those checks — reads
|
|
839
|
+
// the config, which exits 2 through a shared helper when the config is unreadable. So the earliest
|
|
840
|
+
// exit-2 cause there is left stdout EMPTY on this route while java and swift wrote the refusal.
|
|
841
|
+
// Found by the gate-verb cells in candor/bin/probe-causes.sh; candor-scan had the same gap through
|
|
842
|
+
// the same shared loader, one language across.
|
|
843
|
+
if (gate === "-") {
|
|
844
|
+
process.on("exit", (code) => {
|
|
845
|
+
if (code === 2 && !globalThis.__candorGateVerdictWritten) {
|
|
846
|
+
console.log(JSON.stringify(refusalVerdict(SPEC_VERSION,
|
|
847
|
+
"the gate did not complete — this run exited before a verdict could be produced", null), null, 1));
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
refuseGateJsonOverInput(gate, policy, "--policy");
|
|
852
|
+
// §3.3.1 names "a report being read (`gate --report`)" as an input. Writing the verdict there
|
|
853
|
+
// destroys the very report the gate was asked to judge, and the diagnostic then blames the report
|
|
854
|
+
// rather than the collision.
|
|
855
|
+
refuseGateJsonOverInput(gate, report, "--report");
|
|
856
|
+
// ⟨0.28⟩ …AND THE FILES THE LOCATOR EXPANDS TO, because the raw flag value above is not what the
|
|
857
|
+
// gate READS: a locator is a prefix/dir (or a discovery, when absent), and `loadGateReport` reads
|
|
858
|
+
// its expansion — so a sink naming one of the expanded reports, or one of their §2.2 sidecars,
|
|
859
|
+
// named an input the token comparison could not see. Enumerated by the loader-adjacent
|
|
860
|
+
// `gateReportInputFiles` (query-core.mjs); the measurement lives there.
|
|
861
|
+
const expandedInputs = gateReportInputFiles(report ? locatorToPrefix(report) : discoverReportPrefix());
|
|
862
|
+
for (const f of expandedInputs) refuseGateJsonOverInput(gate, f, "a file this gate reads —");
|
|
863
|
+
refuseGateJsonOverInput(gate, process.env.CANDOR_POLICY, "CANDOR_POLICY");
|
|
864
|
+
// THE CONFIG-DECLARED POLICY. This verb's policy ladder falls back to the \`policy\` key of the
|
|
865
|
+
// config discovered from the CWD, and the guard checked only the flags — so the checked-in form,
|
|
866
|
+
// which is the one a CI job has, was destroyed at exit 0 while the flag form refused. The same
|
|
867
|
+
// hole the scan route closed, one route across.
|
|
868
|
+
for (const [p2, label] of configDeclaredInputs()) refuseGateJsonOverInput(gate, p2, label);
|
|
869
|
+
refuseGateJsonAtConfig(gate);
|
|
870
|
+
// ⟨0.28⟩ THE RUNG BINDS EVERY ROUTE. It shipped on scan.mjs only, so this verb kept last-wins and a
|
|
871
|
+
// gate that FIRED left the first named sink holding a previous run's `{"ok": true}`. Every named
|
|
872
|
+
// sink also gets the input checks, and the input exemption covers that PATH, not the run — so the
|
|
873
|
+
// other sinks still receive the refusal.
|
|
874
|
+
const namedSinks = [];
|
|
875
|
+
for (let k = 0; k < rawArgs.length; k++) {
|
|
876
|
+
if (rawArgs[k] !== "--gate-json") continue;
|
|
877
|
+
const v = rawArgs[k + 1];
|
|
878
|
+
if (v === undefined || (v !== "-" && v.startsWith("-"))) continue;
|
|
879
|
+
if (!namedSinks.some((x) => x === v || (x !== "-" && v !== "-" && sameArtifactPath(x, v)))) namedSinks.push(v);
|
|
880
|
+
k++;
|
|
881
|
+
}
|
|
882
|
+
for (const g of namedSinks) {
|
|
883
|
+
refuseGateJsonOverInput(g, policy, "--policy");
|
|
884
|
+
refuseGateJsonOverInput(g, report, "--report");
|
|
885
|
+
// ⟨0.28⟩ the expanded report set (and its sidecars), exactly as the single-sink path asks it
|
|
886
|
+
// above — a duplicate must not smuggle an expanded input past the guard.
|
|
887
|
+
for (const f of expandedInputs) refuseGateJsonOverInput(g, f, "a file this gate reads —");
|
|
888
|
+
refuseGateJsonOverInput(g, process.env.CANDOR_POLICY, "CANDOR_POLICY");
|
|
889
|
+
refuseGateJsonOverInput(g, process.env.CANDOR_CONFIG, "CANDOR_CONFIG");
|
|
890
|
+
for (const [p2, label] of configDeclaredInputs()) refuseGateJsonOverInput(g, p2, label);
|
|
891
|
+
refuseGateJsonAtConfig(g);
|
|
892
|
+
}
|
|
893
|
+
if (namedSinks.length > 1) {
|
|
894
|
+
const list = namedSinks.join(", ");
|
|
895
|
+
console.error(`candor-ts-query: --gate-json given more than once (${list}) — refusing (exit 2). A `
|
|
896
|
+
+ `gate publishes ONE verdict. Naming two sinks says where it goes twice, and the reader of the `
|
|
897
|
+
+ `path that loses cannot tell it lost. Name one, or run the gate twice.`);
|
|
898
|
+
const doc = JSON.stringify(refusalVerdict(SPEC_VERSION,
|
|
899
|
+
`--gate-json was given more than once (${list}) — a run publishes one verdict to one sink`, null), null, 1);
|
|
900
|
+
for (const g of namedSinks) {
|
|
901
|
+
if (g === "-") { globalThis.__candorGateVerdictWritten = true; console.log(doc); continue; }
|
|
902
|
+
try { writeSinkAtomic(g, doc + "\n"); }
|
|
903
|
+
catch (e) { console.error(`candor-ts-query: could not write the refusal to --gate-json ${g} (${e.message})`); }
|
|
904
|
+
}
|
|
905
|
+
process.exit(2);
|
|
906
|
+
}
|
|
907
|
+
if (gate !== "-") armQueryGateJson(gate);
|
|
908
|
+
// …AND THE STREAM'S ANALOG OF ARMING — now installed at the top of this block, see the note there. `armQueryGateJson` writes a fail-closed placeholder to a
|
|
909
|
+
// FILE; a stream cannot hold one, so the equivalent is a hook that emits the refusal on any
|
|
910
|
+
// exit-2 path that has not already written a verdict.
|
|
911
|
+
//
|
|
912
|
+
// Without it, this verb exited 2 during ARGUMENT PARSING with stdout EMPTY, while the same verb
|
|
913
|
+
// refusing later from inside the gate streamed the document — the same operator mistake, two
|
|
914
|
+
// answers, decided by how early it was caught. A machine consumer reading an empty stream after
|
|
915
|
+
// exit 2 cannot tell it from a clean gate.
|
|
916
|
+
//
|
|
917
|
+
// Measured at the 0.27 go/no-go: java, swift and ts all had this hole on the `gate` verb, and
|
|
918
|
+
// PART 36's stream rows never reached it because every one of them runs the SCAN route. The row
|
|
919
|
+
// that catches it is now there.
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// SPEC §3.2 ⟨0.28⟩: "given no value" MEANS the next token is flag-shaped — consuming it as a filename
|
|
923
|
+
// made this diagnostic unreachable and reinterpreted the command line: `--policy --gate-json -` read
|
|
924
|
+
// *policy = the file named `--gate-json`* and diagnosed the displaced `-` as an "unexpected argument".
|
|
925
|
+
// The sink the operator named is STILL a sink: the pre-pass above leaves a flag-shaped token live, so
|
|
926
|
+
// `--gate-json -` after the broken flag installed the stream hook (and a file sink was armed
|
|
927
|
+
// fail-closed) BEFORE this refusal fires — the exits below inherit that, like every other exit-2 in
|
|
928
|
+
// this loop. A bare `-` stays a value; `./--weird` spells a file genuinely named like a flag.
|
|
929
|
+
const flagShapedValue = (v) => v !== undefined && v !== "-" && v.startsWith("-") && v.length > 1;
|
|
443
930
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
444
931
|
const a = rawArgs[i];
|
|
445
932
|
if (a === "--report") {
|
|
933
|
+
if (flagShapedValue(rawArgs[i + 1])) { console.error(`candor-ts: --report was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a locator (a path really named that is spelled ./${rawArgs[i + 1]})\n ${usageLine}`); process.exit(2); }
|
|
446
934
|
if (i + 1 >= rawArgs.length) { console.error(`candor-ts: --report requires a <locator> value (a directory, a .json report path, or a prefix)\n ${usageLine}`); process.exit(2); }
|
|
447
935
|
reportLocator = rawArgs[++i]; continue;
|
|
448
936
|
}
|
|
449
937
|
if (a === "--policy") {
|
|
938
|
+
if (flagShapedValue(rawArgs[i + 1])) { console.error(`candor-ts: --policy was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a path (a file really named that is spelled ./${rawArgs[i + 1]})\n ${usageLine}`); process.exit(2); }
|
|
450
939
|
if (i + 1 >= rawArgs.length) { console.error(`candor-ts: --policy requires a <file> value\n ${usageLine}`); process.exit(2); }
|
|
451
940
|
policyFile = rawArgs[++i]; continue;
|
|
452
941
|
}
|
|
@@ -469,8 +958,18 @@ function resolveGateReportVerb(rawArgs) {
|
|
|
469
958
|
console.error(`candor-ts-query gate: unexpected argument '${a}' — \`gate\` takes no positionals; the report is a --report locator and the policy a --policy file\n ${usageLine}`);
|
|
470
959
|
process.exit(2);
|
|
471
960
|
}
|
|
961
|
+
// ARMING MOVED ABOVE THE FLAG LOOP (SPEC §3.3.1 ⟨0.27⟩).
|
|
962
|
+
//
|
|
963
|
+
// It used to sit here, and the comment justified it with a ⟨0.24⟩ ruling of my own: "a USAGE error was
|
|
964
|
+
// never a gate invocation, so it must write NOTHING". SPEC §3.3 says the opposite in terms — it names
|
|
965
|
+
// an unknown flag as a broken-gate-config exit-2 cause, and §3.1 adds that "if `--gate-json` was
|
|
966
|
+
// requested and the run exits 2 for ANY reason, a fail-closed document is written", calling a
|
|
967
|
+
// carve-out "a fail-open path with a reason attached". The ruling I built here was that carve-out, and
|
|
968
|
+
// the test pinning it pinned a reading the spec had already superseded. The stale green does not care
|
|
969
|
+
// that the operator's shell also failed.
|
|
970
|
+
const _policy = resolvePolicy(policyFile, null).policyFile;
|
|
472
971
|
const prefix = requireReport(reportLocator !== null ? locatorToPrefix(reportLocator) : discoverReportPrefix());
|
|
473
|
-
return { prefix, policyFile:
|
|
972
|
+
return { prefix, policyFile: _policy, gateJsonPath, json };
|
|
474
973
|
}
|
|
475
974
|
|
|
476
975
|
/**
|
|
@@ -678,7 +1177,13 @@ switch (cmd) {
|
|
|
678
1177
|
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
|
|
679
1178
|
// `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
|
|
680
1179
|
if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
|
|
681
|
-
|
|
1180
|
+
// ⟨0.28⟩ RUNG A — `show`'s pinned shape is a TOP-LEVEL ARRAY, so over a hedging report it emits the
|
|
1181
|
+
// CAVEAT DOCUMENT INSTEAD (see `putCaveatInstead`). Before this it took plain `put` and had no
|
|
1182
|
+
// completeness reader at all: measured, `[]` at exit 0 over a report whose own manifest names a file
|
|
1183
|
+
// candor could not read — the only verb of the six with no caveat on EITHER channel.
|
|
1184
|
+
putCaveatInstead(args, coreShow(loadReportOrDie(prefix), q), P.show, reportCompleteness(prefix),
|
|
1185
|
+
`the function(s) shown below are only those candor could SEE match \`${q}\``,
|
|
1186
|
+
"A function in an unread unit is ABSENT from the report, so it cannot be shown here at all. Re-scan for a complete answer.");
|
|
682
1187
|
break;
|
|
683
1188
|
}
|
|
684
1189
|
case "where": {
|
|
@@ -697,7 +1202,11 @@ switch (cmd) {
|
|
|
697
1202
|
if (!KNOWN_EFFECTS.includes(eff) && !new Set(fnsW.flatMap((e) => e.inferred || [])).has(eff)) {
|
|
698
1203
|
console.error(`candor-ts-query where: unknown effect '${eff}' (known: ${KNOWN_EFFECTS.join(", ")})`); process.exit(2);
|
|
699
1204
|
}
|
|
700
|
-
|
|
1205
|
+
// ⟨0.28⟩ SPEC §2 — `{"directly":[],"inherited":[]}` is one of the four empty answers the clause names
|
|
1206
|
+
// by measurement. The caveat rides the SAME document (see `putAnswer`); the exit code does not move.
|
|
1207
|
+
putAnswer(args, coreWhere(fnsW, eff), P.where, reportCompleteness(prefix),
|
|
1208
|
+
`the function(s) named below are only those candor could SEE perform ${eff}`,
|
|
1209
|
+
`A function in an unread unit is ABSENT from the report, so it cannot appear in either list. Re-scan for a complete answer.`);
|
|
701
1210
|
break;
|
|
702
1211
|
}
|
|
703
1212
|
case "callers": {
|
|
@@ -708,12 +1217,64 @@ switch (cmd) {
|
|
|
708
1217
|
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an empty
|
|
709
1218
|
// {of:[],direct:[],transitive:[]} at exit 0 (reads as "nothing reaches it" for a fn never named).
|
|
710
1219
|
if (!q) { console.error("usage: candor-ts-query callers <query> [--include-unknown] [--report <locator>] [--json]"); process.exit(2); }
|
|
1220
|
+
// ⟨0.28⟩ PREFER THE §2.2 SIDECAR, FALL BACK TO THE REPORT'S OWN `calls` EDGES — rust's exact split
|
|
1221
|
+
// (callers.rs). The sidecar records EVERY function including pure ones, so it is the COMPLETE graph;
|
|
1222
|
+
// the report's embedded edges are effect-relevant only, but a report is a §3.3.1 locator in its own
|
|
1223
|
+
// right (a single hand-copied report.json) and rust/java answer real callers over it at exit 0 while
|
|
1224
|
+
// this engine refused `unanswerable` exit 2 — a one-engine divergence that broke a cross-engine
|
|
1225
|
+
// script. The fail-closed arm below now fires only when the graph is GENUINELY absent (no sidecar
|
|
1226
|
+
// AND no embedded edges — the armed pair), which narrows when it fires without removing it.
|
|
711
1227
|
const cg = loadCallgraph(prefix);
|
|
712
|
-
const
|
|
1228
|
+
const completeGraph = Object.keys(cg).length > 0;
|
|
1229
|
+
const graph = completeGraph ? cg : reportCallsGraph(loadReportOrDie(prefix));
|
|
1230
|
+
const cres = includeUnknown ? callersFrontier(graph, loadReportOrDie(prefix), loadHierarchy(prefix), q) : coreCallers(graph, q);
|
|
713
1231
|
// A nonexistent function is a LOUD error (exit 2), like path/impact — never an empty {of:[],direct:[],
|
|
714
1232
|
// transitive:[]} at exit 0, which reads as an authoritative "nothing calls it" for a fn that doesn't exist
|
|
715
1233
|
// (corpus-audit #3). Gated on a NON-empty callgraph so a missing sidecar isn't misreported as "no such fn".
|
|
716
|
-
if (
|
|
1234
|
+
if (cres.of.length === 0) {
|
|
1235
|
+
// ⟨0.28⟩ UNANSWERABLE MUST REACH THE MACHINE CHANNEL (SPEC §3.3.1). This branch printed
|
|
1236
|
+
// `{"of":[],"direct":[],"transitive":[]}` at exit 0 while the human arm said "no function in the
|
|
1237
|
+
// call graph matches that name" — and BOTH readings are a determined negative, which is worse than
|
|
1238
|
+
// the reference engines' split: rust/java's human arm at least said "no call graph in the report".
|
|
1239
|
+
// A consumer reading `direct`, or defaulting it (the fail-open idiom ⟨0.24⟩ names on every key in
|
|
1240
|
+
// this format), is told NOBODY CALLS this function: a blast radius of "safe to edit" over a pair
|
|
1241
|
+
// whose honest answer is "this run judged nothing". The ⟨0.28⟩ sidecar rule (scan.mjs, which
|
|
1242
|
+
// deletes the §2.2 sidecars with an armed report) did not dig this hole — an absent sidecar has
|
|
1243
|
+
// always answered this way — it aimed traffic at it by making no-sidecar the STANDARD state after
|
|
1244
|
+
// a failed run.
|
|
1245
|
+
//
|
|
1246
|
+
// BOTH CHANNELS FAIL CLOSED: the document names itself unanswerable AND the exit is non-zero.
|
|
1247
|
+
// §3.3.1 permits either, but each ALONE leaves a naive reader exposed — the key alone still lets
|
|
1248
|
+
// `d.direct ?? []` read as a determined negative, and the exit alone leaves a JSON consumer
|
|
1249
|
+
// holding an empty document. Same shape as rust `358e117` / java `927252c`.
|
|
1250
|
+
//
|
|
1251
|
+
// ONLY THIS ARM, and the control separation is the load-bearing half. An EMPTY graph means there
|
|
1252
|
+
// is no call graph AT ALL (no §2.2 sidecar, and ⟨0.24⟩ rules an empty/unparseable one identical to
|
|
1253
|
+
// an absent one), which is the unanswerable case. A function with genuinely no callers over a REAL
|
|
1254
|
+
// graph still answers `direct: []` at exit 0 below — a determined negative, and withdrawing it
|
|
1255
|
+
// would be the mirror defect (the ⟨0.24⟩ count-0 lesson) — and a name absent from a real graph
|
|
1256
|
+
// still exits 2 as "no function matching", because a graph WAS read there.
|
|
1257
|
+
//
|
|
1258
|
+
// ONE SITE, MEASURED NOT ASSUMED: the rust reference had this branch twice (callers_via_callgraph
|
|
1259
|
+
// + the frontier variant) and warned to grep for both; candor-ts folds `--include-unknown` through
|
|
1260
|
+
// this same block via `callersFrontier`, so `grep -n 'coreCallers\|callersFrontier' query.mjs`
|
|
1261
|
+
// finds exactly one CALL site (plus the import). Verified through the flag as well: `--json
|
|
1262
|
+
// --include-unknown` discloses too. The MCP surface was measured, not assumed, and already fails
|
|
1263
|
+
// closed — `candor_callers` over an armed pair returns `isError: true` from mcp.mjs's fn-existence
|
|
1264
|
+
// guard, which unions the callgraph keys with the report's fns, both empty here.
|
|
1265
|
+
if (Object.keys(graph).length === 0) {
|
|
1266
|
+
const why = "no call graph in the report — the §2.2 sidecar is absent, so who calls this function is UNANSWERABLE, not empty (SPEC §3.3.1 ⟨0.28⟩)";
|
|
1267
|
+
put(args, { of: [q], unanswerable: why }, () => console.log(`candor: ${why}`));
|
|
1268
|
+
process.exit(2);
|
|
1269
|
+
}
|
|
1270
|
+
// ⟨0.28⟩ Only the COMPLETE graph (the sidecar) can prove a name absent. Over the effect-only
|
|
1271
|
+
// fallback a miss is INCONCLUSIVE — a pure leaf called only by pure fns is simply invisible there —
|
|
1272
|
+
// so the answer is empty at exit 0, never a fabricated "no such function" (rust corpus-audit #5,
|
|
1273
|
+
// byte-matching its two arms: `{}` on the machine channel, the re-scan pointer on the human one).
|
|
1274
|
+
if (!completeGraph) {
|
|
1275
|
+
put(args, {}, () => console.log(`candor: no caller of \`${q}\` in the effect-relevant graph (the full call-graph sidecar is absent; re-scan with --out to see pure-only callers).`));
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
717
1278
|
console.error(`candor-ts-query callers: no function matching '${q}' in the call graph`); process.exit(2);
|
|
718
1279
|
}
|
|
719
1280
|
put(args, cres, P.callers);
|
|
@@ -722,7 +1283,13 @@ switch (cmd) {
|
|
|
722
1283
|
case "map": {
|
|
723
1284
|
// Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
|
|
724
1285
|
const { prefix } = resolveReportVerb(args, 0);
|
|
725
|
-
|
|
1286
|
+
// ⟨0.28⟩ `map` answers `{}`, which SPEC §2 calls the STRONGEST determined negative there is: every key
|
|
1287
|
+
// a consumer reads defaults to empty, so `d["db"] ?? {}` cannot tell an empty map from an unexamined
|
|
1288
|
+
// one. RUNG A: its top level is a USER NAMESPACE, so the caveat REPLACES the module map rather than
|
|
1289
|
+
// merging into it — the merged shape displaced a real module row to make space for the hedge.
|
|
1290
|
+
putCaveatInstead(args, coreMap(loadReportOrDie(prefix)), P.map, reportCompleteness(prefix),
|
|
1291
|
+
"the module rows below cover only the source candor read",
|
|
1292
|
+
"A module living wholly in an unread unit is MISSING from this overview, and one that IS listed may be missing functions. Re-scan for a complete map.");
|
|
726
1293
|
break;
|
|
727
1294
|
}
|
|
728
1295
|
case "containment": {
|
|
@@ -752,10 +1319,20 @@ switch (cmd) {
|
|
|
752
1319
|
process.exit(2);
|
|
753
1320
|
}
|
|
754
1321
|
const r = coreContainment(loadReportOrDie(prefix), baseFns);
|
|
755
|
-
|
|
1322
|
+
// ⟨0.28⟩ BOTH SIDES' MANIFESTS. This answer is a DIFFERENCE, so it is unsound if either side is
|
|
1323
|
+
// partial, and in OPPOSITE directions: a leak living in an unread file of the CURRENT tree is missed
|
|
1324
|
+
// (a false all-clear at exit 0), while one living in an unread file of the BASELINE reads as newly
|
|
1325
|
+
// appeared (a fabricated leak, at exit 1). A wholly-empty baseline already fails closed above; a
|
|
1326
|
+
// baseline that loaded but declares `unanalyzed` is the case that reached here silently.
|
|
1327
|
+
putAnswer(args, r, P.containment,
|
|
1328
|
+
absorbCompleteness(reportCompleteness(prefix), reportCompleteness(basePrefix)),
|
|
1329
|
+
"the leak set below is a difference over only the code candor read on BOTH sides",
|
|
1330
|
+
"An unread unit of the current tree hides a leak; an unread unit of the baseline manufactures one. Re-scan both before moving the ratchet.");
|
|
756
1331
|
process.exit(r.leaks.length ? 1 : 0);
|
|
757
1332
|
}
|
|
758
|
-
|
|
1333
|
+
putAnswer(args, coreContainment(loadReportOrDie(prefix)), P.containment, reportCompleteness(prefix),
|
|
1334
|
+
"the containment scores below cover only the boundary effects candor could see",
|
|
1335
|
+
"A boundary effect in an unread unit is in no layer's count, so a dispersed effect can score as contained. Re-scan for a complete picture.");
|
|
759
1336
|
break;
|
|
760
1337
|
}
|
|
761
1338
|
case "diff": {
|
|
@@ -800,9 +1377,13 @@ switch (cmd) {
|
|
|
800
1377
|
const roots = fns.filter((e) => e.entryPoint);
|
|
801
1378
|
const byEff = {};
|
|
802
1379
|
for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
|
|
803
|
-
|
|
1380
|
+
// ⟨0.28⟩ `{"entryPoints":0,"effects":{}}` asserts *the program performs no effect at runtime* — the
|
|
1381
|
+
// strongest claim in this binary — and over a report that judged nothing it rests on no evidence.
|
|
1382
|
+
putAnswer(args, { entryPoints: roots.length,
|
|
804
1383
|
effects: Object.fromEntries(Object.entries(byEff).sort()
|
|
805
|
-
.map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable)
|
|
1384
|
+
.map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable, reportCompleteness(prefix),
|
|
1385
|
+
"the runtime effect set below is a union over only the entry points candor could see",
|
|
1386
|
+
"An entry point in an unread unit contributes NOTHING to this union, and neither does any effect it reaches. Re-scan before treating this as the program's runtime surface.");
|
|
806
1387
|
break;
|
|
807
1388
|
}
|
|
808
1389
|
case "impact": {
|
|
@@ -812,7 +1393,60 @@ switch (cmd) {
|
|
|
812
1393
|
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an
|
|
813
1394
|
// affectedCount:0 blast radius at exit 0 for a function that was never named.
|
|
814
1395
|
if (!q) { console.error("usage: candor-ts-query impact <query> [--report <locator>] [--json]"); process.exit(2); }
|
|
815
|
-
|
|
1396
|
+
const impFns = loadReportOrDie(prefix);
|
|
1397
|
+
// ⟨0.28⟩ Sidecar first, then the report's embedded `calls` edges — rust's `impact` runs on the
|
|
1398
|
+
// report's edges ALONE (callers.rs cmd_impact), so a sidecar-less report must answer here too; see
|
|
1399
|
+
// the callers verb. The unanswerable arm below keeps the genuinely-absent case (the armed pair).
|
|
1400
|
+
let impCg = loadCallgraph(prefix);
|
|
1401
|
+
if (Object.keys(impCg).length === 0) impCg = reportCallsGraph(impFns);
|
|
1402
|
+
// ⟨0.28⟩ UNANSWERABLE MUST REACH THE MACHINE CHANNEL (SPEC §3.3.1), the `callers` fix (5091905) on
|
|
1403
|
+
// the verb its own commit message named as still open. Over an armed pair — report armed to the
|
|
1404
|
+
// ⟨0.21⟩ Row-1 empty, §2.2 sidecars deleted, the STANDARD post-failure state since the ⟨0.28⟩
|
|
1405
|
+
// sidecar rung — this printed `{"fn":…,"affectedCount":0,"affected":[],"entryPoints":[]}` at exit 0.
|
|
1406
|
+
// `affectedCount: 0` is the blast-radius verb's strongest claim: NOTHING CALLS THIS, SAFE TO CHANGE.
|
|
1407
|
+
// With no call graph the engine has not judged that; it has judged nothing. rust/java exit 2 here.
|
|
1408
|
+
//
|
|
1409
|
+
// BOTH CHANNELS FAIL CLOSED (the exit alone leaves a JSON consumer holding a document; the key alone
|
|
1410
|
+
// lets `d.affectedCount ?? 0` read as a determined negative), and the empty ANSWER keys are OMITTED
|
|
1411
|
+
// rather than zeroed, so there is nothing left for a defaulting reader to mistake for a finding.
|
|
1412
|
+
//
|
|
1413
|
+
// THE GATE IS THE EMPTY GRAPH, NOT THE EMPTY ANSWER, and that separation is the load-bearing half.
|
|
1414
|
+
// `impact` resolves its target over the CALLGRAPH keys, so an absent graph makes every answer a
|
|
1415
|
+
// vacuous 0 — that is the unanswerable case. A function that genuinely affects nothing over a REAL
|
|
1416
|
+
// graph still answers `affectedCount: 0` at exit 0 below: a determined negative, and withdrawing it
|
|
1417
|
+
// would be the mirror defect (the ⟨0.24⟩ count-0 lesson, where the plausible fix withdrew 104 real
|
|
1418
|
+
// claims to catch 6). The MCP `candor_impact` was measured, not assumed, and already fails closed —
|
|
1419
|
+
// its fn-existence guard unions the callgraph keys with the report's, both empty here.
|
|
1420
|
+
if (Object.keys(impCg).length === 0) {
|
|
1421
|
+
const why = "no call graph in the report — the §2.2 sidecar is absent, so what this function affects is UNANSWERABLE, not empty (SPEC §3.3.1 ⟨0.28⟩)";
|
|
1422
|
+
put(args, { fn: q, unanswerable: why }, () => console.log(`candor: ${why}`));
|
|
1423
|
+
process.exit(2);
|
|
1424
|
+
}
|
|
1425
|
+
// BAD TARGET → LOUD exit 2 (corpus-audit #3), the rule `callers` applies one verb up and the rule
|
|
1426
|
+
// rust/java ALREADY apply here — measured four-way, array-quoted, on a valid report: rust and java
|
|
1427
|
+
// both exit 2 with `impact: no function matching '…'`, and candor-ts was the ONE arm that printed
|
|
1428
|
+
// `{"fn":"zzz_no_such_fn","affectedCount":0,"affected":[],"entryPoints":[]}` at exit 0. On the
|
|
1429
|
+
// BLAST-RADIUS verb, `affectedCount: 0` is the strongest claim in the vocabulary — "nothing calls
|
|
1430
|
+
// this, safe to change" — and here it was asserted about a function that does not exist. A typo in a
|
|
1431
|
+
// CI script or an agent's query reads back as reassurance (§4); the truth is the question was never
|
|
1432
|
+
// posed. This is the same shape conformance §17 (1b) already pins for `where`/`callers`, on the two
|
|
1433
|
+
// verbs whose comment there wrongly assumed "path/impact already gate".
|
|
1434
|
+
//
|
|
1435
|
+
// A DIFFERENT CONDITION FROM THE UNANSWERABLE GATE ABOVE, and keeping them distinguishable is the
|
|
1436
|
+
// load-bearing half. That one is "there is no call graph" — nothing was judged, so the machine
|
|
1437
|
+
// channel carries an `unanswerable` key. This one is "the graph is fine and the NAME does not
|
|
1438
|
+
// resolve" — a USAGE error, reported the way `where`/`callers`/`show` report one: stderr, and NO
|
|
1439
|
+
// `unanswerable` key, because the run judged plenty. Hence the ORDER: unanswerable first, so an
|
|
1440
|
+
// absent sidecar is never misreported as a bad name (a wrong cause reads as an answer, 5091905).
|
|
1441
|
+
//
|
|
1442
|
+
// NOT THE OTHER DIRECTION: a REAL fn that genuinely affects nothing still answers `affectedCount: 0`
|
|
1443
|
+
// at exit 0 below. "No such function" and "that function affects nothing" must not collapse into one
|
|
1444
|
+
// another — withdrawing the determined negative to catch the fabricated one is the ⟨0.24⟩ count-0
|
|
1445
|
+
// mirror defect, where the plausible fix withdrew 104 real claims to catch 6.
|
|
1446
|
+
if (coreMatches(knownFnNames(impCg, impFns), q).length === 0) {
|
|
1447
|
+
console.error(`candor-ts-query impact: no function matching '${q}'`); process.exit(2);
|
|
1448
|
+
}
|
|
1449
|
+
put(args, coreImpact(impFns, impCg, q), P.impact);
|
|
816
1450
|
break;
|
|
817
1451
|
}
|
|
818
1452
|
case "blindspots": {
|
|
@@ -821,10 +1455,18 @@ switch (cmd) {
|
|
|
821
1455
|
const { prefix } = resolveReportVerb(args, 0);
|
|
822
1456
|
const ci = args.indexOf("--class");
|
|
823
1457
|
const classFilter = ci >= 0 ? args[ci + 1] : null; // ⟨0.20⟩ drill-down by reason class
|
|
1458
|
+
// ⟨0.28⟩ THE SHARPEST OF THE SIX: `{"sources":[],"totalUnknown":0}` reports *no blind spots* out of a
|
|
1459
|
+
// report whose own manifest names a file candor could not read — the unread file is the blind spot, and
|
|
1460
|
+
// it contributes no entry, so nothing in the computation below can see it. BOTH forms, because
|
|
1461
|
+
// `--stats` is the same claim counted differently (the sibling-route habit: a rule applied where the
|
|
1462
|
+
// work is and never to the arm one line down).
|
|
1463
|
+
const bsComp = reportCompleteness(prefix);
|
|
1464
|
+
const bsSoWhat = "the Unknown sources below are only those rooted in a call candor could see";
|
|
1465
|
+
const bsTail = "An unread unit contributes no entry at all, so its own Unknowns are not counted here and cannot be. Re-scan before treating this as the blind-spot inventory.";
|
|
824
1466
|
if (args.includes("--stats")) { // ⟨0.20⟩ the reason-class distribution, not the source list
|
|
825
|
-
|
|
1467
|
+
putAnswer(args, coreBlindspotsStats(loadReportOrDie(prefix), classFilter), P.blindspotsStats, bsComp, bsSoWhat, bsTail);
|
|
826
1468
|
} else {
|
|
827
|
-
|
|
1469
|
+
putAnswer(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix), classFilter), P.blindspots, bsComp, bsSoWhat, bsTail);
|
|
828
1470
|
}
|
|
829
1471
|
break;
|
|
830
1472
|
}
|
|
@@ -880,6 +1522,9 @@ switch (cmd) {
|
|
|
880
1522
|
// The header names the report's §2 envelope `package` — meaningful and locator-independent, so every
|
|
881
1523
|
// engine and every --report form print the SAME crate. Falls back to the prefix basename.
|
|
882
1524
|
const crateName = reportPackage(prefix) ?? path.basename(prefix);
|
|
1525
|
+
// ⟨0.28⟩ read ONCE, before either channel branches, so the JSON half and the prose half cannot end up
|
|
1526
|
+
// triggered by two different readings of the same bytes (see `putAnswer`; tour renders its own output).
|
|
1527
|
+
const tourComp = reportCompleteness(prefix);
|
|
883
1528
|
if (wantJson) {
|
|
884
1529
|
// Pure JSON to STDOUT: {"reaches":[{effect,fn,hops,loc,score,source}, …]} — ALPHABETICAL keys, the
|
|
885
1530
|
// same order Rust+Swift emit (loc is the SOURCE's file:line, "" when absent).
|
|
@@ -893,9 +1538,17 @@ switch (cmd) {
|
|
|
893
1538
|
const teff = fns.filter((e) => (e.inferred ?? []).length > 0).length;
|
|
894
1539
|
const tunk = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
|
|
895
1540
|
if (teff > 0 && tunk * 3 >= teff) out.unknown = { count: tunk, total: teff };
|
|
896
|
-
|
|
1541
|
+
// ⟨0.28⟩ THE SAME ARGUMENT AS `unknown` ABOVE, ONE CAUSE OVER — and the ⅓ threshold cannot reach this
|
|
1542
|
+
// one. That field exists because a bare `{"reaches":[]}` read as clean to the agent loop over a
|
|
1543
|
+
// mostly-Unknown graph; a report that judged nothing, or that names a file it could not read, yields
|
|
1544
|
+
// the IDENTICAL empty array from strictly less evidence, and an unread unit contributes no entry, so
|
|
1545
|
+
// it moves neither `unknown` nor `total`. Spread last, `{}` on a complete report.
|
|
1546
|
+
console.log(JSON.stringify({ ...out, ...completenessFields(tourComp) }));
|
|
897
1547
|
break;
|
|
898
1548
|
}
|
|
1549
|
+
incompleteAnswerNote(tourComp,
|
|
1550
|
+
"the reaches below are ranked over only the call graph candor could see",
|
|
1551
|
+
"A surprising reach whose path runs through an unread unit is not ranked here at all, and cannot be. Re-scan for the full tour.");
|
|
899
1552
|
if (finds.length === 0) {
|
|
900
1553
|
// Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
|
|
901
1554
|
// answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine. BUT never
|
|
@@ -907,9 +1560,14 @@ switch (cmd) {
|
|
|
907
1560
|
if (teff > 0 && tunk * 3 >= teff) {
|
|
908
1561
|
console.log(
|
|
909
1562
|
`candor: no surprising reaches — but ${tunk} of ${teff} function(s) are Unknown `
|
|
910
|
-
+ `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots
|
|
911
|
-
+ `
|
|
1563
|
+
+ `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\` — `
|
|
1564
|
+
+ `the report records a reason for each.`,
|
|
912
1565
|
);
|
|
1566
|
+
} else if (mustHedge(tourComp)) {
|
|
1567
|
+
// "nothing hidden" is the single most reassuring sentence this binary prints, and over these bytes
|
|
1568
|
+
// it is the false all-clear in plain English. The ⅓-Unknown branch above cannot catch it, for the
|
|
1569
|
+
// reason given on the JSON arm.
|
|
1570
|
+
console.log(`candor: nothing hidden in what candor COULD SEE ${NOT_A("nothing is hidden")}.`);
|
|
913
1571
|
} else {
|
|
914
1572
|
console.log("candor: nothing hidden — every effect sits where its name says it should.");
|
|
915
1573
|
}
|
|
@@ -954,9 +1612,26 @@ switch (cmd) {
|
|
|
954
1612
|
// read as total), plus `coverageDelta` when the baseline names different blind packages. Both
|
|
955
1613
|
// OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
|
|
956
1614
|
// Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
|
|
1615
|
+
// ⟨0.28⟩ SPEC §2 — …AND THE ⟨0.21⟩ MANIFEST TRAVELS ON THE SAME TERMS, WHICH IS THE STRONGER CAVEAT.
|
|
1616
|
+
// The line above carries `coverage` because "no gains over an uncovered dep reads clean with false
|
|
1617
|
+
// confidence"; measured, this same call dropped `unanalyzed` — *I could not read a file of your own
|
|
1618
|
+
// code* — and `analyzed.count: 0` — *I judged nothing at all*. BOTH SIDES, disclosed separately
|
|
1619
|
+
// (`gainsCompletenessFields`): an incomplete CURRENT means the gained set may be SHORT, an incomplete
|
|
1620
|
+
// BASELINE means the comparison floor is soft and the existing/new split unreliable. Not `put`,
|
|
1621
|
+
// because ONE trigger must reach BOTH channels — the JSON-only half is the mutant that survived a
|
|
1622
|
+
// whole suite in candor-rust. Verdict-preserving: the exit below is untouched.
|
|
957
1623
|
const gainsResult = coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix));
|
|
958
|
-
|
|
959
|
-
|
|
1624
|
+
const gCur = reportCompleteness(curPrefix), gBase = reportCompleteness(basePrefix);
|
|
1625
|
+
const gDoc = { baseline_version: gbv ?? "", engine_version: gv ?? "",
|
|
1626
|
+
...gainsResult, ...gainsCoverage(curPrefix, basePrefix), ...gainsCompletenessFields(gCur, gBase) };
|
|
1627
|
+
if (wantJsonOut(args)) emit(gDoc);
|
|
1628
|
+
else {
|
|
1629
|
+
incompleteAnswerNote(gCur, "the gained set below names only effects candor read in the CURRENT tree and may be SHORT",
|
|
1630
|
+
"An effect introduced in an unread unit of the current tree is not in the list below.");
|
|
1631
|
+
incompleteAnswerNote(gBase, "the BASELINE half of this comparison is itself partial and the floor it sets is soft",
|
|
1632
|
+
"An effect living in an unread unit of the baseline reads as NEWLY gained here — the existing/new origin split is unreliable until the baseline is re-scanned.");
|
|
1633
|
+
P.gains(gDoc, mustHedge(gCur) || mustHedge(gBase));
|
|
1634
|
+
}
|
|
960
1635
|
// Advisory by default (exit 0 — gains is a diff view); `--strict` fails on ANY gained effect so a
|
|
961
1636
|
// supply-chain CI job can require a bump introduce no new capability (mirrors `unverified --strict`).
|
|
962
1637
|
process.exit(strict && (gainsResult.gained?.length ?? 0) > 0 ? 1 : 0);
|
|
@@ -973,7 +1648,54 @@ switch (cmd) {
|
|
|
973
1648
|
// "does not perform undefined" at exit 0 — a false all-clear over a question that was never posed.
|
|
974
1649
|
if (!fn || !eff) { console.error("usage: candor-ts-query path <fn> <Effect> [--report <locator>] [--json]"); process.exit(2); }
|
|
975
1650
|
const fns = loadReportOrDie(prefix);
|
|
976
|
-
|
|
1651
|
+
// ⟨0.28⟩ Sidecar first, then the report's embedded `calls` edges (rust's `path` runs on the report's
|
|
1652
|
+
// edges alone — see the callers verb). The unanswerable arm below keeps the genuinely-absent case.
|
|
1653
|
+
let cg = loadCallgraph(prefix);
|
|
1654
|
+
if (Object.keys(cg).length === 0) cg = reportCallsGraph(fns);
|
|
1655
|
+
// ⟨0.28⟩ UNANSWERABLE MUST REACH THE MACHINE CHANNEL (SPEC §3.3.1) — the `impact` argument above,
|
|
1656
|
+
// on the verb that answers the OTHER direction. Over an armed pair this emitted
|
|
1657
|
+
// `{"effect":…,"fn":…,"path":[]}` at exit 0: "there is no route by which this function reaches that
|
|
1658
|
+
// effect", which is precisely the reassurance a reader asks `path` for, over a run that traced
|
|
1659
|
+
// nothing. rust/java exit 2 here.
|
|
1660
|
+
//
|
|
1661
|
+
// BEFORE THE SPLIT, so BOTH arms fail closed, and the human arm is REPAIRED not merely forwarded —
|
|
1662
|
+
// it exited 2 already, but said "no function matching 'f'", a determined negative about the NAME
|
|
1663
|
+
// when the truth is about the GRAPH. (Its other two exits are worse: with a valid report and no
|
|
1664
|
+
// sidecar, `path` reached "does not perform Fs" / "not statically traceable" at exit 0.) Same
|
|
1665
|
+
// correction `callers` needed in 5091905, for the same reason: a wrong cause reads as an answer.
|
|
1666
|
+
//
|
|
1667
|
+
// THE GATE IS THE EMPTY GRAPH, NOT THE EMPTY PATH. `path` resolves its start over the CALLGRAPH
|
|
1668
|
+
// keys, so with no graph every answer is a vacuous `[]`. A function that genuinely does not reach
|
|
1669
|
+
// the effect over a REAL graph still answers `path: []` at exit 0 below — the graph SAID no, and
|
|
1670
|
+
// withdrawing that is the ⟨0.24⟩ count-0 mirror defect. "The graph says no" and "there is no graph"
|
|
1671
|
+
// must stay distinguishable. MCP `candor_path` was measured and already fails closed (`isError`).
|
|
1672
|
+
if (Object.keys(cg).length === 0) {
|
|
1673
|
+
const why = "no call graph in the report — the §2.2 sidecar is absent, so whether this function reaches that effect is UNANSWERABLE, not a determined `no` (SPEC §3.3.1 ⟨0.28⟩)";
|
|
1674
|
+
if (wantJson) emit({ effect: eff, fn, unanswerable: why });
|
|
1675
|
+
else console.log(`candor: ${why}`);
|
|
1676
|
+
process.exit(2);
|
|
1677
|
+
}
|
|
1678
|
+
// BAD TARGET → LOUD exit 2 (corpus-audit #3), the `impact` argument above on the verb that answers
|
|
1679
|
+
// the OTHER direction, and the ONE-ENGINE divergence was HALF a divergence here: the HUMAN arm
|
|
1680
|
+
// (renderPathHuman) has always exited 2 with "no function matching", while `--json` printed
|
|
1681
|
+
// `{"effect":"Net","fn":"zzz_no_such_fn","path":[]}` at exit 0 — "there is no route by which this
|
|
1682
|
+
// function reaches that effect", the precise reassurance a reader asks `path` for, about a function
|
|
1683
|
+
// that does not exist. The MACHINE arm being the lenient one is the worse half: the human at least
|
|
1684
|
+
// saw an error. rust/java/swift all exit 2 on both arms (measured).
|
|
1685
|
+
//
|
|
1686
|
+
// HOISTED ABOVE THE SPLIT so ONE gate covers both arms — the divergence existed because the check
|
|
1687
|
+
// lived inside the human renderer only, and a rule that lives on one route is a rule the sibling
|
|
1688
|
+
// route does not have. renderPathHuman keeps its own resolution (over the REPORT entries, where
|
|
1689
|
+
// `inferred` lives) as the stricter downstream case; this gate refuses only what neither set knows.
|
|
1690
|
+
//
|
|
1691
|
+
// DISTINCT FROM THE UNANSWERABLE GATE ABOVE and ordered after it: "there is no graph" carries the
|
|
1692
|
+
// `unanswerable` key in the machine channel, "the graph is fine and the name is not in it" is a
|
|
1693
|
+
// usage error on stderr. NOT the mirror: a real fn that genuinely does not reach the effect still
|
|
1694
|
+
// answers `path: []` at exit 0 below — the graph SAID no, and "the graph says no", "there is no
|
|
1695
|
+
// graph" and "there is no such function" are three answers, not one.
|
|
1696
|
+
if (coreMatches(knownFnNames(cg, fns), fn).length === 0) {
|
|
1697
|
+
console.error(`candor-ts-query path: no function matching '${fn}'`); process.exit(2);
|
|
1698
|
+
}
|
|
977
1699
|
if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
|
|
978
1700
|
else {
|
|
979
1701
|
// The accepted 0.11 default change (the human chain replaced JSON as the no-flag output) gets a
|
|
@@ -1017,10 +1739,21 @@ switch (cmd) {
|
|
|
1017
1739
|
// consulted BEFORE an edit, where the alternative is the operator guessing. The exit is UNCHANGED:
|
|
1018
1740
|
// this verb has no `--strict`, §3.2 rules no exit for it, and inventing one is the failure mode the
|
|
1019
1741
|
// clause it lives beside exists to prevent.
|
|
1020
|
-
const
|
|
1742
|
+
const wcomp = reportCompleteness(prefix);
|
|
1743
|
+
const wunan = wcomp.unanalyzed;
|
|
1021
1744
|
if (wunan.length)
|
|
1022
1745
|
console.error(`candor-ts: whatif is NOT a complete answer — the report declares ${wunan.length} unit(s) candor could not analyze (disclosed under \`unanalyzed\`); \`ok\` is omitted because neither value is a statement the input licenses`);
|
|
1023
|
-
|
|
1746
|
+
// ⟨0.28⟩ …and the count-0 cause, which reaches here through a LIVE §2.2 sidecar: the target resolves
|
|
1747
|
+
// over the call graph, so this verb answers `ok: true` where the report-only verbs exit 2 on the name.
|
|
1748
|
+
// MEASURED exactly so — the pre-edit gate check, green, over a report that judged nothing.
|
|
1749
|
+
if (wcomp.judgedNothing.length) advisoryJudgedNothingNote("whatif");
|
|
1750
|
+
if (wcomp.noManifest.length) advisoryNoManifestNote("whatif", wcomp.noManifest);
|
|
1751
|
+
if (wcomp.unreadable.length) advisoryUnreadableNote("whatif", wcomp.unreadable);
|
|
1752
|
+
// ⟨0.28⟩ SPEC §2 — a CONFIGURED policy that parsed to zero rules asked nothing, so the pre-edit
|
|
1753
|
+
// verdict AND the blast radius it qualifies are withheld in favour of the caveat document. The exit
|
|
1754
|
+
// is UNCHANGED (0: with no rules, `violations` was empty by construction on this path anyway).
|
|
1755
|
+
if (policyFile && policyAskedNothing(pol)) { emitZeroRuleCaveat("whatif", policyFile, wcomp); process.exit(0); }
|
|
1756
|
+
emit(advisoryAnswer(r, wunan, wcomp.judgedNothing, wcomp.unreadable, wcomp.noManifest));
|
|
1024
1757
|
process.exit(r.violations.length ? 1 : 0);
|
|
1025
1758
|
break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
|
|
1026
1759
|
}
|
|
@@ -1040,8 +1773,17 @@ switch (cmd) {
|
|
|
1040
1773
|
// The sidecar is the ONLY graph a candor-ts report carries (it embeds no inline `calls`). Fail LOUD when
|
|
1041
1774
|
// it's absent — never compute a degenerate empty-graph remedy that reads as a false "no clean hoist".
|
|
1042
1775
|
if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
|
|
1043
|
-
const
|
|
1776
|
+
const fpol = loadPolicyOrDie(policyFile, ptext);
|
|
1777
|
+
const r = coreFix(cg, loadReportOrDie(prefix), target, eff, fpol, scopeMatches);
|
|
1044
1778
|
if (r === null) { console.error(`candor: no function matching \`${target}\` in the call graph`); process.exit(2); }
|
|
1779
|
+
// ⟨0.28⟩ SPEC §2 — this verb shares the policy loader with the three the clause names, and its every
|
|
1780
|
+
// answer is equally policy-relative: over a zero-rule policy it emitted `{"crossing": false,
|
|
1781
|
+
// "reason": "not-forbidden"}` at exit 0, and *not-forbidden* by a policy that forbids nothing is
|
|
1782
|
+
// vacuously true. Composed with the ⟨0.28⟩ `crossing` ruling, this emits NO `crossing` KEY — that key
|
|
1783
|
+
// is present exactly when the verb answered, and here it did not. Exit UNCHANGED (0; the
|
|
1784
|
+
// missing-function usage error above keeps its 2). Placed AFTER that error so a bad `fn` still
|
|
1785
|
+
// reports the bad `fn`.
|
|
1786
|
+
if (policyAskedNothing(fpol)) { emitZeroRuleCaveat("fix", policyFile, reportCompleteness(prefix)); process.exit(0); }
|
|
1045
1787
|
// ⟨0.24⟩ SPEC §3.2 `4fd140c` — the printed channel for a REFUSED remedy (`refused: true`, no `crossing`
|
|
1046
1788
|
// key). Without it the terminal shows a document with no plan in it and no reason for the absence.
|
|
1047
1789
|
if (r.unevaluated?.length) advisoryUnevaluatedNote("fix", r.unevaluated,
|
|
@@ -1062,18 +1804,39 @@ switch (cmd) {
|
|
|
1062
1804
|
catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
|
|
1063
1805
|
const cg = loadCallgraph(prefix);
|
|
1064
1806
|
if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix-gate needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
|
|
1065
|
-
const
|
|
1807
|
+
const fgpol = loadPolicyOrDie(policyFile, ptext);
|
|
1808
|
+
const fgr = coreFixGate(cg, loadReportOrDie(prefix), fgpol, scopeMatches);
|
|
1066
1809
|
// ⟨0.24⟩ SPEC §3.2 — see `advisoryAnswer`. Over a report declaring `unanalyzed` this OMITS `ok`, adds
|
|
1067
1810
|
// the manifest, and `--strict` (the CI form) exits 2 — could-not-fully-evaluate, the same code the gate
|
|
1068
1811
|
// uses for the same situation — rather than the 1 that would claim a finding or the 0 that certified.
|
|
1069
|
-
const
|
|
1812
|
+
const fgComp = reportCompleteness(prefix);
|
|
1813
|
+
const fgUnan = fgComp.unanalyzed;
|
|
1070
1814
|
if (fgUnan.length) advisoryIncompleteNote("fix-gate", fgUnan);
|
|
1815
|
+
// ⟨0.28⟩ …and the count-0 cause, which reaches the DOCUMENT and the PROSE but deliberately NOT the exit
|
|
1816
|
+
// below (see `advisoryAnswer`). Leaving it out would have let this verb print a remedy list beside
|
|
1817
|
+
// `ok: true` over a report that judged nothing — the same false all-clear, arriving by omission.
|
|
1818
|
+
if (fgComp.judgedNothing.length) advisoryJudgedNothingNote("fix-gate");
|
|
1819
|
+
if (fgComp.noManifest.length) advisoryNoManifestNote("fix-gate", fgComp.noManifest);
|
|
1820
|
+
if (fgComp.unreadable.length) advisoryUnreadableNote("fix-gate", fgComp.unreadable);
|
|
1071
1821
|
// ⟨0.24⟩ SPEC §3.2 `4fd140c` — and the same posture for a rule the GATE refused: no remedy is computed
|
|
1072
1822
|
// from evidence the gate declined to read, the refusal is disclosed on both channels, and `--strict`
|
|
1073
1823
|
// exits 2 (could-not-evaluate) rather than the 0 that would read as "no crossings left to fix".
|
|
1074
1824
|
if (fgr.unevaluated?.length) advisoryUnevaluatedNote("fix-gate", fgr.unevaluated, UNEVAL_TAIL_STRICT);
|
|
1075
|
-
|
|
1076
|
-
|
|
1825
|
+
// ⟨0.28⟩ SPEC §2 — an empty `remedies` beside `ok: true` here is a claim relative to a gate that never
|
|
1826
|
+
// asked a question. The caveat document replaces the result; the EXIT is the SAME expression the
|
|
1827
|
+
// result path computes, over the finding sets a zero-rule policy produces by construction (no
|
|
1828
|
+
// remedies, no unanswerable rule) — so it moves only with the REPORT's own incompleteness, exactly as
|
|
1829
|
+
// it does today.
|
|
1830
|
+
if (policyAskedNothing(fgpol)) {
|
|
1831
|
+
emitZeroRuleCaveat("fix-gate", policyFile, fgComp);
|
|
1832
|
+
process.exit(fgUnan.length || fgComp.unreadable.length ? (strict ? 2 : 0) : 0);
|
|
1833
|
+
}
|
|
1834
|
+
emit(advisoryAnswer(fgr, fgUnan, fgComp.judgedNothing, fgComp.unreadable, fgComp.noManifest));
|
|
1835
|
+
// ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger (SPEC §3.2's pessimism relation): `gate
|
|
1836
|
+
// --report` REFUSES over a corrupt member — measured, exit 2 — so exiting 0/1 here claimed this verb
|
|
1837
|
+
// got FURTHER than the gate on identical bytes. The unreadable note above already SAID the exit was
|
|
1838
|
+
// bounded by the gate's while this line did not read the field — a documented limitation, unmeasured.
|
|
1839
|
+
process.exit(fgUnan.length || fgComp.unreadable.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
|
|
1077
1840
|
break; // unreachable
|
|
1078
1841
|
}
|
|
1079
1842
|
case "unverified": {
|
|
@@ -1099,19 +1862,37 @@ switch (cmd) {
|
|
|
1099
1862
|
const ucg = uci >= 0 ? loadCallgraph(prefix) : {};
|
|
1100
1863
|
if (uci >= 0 && Object.keys(ucg).length === 0 && !ucg.partial && !ufns.some((e) => (e.calls ?? []).length))
|
|
1101
1864
|
console.error(`candor-ts: no call-graph sidecar for '${prefix}' and no \`calls\` edges in the report — \`--class\` resolved each hole's reason class from its OWN \`unknownWhy\` only; a hole whose Unknown is INHERITED reads \`unresolved\` here (re-run: candor-ts <src> --out ${prefix})`);
|
|
1102
|
-
const
|
|
1865
|
+
const upol = loadPolicyOrDie(policyFile, ptext);
|
|
1866
|
+
const r = coreUnverified(ufns, upol, scopeMatches,
|
|
1103
1867
|
uci >= 0 ? args[uci + 1] : null, ucg);
|
|
1104
1868
|
// ⟨0.24⟩ SPEC §3.2 — see `advisoryAnswer`, and this is the SHARPEST case in the family: the verb whose
|
|
1105
1869
|
// entire job is "your green gate is not provably green" was certifying a set it knows it cannot see all
|
|
1106
1870
|
// of. A function in an unparsed file is absent from `functions`, so it cannot be enumerated as an
|
|
1107
1871
|
// unverified pass — and that absence is exactly what this verb would have to report.
|
|
1108
|
-
const
|
|
1872
|
+
const uComp = reportCompleteness(prefix);
|
|
1873
|
+
const uUnan = uComp.unanalyzed;
|
|
1109
1874
|
if (uUnan.length) advisoryIncompleteNote("unverified", uUnan);
|
|
1875
|
+
// ⟨0.28⟩ …and the count-0 cause. MEASURED before this line: `{ok: true, unverified: []}` over a report
|
|
1876
|
+
// that judged nothing — this verb certifying a package it never examined. The exit is untouched.
|
|
1877
|
+
if (uComp.judgedNothing.length) advisoryJudgedNothingNote("unverified");
|
|
1878
|
+
if (uComp.noManifest.length) advisoryNoManifestNote("unverified", uComp.noManifest);
|
|
1879
|
+
if (uComp.unreadable.length) advisoryUnreadableNote("unverified", uComp.unreadable);
|
|
1110
1880
|
// ⟨0.24⟩ SPEC §3.2 `4fd140c` — the function the gate could not judge is NAMED in `unverified` above,
|
|
1111
1881
|
// with the missing evidence as its reason; this is the human channel for the same fact.
|
|
1112
1882
|
if (r.unevaluated?.length) advisoryUnevaluatedNote("unverified", r.unevaluated, UNEVAL_TAIL_STRICT);
|
|
1113
|
-
|
|
1114
|
-
|
|
1883
|
+
// ⟨0.28⟩ SPEC §2 — the sharpest of the four: the verb whose whole job is "your green gate is not
|
|
1884
|
+
// provably green" answered `{"ok": true, "unverified": []}` over a policy that asked nothing. The
|
|
1885
|
+
// empty list is withheld for ⟨0.27⟩'s reason (a refusal document must not carry `violations`), and the
|
|
1886
|
+
// exit follows the same expression the result path computes over empty finding sets.
|
|
1887
|
+
if (policyAskedNothing(upol)) {
|
|
1888
|
+
emitZeroRuleCaveat("unverified", policyFile, uComp);
|
|
1889
|
+
process.exit(uUnan.length || uComp.unreadable.length ? (strict ? 2 : 0) : 0);
|
|
1890
|
+
}
|
|
1891
|
+
emit(advisoryAnswer(r, uUnan, uComp.judgedNothing, uComp.unreadable, uComp.noManifest));
|
|
1892
|
+
// ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger — see fix-gate above. Measured on this verb
|
|
1893
|
+
// before the fix: over one good report plus one unparsable sibling, `gate --report` exited 2 and
|
|
1894
|
+
// `unverified --strict` exited 0 — and `--strict` is how CI consumes it.
|
|
1895
|
+
process.exit(uUnan.length || uComp.unreadable.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
|
|
1115
1896
|
break; // unreachable
|
|
1116
1897
|
}
|
|
1117
1898
|
case "gate": {
|
|
@@ -1139,13 +1920,17 @@ switch (cmd) {
|
|
|
1139
1920
|
const gwrite = (obj) => {
|
|
1140
1921
|
const text = JSON.stringify(obj, null, 1);
|
|
1141
1922
|
for (const dest of gdests) {
|
|
1142
|
-
|
|
1923
|
+
// THE FLAG IS SET WHERE THE WRITE HAPPENS, not where the gate is entered. It was set on
|
|
1924
|
+
// entering this verb, under the comment "reaching here means the gate ran and will write its
|
|
1925
|
+
// own document" — which is a claim about the future, and false: the `--policy` fallback ladder
|
|
1926
|
+
// can still exit 2 below without writing. That suppressed the pre-pass hook and returned the
|
|
1927
|
+
// run to EMPTY stdout after exit 2, which is the exact channel the hook was added to close.
|
|
1928
|
+
// Caught by the second go/no-go panel; the first flag placement lasted about an hour.
|
|
1929
|
+
if (dest === "-") { globalThis.__candorGateVerdictWritten = true; console.log(text); continue; }
|
|
1143
1930
|
// A SURFACING side-output: an unwritable path is one stderr line, never a raw ENOENT crash whose
|
|
1144
1931
|
// exit 1 would read as a policy violation on a clean run (the scan path's rule).
|
|
1145
1932
|
try {
|
|
1146
|
-
|
|
1147
|
-
fs.writeFileSync(tmp, text + "\n");
|
|
1148
|
-
fs.renameSync(tmp, dest);
|
|
1933
|
+
writeSinkAtomic(dest, text + "\n");
|
|
1149
1934
|
} catch (e) { console.error(`candor-ts: could not write --gate-json ${dest}: ${e.message}`); }
|
|
1150
1935
|
}
|
|
1151
1936
|
};
|
|
@@ -1182,7 +1967,23 @@ switch (cmd) {
|
|
|
1182
1967
|
// uses (SPEC §3.1 makes byte-equality between the two documents the acceptance test — the scan route
|
|
1183
1968
|
// needed this list so a dominating baseline regression could carry the refusal beside it, and a list on
|
|
1184
1969
|
// one route only would break the equality on the very change that repaired the precedence).
|
|
1185
|
-
|
|
1970
|
+
// ⟨0.27⟩ …listing EVERY rule of the refused policy, not only the unhonourable lines — the shared
|
|
1971
|
+
// builder with the scan route (SPEC §3.1's composed-document clause; byte-equality binds the two).
|
|
1972
|
+
if (gfatal.length) { const why = policyErrorText(policyFile, gfatal); console.error(why); grefuse(why, policyRefusalUnevaluated(gtext, gfatal)); }
|
|
1973
|
+
// ⟨0.28⟩ …AND A CONFIGURED POLICY THAT YIELDED ZERO RULES REFUSES THE SAME WAY (SPEC §6.2). The scan
|
|
1974
|
+
// route carries the same block with the same builder; this one is not optional beside it, on §6.2's own
|
|
1975
|
+
// words ("Measured on the `gate --report` verb too — a route is not covered by its sibling") and on
|
|
1976
|
+
// §3.1's byte-equality MUST, which a one-route rung would break on the `# nothing` policy. Every rule
|
|
1977
|
+
// vector, never a subset: `deny` (deny + pure), `allow`, `forbid` — keying on one would refuse an
|
|
1978
|
+
// ordinary allow-only or forbid-only gate as if it had no rules.
|
|
1979
|
+
if (!gpol.deny.length && !gpol.allow.length && !gpol.forbid.length) {
|
|
1980
|
+
const { why, unevaluated } = policyZeroRules(policyFile);
|
|
1981
|
+
console.error(`candor-ts: gate: ${why} — refusing (exit 2, gate NOT enforced). Every line was ignored `
|
|
1982
|
+
+ `(see the \`ignoring policy rule\` warnings above), the file is empty, or it holds only comments. A `
|
|
1983
|
+
+ `gate with no rules cannot have caught anything, and \`ok: true\` here would be indistinguishable `
|
|
1984
|
+
+ `from a gate that ran and found nothing.`);
|
|
1985
|
+
grefuse(why, unevaluated);
|
|
1986
|
+
}
|
|
1186
1987
|
// ⟨0.24⟩ THE CONFIG FILE THAT SUPPLIED VOCABULARY THE VERDICT USED (SPEC §3.1 `99eb4e9`) — named on a
|
|
1187
1988
|
// REFERENCE, not only on a firing, because the measured harm was a GREEN verdict a vocabulary file made
|
|
1188
1989
|
// green. Omitted when no alias was used, so every other verdict stays byte-identical to before.
|
|
@@ -1305,6 +2106,17 @@ switch (cmd) {
|
|
|
1305
2106
|
// Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
|
|
1306
2107
|
// document, so `candor-ts-query gate … --json | jq` sees pure JSON.
|
|
1307
2108
|
const gsay = (json || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
|
2109
|
+
// ⟨0.27⟩ SPEC §4 — THE ZERO-MATCH DISCLOSURE BELONGS ON THIS ROUTE TOO. Its absence was found by a
|
|
2110
|
+
// cross-engine differential: java and swift disclosed on `gate --report`, rust and ts did not, so
|
|
2111
|
+
// the same typo'd policy was reported by two engines and silently scored as satisfied by two. §4's
|
|
2112
|
+
// MUST carries no route qualifier, and this is the SUPPLY-CHAIN gate — a consumer pointing a policy
|
|
2113
|
+
// at a report someone else produced. ALWAYS on stderr, never through `gsay`: this is a disclosure
|
|
2114
|
+
// about the policy, not a verdict line, and stdout may be carrying the verdict document.
|
|
2115
|
+
for (const raw of gviol.zeroMatch ?? []) {
|
|
2116
|
+
console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
|
|
2117
|
+
+ `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
|
|
2118
|
+
+ `repos; a typo'd layer name otherwise.`);
|
|
2119
|
+
}
|
|
1308
2120
|
for (const x of gviol) gsay(`[${x.rule}] ${x.detail}`);
|
|
1309
2121
|
// ⟨0.21⟩ COMPLETENESS MANIFEST: a gate cannot be green over code candor never analyzed. The scan path
|
|
1310
2122
|
// exits 2 on its OWN `unanalyzed`; here the same manifest travels ON the report, so the same verdict
|
|
@@ -1325,6 +2137,16 @@ switch (cmd) {
|
|
|
1325
2137
|
if (gvocab) gverdictObj.policyVocabulary = gvocab;
|
|
1326
2138
|
gverdictObj.violations = gviol;
|
|
1327
2139
|
if (gunevaluated.length) gverdictObj.unevaluated = gunevaluated;
|
|
2140
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines above carry, in the machine channel,
|
|
2141
|
+
// in the same position the scan route puts it (§3.1's byte-equality MUST binds the two documents).
|
|
2142
|
+
if (gviol.zeroMatch?.length) gverdictObj.zeroMatch = gviol.zeroMatch;
|
|
2143
|
+
// ⟨0.28⟩ SPEC §6.2 `ignored: [{line, text, reason}]` — the policy lines the parse DROPPED, in the SAME
|
|
2144
|
+
// position the scan route puts them, because §3.1 makes byte-equality between the two documents the
|
|
2145
|
+
// acceptance test and §6.2 records this defect measured "on the `gate --report` verb too — a route is
|
|
2146
|
+
// not covered by its sibling". Distinct from `unevaluated`: that carries rules that PARSED and could
|
|
2147
|
+
// not be answered, this carries text that never became a rule at all. Omitted when empty; `ok` and
|
|
2148
|
+
// the exit do not consult it (the line-level leniency is unchanged, only disclosed).
|
|
2149
|
+
if (gpol.ignored?.length) gverdictObj.ignored = gpol.ignored;
|
|
1328
2150
|
if (gincomplete) { gverdictObj.incomplete = true; gverdictObj.unanalyzed = g.unanalyzed; }
|
|
1329
2151
|
if (g.coverage.length)
|
|
1330
2152
|
gverdictObj.coverage = { uncovered: g.coverage.length, packages: g.coverage.map((c) => c.name) };
|