@copperbox/why 1.0.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/README.md +168 -0
- package/dist/anchor.d.ts +112 -0
- package/dist/anchor.js +697 -0
- package/dist/anchors.d.ts +101 -0
- package/dist/anchors.js +223 -0
- package/dist/audit.d.ts +120 -0
- package/dist/audit.js +483 -0
- package/dist/blame.d.ts +91 -0
- package/dist/blame.js +294 -0
- package/dist/bundle.d.ts +78 -0
- package/dist/bundle.js +188 -0
- package/dist/capture.d.ts +76 -0
- package/dist/capture.js +462 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +641 -0
- package/dist/dig-state.d.ts +46 -0
- package/dist/dig-state.js +146 -0
- package/dist/dig.d.ts +125 -0
- package/dist/dig.js +380 -0
- package/dist/discover.d.ts +11 -0
- package/dist/discover.js +47 -0
- package/dist/doctor.d.ts +135 -0
- package/dist/doctor.js +263 -0
- package/dist/evidence.d.ts +73 -0
- package/dist/evidence.js +425 -0
- package/dist/export.d.ts +67 -0
- package/dist/export.js +106 -0
- package/dist/find-symbol.d.ts +19 -0
- package/dist/find-symbol.js +267 -0
- package/dist/git.d.ts +17 -0
- package/dist/git.js +51 -0
- package/dist/init.d.ts +24 -0
- package/dist/init.js +100 -0
- package/dist/lint.d.ts +40 -0
- package/dist/lint.js +161 -0
- package/dist/serve-assets.d.ts +10 -0
- package/dist/serve-assets.js +55 -0
- package/dist/serve.d.ts +53 -0
- package/dist/serve.js +226 -0
- package/dist/trace-range.d.ts +22 -0
- package/dist/trace-range.js +216 -0
- package/package.json +44 -0
- package/ui/app.js +323 -0
- package/ui/graph.js +164 -0
- package/ui/highlight.js +202 -0
- package/ui/story-panel.d.ts +9 -0
- package/ui/story-panel.js +125 -0
- package/ui/style.css +229 -0
package/dist/audit.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// `why audit` (DESIGN.md §5): constraints must be falsifiable, and expiry
|
|
2
|
+
// must propagate downstream. The sweep covers every `active` constraint:
|
|
3
|
+
// `verify.method: check` commands run in the enclosing repo directory with a
|
|
4
|
+
// timeout and captured output; `method: ask` items are exported as an
|
|
5
|
+
// agent-consumable questionnaire (the CLI never calls an LLM) and applied
|
|
6
|
+
// back via `--answers`; overdue `review_by` dates are flagged. A failed
|
|
7
|
+
// check — or an answer marking an ask no longer true — flips the constraint
|
|
8
|
+
// to `status: expired` and walks `# Because of` edges backwards: every
|
|
9
|
+
// `active` decision reached is reported as candidate scar tissue and gets a
|
|
10
|
+
// generated `question` concept, unless an open question already links the
|
|
11
|
+
// pair. That report is the tool's reason to exist.
|
|
12
|
+
//
|
|
13
|
+
// Nothing here asserts above its evidence: a check that cannot run (timeout,
|
|
14
|
+
// spawn failure) is an error item, never an expiry, and an unanswered ask
|
|
15
|
+
// stays open. Writes go through okf-mcp's updateConcept/writeConcept so
|
|
16
|
+
// everything outside the touched spans survives byte-for-byte.
|
|
17
|
+
import { exec } from "node:child_process";
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { extractSection, updateConcept, writeConcept } from "@copperbox/okf-mcp";
|
|
23
|
+
import { isOneOf, isPlainMap } from "./bundle.js";
|
|
24
|
+
import { plural } from "./dig.js";
|
|
25
|
+
const execAsync = promisify(exec);
|
|
26
|
+
/** An audit run that must stop cleanly (exit 1), not crash. */
|
|
27
|
+
export class AuditError extends Error {
|
|
28
|
+
}
|
|
29
|
+
/** How long one `verify.check` command may run before it counts as an error. */
|
|
30
|
+
export const DEFAULT_CHECK_TIMEOUT_MS = 30_000;
|
|
31
|
+
/** Chars of captured check output kept — clipped with a marker, never silently. */
|
|
32
|
+
export const CHECK_OUTPUT_LIMIT = 4_000;
|
|
33
|
+
export const ASK_ANSWERS = ["still-true", "no-longer-true", "unknown"];
|
|
34
|
+
// --- Questionnaire ---------------------------------------------------------
|
|
35
|
+
export const EVIDENCE_PLACEHOLDER = "(replace this line with the evidence for your answer)";
|
|
36
|
+
/** The agent-facing export of `method: ask` items; round-trips via `--answers`. */
|
|
37
|
+
export function renderQuestionnaire(items) {
|
|
38
|
+
const lines = [
|
|
39
|
+
"# why audit questionnaire",
|
|
40
|
+
"",
|
|
41
|
+
"Active constraints verifying with `method: ask`, exported for an agent (or",
|
|
42
|
+
"human) to re-verify — `why audit` never calls an LLM itself. For each item,",
|
|
43
|
+
"investigate, set `answer:` to `still-true`, `no-longer-true`, or `unknown`,",
|
|
44
|
+
"and replace the placeholder with your evidence. Then apply the answers:",
|
|
45
|
+
"",
|
|
46
|
+
" why audit --answers <this-file>",
|
|
47
|
+
"",
|
|
48
|
+
];
|
|
49
|
+
if (items.length === 0) {
|
|
50
|
+
lines.push("No unanswered `method: ask` constraints — nothing to do.");
|
|
51
|
+
}
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
lines.push(`## ${item.concept}`, "", item.ask, "", "answer: unknown", "", EVIDENCE_PLACEHOLDER, "");
|
|
54
|
+
}
|
|
55
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse a filled-in questionnaire: `## <concept-id>` starts an item, the first
|
|
59
|
+
* `answer:` line inside it is the verdict, and everything after that line
|
|
60
|
+
* (until the next item) is the evidence. Strict where it matters: an
|
|
61
|
+
* unrecognized answer or a `no-longer-true` without evidence is an error —
|
|
62
|
+
* the flip writes that evidence into `# Still true?`, so it cannot be empty.
|
|
63
|
+
*/
|
|
64
|
+
export function parseAnswers(text) {
|
|
65
|
+
const out = new Map();
|
|
66
|
+
let current;
|
|
67
|
+
let answer;
|
|
68
|
+
let evidence = [];
|
|
69
|
+
const flush = () => {
|
|
70
|
+
if (current === undefined)
|
|
71
|
+
return;
|
|
72
|
+
const value = answer ?? "unknown";
|
|
73
|
+
if (!isOneOf(ASK_ANSWERS, value)) {
|
|
74
|
+
throw new AuditError(`--answers: "${value}" is not an answer for ${current} — use ${ASK_ANSWERS.join(", ")}`);
|
|
75
|
+
}
|
|
76
|
+
const kept = evidence.join("\n").replaceAll(EVIDENCE_PLACEHOLDER, "").trim();
|
|
77
|
+
if (value === "no-longer-true" && kept === "") {
|
|
78
|
+
throw new AuditError(`--answers: ${current} is marked no-longer-true without evidence — the flip records that evidence in "# Still true?", so it cannot be empty`);
|
|
79
|
+
}
|
|
80
|
+
out.set(current, { answer: value, evidence: kept });
|
|
81
|
+
};
|
|
82
|
+
for (const line of text.split("\n")) {
|
|
83
|
+
const heading = /^##\s+(\S+)\s*$/.exec(line);
|
|
84
|
+
if (heading !== null) {
|
|
85
|
+
flush();
|
|
86
|
+
current = heading[1];
|
|
87
|
+
if (out.has(current)) {
|
|
88
|
+
throw new AuditError(`--answers: ${current} appears twice — keep one answer per constraint`);
|
|
89
|
+
}
|
|
90
|
+
answer = undefined;
|
|
91
|
+
evidence = [];
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (current === undefined)
|
|
95
|
+
continue;
|
|
96
|
+
if (answer === undefined) {
|
|
97
|
+
const m = /^answer:\s*(.*)$/.exec(line.trim());
|
|
98
|
+
if (m !== null)
|
|
99
|
+
answer = m[1].trim();
|
|
100
|
+
continue; // text between the heading and the answer line is the ask itself
|
|
101
|
+
}
|
|
102
|
+
evidence.push(line);
|
|
103
|
+
}
|
|
104
|
+
flush();
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
// --- Check execution -------------------------------------------------------
|
|
108
|
+
function clipOutput(text) {
|
|
109
|
+
if (text.length <= CHECK_OUTPUT_LIMIT)
|
|
110
|
+
return text;
|
|
111
|
+
return `${text.slice(0, CHECK_OUTPUT_LIMIT)}\n[clipped: showing ${CHECK_OUTPUT_LIMIT} of ${text.length} chars]`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Run one `verify.check` command, confined to the repo directory (cwd) with a
|
|
115
|
+
* timeout and captured output. Exit 0 = still true; a non-zero exit is the
|
|
116
|
+
* falsification; anything that prevents an exit code is an `error`.
|
|
117
|
+
*/
|
|
118
|
+
async function runCheck(concept, command, repoDir, timeoutMs) {
|
|
119
|
+
try {
|
|
120
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
121
|
+
cwd: repoDir,
|
|
122
|
+
timeout: timeoutMs,
|
|
123
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
124
|
+
});
|
|
125
|
+
return { concept, command, outcome: "passed", exitCode: 0, output: clipOutput((stdout + stderr).trimEnd()) };
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
const err = e;
|
|
129
|
+
const output = clipOutput(`${err.stdout ?? ""}${err.stderr ?? ""}`.trimEnd());
|
|
130
|
+
if (err.killed === true) {
|
|
131
|
+
return { concept, command, outcome: "error", exitCode: null, output, detail: `timed out after ${timeoutMs}ms` };
|
|
132
|
+
}
|
|
133
|
+
if (typeof err.code === "number") {
|
|
134
|
+
return { concept, command, outcome: "failed", exitCode: err.code, output };
|
|
135
|
+
}
|
|
136
|
+
return { concept, command, outcome: "error", exitCode: null, output, detail: String(err.message ?? err.code) };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// --- Blast radius ----------------------------------------------------------
|
|
140
|
+
/**
|
|
141
|
+
* Walk `# Because of` edges backwards from a constraint (DESIGN.md §5):
|
|
142
|
+
* concepts linking to it from a `# Because of` section exist because of it,
|
|
143
|
+
* transitively. Returns every `active` decision reached, sorted.
|
|
144
|
+
*/
|
|
145
|
+
export function blastRadius(bundle, constraintId) {
|
|
146
|
+
const inbound = new Map();
|
|
147
|
+
for (const concept of bundle.concepts.values()) {
|
|
148
|
+
for (const link of concept.links) {
|
|
149
|
+
if (link.section?.toLowerCase() !== "because of" || link.resolvedId === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
const sources = inbound.get(link.resolvedId) ?? [];
|
|
152
|
+
sources.push(concept.id);
|
|
153
|
+
inbound.set(link.resolvedId, sources);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const visited = new Set([constraintId]);
|
|
157
|
+
const queue = [constraintId];
|
|
158
|
+
const decisions = [];
|
|
159
|
+
while (queue.length > 0) {
|
|
160
|
+
for (const source of inbound.get(queue.shift()) ?? []) {
|
|
161
|
+
if (visited.has(source))
|
|
162
|
+
continue;
|
|
163
|
+
visited.add(source);
|
|
164
|
+
queue.push(source);
|
|
165
|
+
const concept = bundle.concepts.get(source);
|
|
166
|
+
if (concept?.frontmatter.type === "decision" && concept.why.status === "active") {
|
|
167
|
+
decisions.push(source);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return decisions.sort();
|
|
172
|
+
}
|
|
173
|
+
/** An open question already linking both the constraint and the decision. */
|
|
174
|
+
function existingQuestion(bundle, constraintId, decisionId) {
|
|
175
|
+
return [...bundle.concepts.values()].find((c) => c.frontmatter.type === "question" &&
|
|
176
|
+
c.why.status === "open" &&
|
|
177
|
+
c.links.some((l) => l.resolvedId === constraintId) &&
|
|
178
|
+
c.links.some((l) => l.resolvedId === decisionId));
|
|
179
|
+
}
|
|
180
|
+
// --- Writes ------------------------------------------------------------------
|
|
181
|
+
function titleOf(concept) {
|
|
182
|
+
const title = concept.frontmatter.title;
|
|
183
|
+
return typeof title === "string" && title !== "" ? title : concept.id;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Flip one constraint: status/expired_on via the frontmatter patch, evidence
|
|
187
|
+
* appended to `# Still true?` via the section patch — the rest of the file
|
|
188
|
+
* survives byte-for-byte (okf-mcp updateConcept splices the document on disk).
|
|
189
|
+
*/
|
|
190
|
+
async function expireConstraint(bundle, constraint, expiredOn, evidence) {
|
|
191
|
+
const rawWhy = isPlainMap(constraint.frontmatter.why) ? constraint.frontmatter.why : {};
|
|
192
|
+
const frontmatter = { why: { ...rawWhy, status: "expired", expired_on: expiredOn } };
|
|
193
|
+
const existing = extractSection(constraint.body, "still true?");
|
|
194
|
+
if (existing !== undefined) {
|
|
195
|
+
const content = existing.content === "" ? evidence : `${existing.content}\n\n${evidence}`;
|
|
196
|
+
await updateConcept(bundle.okf, constraint.id, {
|
|
197
|
+
frontmatter,
|
|
198
|
+
section: { heading: "Still true?", content },
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// A constraint missing its required `# Still true?` section (lint W202)
|
|
203
|
+
// still flips — the falsification is real — and gains the section by a
|
|
204
|
+
// plain append, since the section patch has no heading to splice into.
|
|
205
|
+
await updateConcept(bundle.okf, constraint.id, { frontmatter });
|
|
206
|
+
const absolute = join(bundle.root, constraint.path);
|
|
207
|
+
const source = await readFile(absolute, "utf8");
|
|
208
|
+
const glue = source.endsWith("\n") ? "\n" : "\n\n";
|
|
209
|
+
await writeFile(absolute, `${source}${glue}# Still true?\n\n${evidence}\n`, "utf8");
|
|
210
|
+
}
|
|
211
|
+
/** File the "is this decision still needed?" question for one blast-radius pair. */
|
|
212
|
+
async function writeQuestion(bundle, constraint, decision, expiredOn) {
|
|
213
|
+
const slug = `is-${basename(decision.path, ".md")}-still-needed`;
|
|
214
|
+
let relPath = `questions/${slug}.md`;
|
|
215
|
+
for (let n = 2; existsSync(join(bundle.root, relPath)); n++) {
|
|
216
|
+
relPath = `questions/${slug}-${n}.md`;
|
|
217
|
+
}
|
|
218
|
+
const constraintTitle = titleOf(constraint);
|
|
219
|
+
const decisionTitle = titleOf(decision);
|
|
220
|
+
const title = `Constraint "${constraintTitle}" expired — is "${decisionTitle}" still needed?`;
|
|
221
|
+
const body = [
|
|
222
|
+
`# ${title}`,
|
|
223
|
+
"",
|
|
224
|
+
`[${constraintTitle}](/${constraint.path}) expired on ${expiredOn}, but ` +
|
|
225
|
+
`[${decisionTitle}](/${decision.path}) — shaped by it via \`# Because of\` — is still \`active\`. ` +
|
|
226
|
+
"The decision's code shape may now be scar tissue: re-evaluate it, then either supersede it " +
|
|
227
|
+
"or record why it stands on its own merits.",
|
|
228
|
+
"",
|
|
229
|
+
"Filed by `why audit` (DESIGN.md §5).",
|
|
230
|
+
"",
|
|
231
|
+
].join("\n");
|
|
232
|
+
await writeConcept(bundle.root, relPath, {
|
|
233
|
+
type: "question",
|
|
234
|
+
title,
|
|
235
|
+
description: `Generated by why audit: ${constraint.id} expired on ${expiredOn} while ${decision.id} is still active.`,
|
|
236
|
+
why: { status: "open", happened_on: expiredOn },
|
|
237
|
+
}, body, { failIfExists: true });
|
|
238
|
+
return relPath;
|
|
239
|
+
}
|
|
240
|
+
// --- The sweep ---------------------------------------------------------------
|
|
241
|
+
function isoDate(now) {
|
|
242
|
+
return now.toISOString().slice(0, 10);
|
|
243
|
+
}
|
|
244
|
+
function indentBlock(text) {
|
|
245
|
+
const body = text === "" ? "(no output)" : text;
|
|
246
|
+
return body
|
|
247
|
+
.split("\n")
|
|
248
|
+
.map((line) => ` ${line}`)
|
|
249
|
+
.join("\n");
|
|
250
|
+
}
|
|
251
|
+
/** The evidence paragraph appended to `# Still true?` when a check fails. */
|
|
252
|
+
function checkEvidence(check, expiredOn) {
|
|
253
|
+
return [
|
|
254
|
+
`**No — expired ${expiredOn}.** \`why audit\`: the verify check failed (exit ${check.exitCode}).`,
|
|
255
|
+
"",
|
|
256
|
+
indentBlock(`$ ${check.command}\n${check.output === "" ? "(no output)" : check.output}`),
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
/** The evidence paragraph appended to `# Still true?` for an answered ask. */
|
|
260
|
+
function askEvidence(evidence, expiredOn) {
|
|
261
|
+
const quoted = evidence
|
|
262
|
+
.split("\n")
|
|
263
|
+
.map((line) => (line === "" ? ">" : `> ${line}`))
|
|
264
|
+
.join("\n");
|
|
265
|
+
return [
|
|
266
|
+
`**No — expired ${expiredOn}.** \`why audit --answers\`: marked no longer true.`,
|
|
267
|
+
"",
|
|
268
|
+
quoted,
|
|
269
|
+
].join("\n");
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The full audit: sweep, verify, flip, walk, file. Returns the report; exit
|
|
273
|
+
* semantics (1 when anything newly expired) belong to the CLI.
|
|
274
|
+
*/
|
|
275
|
+
export async function auditBundle(bundle, options = {}) {
|
|
276
|
+
const now = options.now ?? new Date();
|
|
277
|
+
const today = isoDate(now);
|
|
278
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS;
|
|
279
|
+
const answers = options.answers ?? new Map();
|
|
280
|
+
const repoDir = dirname(bundle.root);
|
|
281
|
+
const constraints = [...bundle.concepts.values()]
|
|
282
|
+
.filter((c) => c.frontmatter.type === "constraint")
|
|
283
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
284
|
+
const active = constraints.filter((c) => c.why.status === "active");
|
|
285
|
+
const activeIds = new Set(active.map((c) => c.id));
|
|
286
|
+
// Answers must land on sweepable ask constraints — a typo or a stale
|
|
287
|
+
// questionnaire (the constraint expired since) errors loudly, never a no-op.
|
|
288
|
+
for (const id of answers.keys()) {
|
|
289
|
+
const target = bundle.concepts.get(id);
|
|
290
|
+
if (target === undefined || target.frontmatter.type !== "constraint") {
|
|
291
|
+
throw new AuditError(`--answers: ${id} is not a constraint in this bundle`);
|
|
292
|
+
}
|
|
293
|
+
if (!activeIds.has(id)) {
|
|
294
|
+
throw new AuditError(`--answers: ${id} is not an active constraint (status: ${target.why.status ?? "unset"}) — the answer no longer applies`);
|
|
295
|
+
}
|
|
296
|
+
if (target.why.verify?.method !== "ask") {
|
|
297
|
+
throw new AuditError(`--answers: ${id} does not verify with method "ask"`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const checks = [];
|
|
301
|
+
const asks = [];
|
|
302
|
+
const reviewByPastDue = [];
|
|
303
|
+
const unverifiable = [];
|
|
304
|
+
const flips = [];
|
|
305
|
+
for (const constraint of active) {
|
|
306
|
+
const verify = constraint.why.verify;
|
|
307
|
+
if (verify === undefined) {
|
|
308
|
+
unverifiable.push({
|
|
309
|
+
concept: constraint.id,
|
|
310
|
+
reason: "no verify block — a constraint must be falsifiable (DESIGN.md §5)",
|
|
311
|
+
});
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (verify.method === "check") {
|
|
315
|
+
if (verify.check === undefined) {
|
|
316
|
+
unverifiable.push({ concept: constraint.id, reason: "verify.check is missing — run `why lint`" });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const result = await runCheck(constraint.id, verify.check, repoDir, timeoutMs);
|
|
320
|
+
checks.push(result);
|
|
321
|
+
if (result.outcome === "failed") {
|
|
322
|
+
flips.push({
|
|
323
|
+
constraint,
|
|
324
|
+
method: "check",
|
|
325
|
+
detail: `verify check failed (exit ${result.exitCode})`,
|
|
326
|
+
evidence: checkEvidence(result, today),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else if (verify.method === "ask") {
|
|
331
|
+
if (verify.ask === undefined) {
|
|
332
|
+
unverifiable.push({ concept: constraint.id, reason: "verify.ask is missing — run `why lint`" });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const entry = answers.get(constraint.id);
|
|
336
|
+
asks.push({ concept: constraint.id, ask: verify.ask, answer: entry?.answer ?? "unknown" });
|
|
337
|
+
if (entry?.answer === "no-longer-true") {
|
|
338
|
+
flips.push({
|
|
339
|
+
constraint,
|
|
340
|
+
method: "ask",
|
|
341
|
+
detail: "marked no longer true via --answers",
|
|
342
|
+
evidence: askEvidence(entry.evidence, today),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
const reviewBy = verify.review_by;
|
|
348
|
+
if (reviewBy === undefined) {
|
|
349
|
+
unverifiable.push({ concept: constraint.id, reason: "verify.review_by is missing — run `why lint`" });
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// An unparseable date cannot be shown to lie in the future, so it counts
|
|
353
|
+
// as due — the check may not silently pass a claim it cannot evaluate.
|
|
354
|
+
if (!(Date.parse(reviewBy) > now.getTime())) {
|
|
355
|
+
reviewByPastDue.push({ concept: constraint.id, review_by: reviewBy });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Flips and their blast radius. The question filing dedupes by link pair:
|
|
360
|
+
// an open question already linking both concepts makes a new one noise.
|
|
361
|
+
const expired = [];
|
|
362
|
+
const questionsWritten = [];
|
|
363
|
+
for (const flip of flips) {
|
|
364
|
+
await expireConstraint(bundle, flip.constraint, today, flip.evidence);
|
|
365
|
+
const entries = [];
|
|
366
|
+
for (const decisionId of blastRadius(bundle, flip.constraint.id)) {
|
|
367
|
+
const decision = bundle.concepts.get(decisionId);
|
|
368
|
+
const open = existingQuestion(bundle, flip.constraint.id, decisionId);
|
|
369
|
+
if (open !== undefined) {
|
|
370
|
+
entries.push({ decision: decisionId, question: open.id, written: false });
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
const relPath = await writeQuestion(bundle, flip.constraint, decision, today);
|
|
374
|
+
questionsWritten.push(relPath);
|
|
375
|
+
entries.push({ decision: decisionId, question: relPath, written: true });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
expired.push({
|
|
379
|
+
concept: flip.constraint.id,
|
|
380
|
+
method: flip.method,
|
|
381
|
+
expired_on: today,
|
|
382
|
+
detail: flip.detail,
|
|
383
|
+
blastRadius: entries,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
// Constraints already expired before this run keep their candidacy visible
|
|
387
|
+
// (report-only — no re-flip, no new questions: that flagging already ran).
|
|
388
|
+
const alreadyExpired = [];
|
|
389
|
+
for (const constraint of constraints) {
|
|
390
|
+
if (constraint.why.status !== "expired")
|
|
391
|
+
continue;
|
|
392
|
+
const decisions = blastRadius(bundle, constraint.id);
|
|
393
|
+
if (decisions.length === 0)
|
|
394
|
+
continue;
|
|
395
|
+
alreadyExpired.push({ concept: constraint.id, expired_on: constraint.why.expired_on ?? null, decisions });
|
|
396
|
+
}
|
|
397
|
+
// The questionnaire exports what still needs answering; already-answered
|
|
398
|
+
// items would loop forever if re-exported.
|
|
399
|
+
let questionnaire = null;
|
|
400
|
+
if (options.questionsOut !== undefined) {
|
|
401
|
+
const unanswered = asks.filter((a) => a.answer === "unknown");
|
|
402
|
+
await writeFile(options.questionsOut, renderQuestionnaire(unanswered), "utf8");
|
|
403
|
+
questionnaire = options.questionsOut;
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
root: bundle.root,
|
|
407
|
+
activeConstraints: active.length,
|
|
408
|
+
checks,
|
|
409
|
+
asks,
|
|
410
|
+
reviewByPastDue,
|
|
411
|
+
unverifiable,
|
|
412
|
+
expired,
|
|
413
|
+
alreadyExpired,
|
|
414
|
+
questionsWritten,
|
|
415
|
+
questionnaire,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// --- Rendering ---------------------------------------------------------------
|
|
419
|
+
export function renderAuditReport(report) {
|
|
420
|
+
const lines = [];
|
|
421
|
+
lines.push(`why audit: ${plural(report.activeConstraints, "active constraint")} at ${report.root}`);
|
|
422
|
+
const swept = report.checks.length + report.asks.length + report.reviewByPastDue.length + report.unverifiable.length > 0;
|
|
423
|
+
if (swept)
|
|
424
|
+
lines.push("");
|
|
425
|
+
for (const check of report.checks) {
|
|
426
|
+
let label = check.outcome;
|
|
427
|
+
let suffix = "";
|
|
428
|
+
if (check.outcome === "failed") {
|
|
429
|
+
label = "FAILED";
|
|
430
|
+
suffix = ` (exit ${check.exitCode})`;
|
|
431
|
+
}
|
|
432
|
+
else if (check.outcome === "error") {
|
|
433
|
+
suffix = ` — ${check.detail}`;
|
|
434
|
+
}
|
|
435
|
+
lines.push(` check ${label.padEnd(8)} ${check.concept} \`${check.command}\`${suffix}`);
|
|
436
|
+
}
|
|
437
|
+
for (const ask of report.asks) {
|
|
438
|
+
const note = ask.answer === "unknown"
|
|
439
|
+
? "unanswered — export with --questions-out, apply with --answers"
|
|
440
|
+
: `answered ${ask.answer} via --answers`;
|
|
441
|
+
lines.push(` ask ${ask.answer.padEnd(8)} ${ask.concept} ${note}`);
|
|
442
|
+
}
|
|
443
|
+
for (const item of report.reviewByPastDue) {
|
|
444
|
+
lines.push(` review-by PAST DUE ${item.concept} review_by ${item.review_by} — needs a human look`);
|
|
445
|
+
}
|
|
446
|
+
for (const item of report.unverifiable) {
|
|
447
|
+
lines.push(` unverifiable ${item.concept} ${item.reason}`);
|
|
448
|
+
}
|
|
449
|
+
if (report.questionnaire !== null) {
|
|
450
|
+
const unanswered = report.asks.filter((a) => a.answer === "unknown").length;
|
|
451
|
+
lines.push("", `wrote questionnaire (${plural(unanswered, "unanswered ask")}) to ${report.questionnaire}`);
|
|
452
|
+
}
|
|
453
|
+
if (report.expired.length > 0) {
|
|
454
|
+
lines.push("", "expired this run:");
|
|
455
|
+
for (const item of report.expired) {
|
|
456
|
+
lines.push(` ${item.concept} — expired_on ${item.expired_on} (${item.detail})`);
|
|
457
|
+
for (const entry of item.blastRadius) {
|
|
458
|
+
const action = entry.written ? `filed ${entry.question}` : `already tracked by ${entry.question}`;
|
|
459
|
+
lines.push(` ${entry.decision} is active because of it — ${action}`);
|
|
460
|
+
}
|
|
461
|
+
if (item.blastRadius.length === 0) {
|
|
462
|
+
lines.push(" no active decisions downstream");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (report.alreadyExpired.length > 0) {
|
|
467
|
+
lines.push("", "already expired (candidate scar tissue, report-only):");
|
|
468
|
+
for (const item of report.alreadyExpired) {
|
|
469
|
+
lines.push(` ${item.concept}${item.expired_on === null ? "" : ` (expired_on ${item.expired_on})`}`);
|
|
470
|
+
for (const decision of item.decisions) {
|
|
471
|
+
lines.push(` ${decision} is still active`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
lines.push("");
|
|
476
|
+
if (report.expired.length > 0) {
|
|
477
|
+
lines.push(`${plural(report.expired.length, "constraint")} expired this run — the archive learned something (exit 1)`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
lines.push("nothing newly expired");
|
|
481
|
+
}
|
|
482
|
+
return lines;
|
|
483
|
+
}
|
package/dist/blame.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type AnchorIndex, type LineRange } from "./anchors.js";
|
|
2
|
+
import type { Anchor, Confidence, WhyBundle } from "./bundle.js";
|
|
3
|
+
export { parseLineRange } from "./anchors.js";
|
|
4
|
+
export type { LineRange } from "./anchors.js";
|
|
5
|
+
/** The target spec was malformed — a usage error, not an operational one. */
|
|
6
|
+
export declare class BlameTargetError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export interface BlameTarget {
|
|
9
|
+
path: string;
|
|
10
|
+
/** Absent = the whole file. */
|
|
11
|
+
lines?: LineRange;
|
|
12
|
+
}
|
|
13
|
+
/** Parse `<path>[:line[-line]]`; a non-numeric suffix is part of the path. */
|
|
14
|
+
export declare function parseBlameTarget(spec: string): BlameTarget;
|
|
15
|
+
export declare function normalizePath(path: string): string;
|
|
16
|
+
export declare function formatTarget(target: BlameTarget): string;
|
|
17
|
+
/** One edge endpoint or downstream note, resolved to its concept when possible. */
|
|
18
|
+
export interface BlameEdge {
|
|
19
|
+
title: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
status?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Typed edges out of a story hit, grouped by relation (docs/ui-contract.md). */
|
|
25
|
+
export interface BlameEdges {
|
|
26
|
+
becauseOf: BlameEdge[];
|
|
27
|
+
insteadOf: BlameEdge[];
|
|
28
|
+
supersededBy: BlameEdge[];
|
|
29
|
+
}
|
|
30
|
+
/** One `# Citations` entry: display label plus the link target. */
|
|
31
|
+
export interface BlameCitation {
|
|
32
|
+
label: string;
|
|
33
|
+
url: string;
|
|
34
|
+
}
|
|
35
|
+
/** One concept in the story, fully resolved for rendering or `--json`. */
|
|
36
|
+
export interface BlameBlock {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
type: string;
|
|
40
|
+
status?: string;
|
|
41
|
+
happened_on?: string;
|
|
42
|
+
expired_on?: string;
|
|
43
|
+
confidence?: Confidence;
|
|
44
|
+
/** The one-line rationale as written; `renderedRationale` is the hedged form. */
|
|
45
|
+
description: string;
|
|
46
|
+
/** True when confidence sits below `corroborated` (questions never hedge). */
|
|
47
|
+
hedged: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The rationale with its mandatory hedge prefix baked in (DESIGN.md §2 made
|
|
50
|
+
* structural): renderers display this verbatim and must not re-derive
|
|
51
|
+
* hedging — a renderer that ignores `confidence` still cannot show
|
|
52
|
+
* unhedged speculation. Empty when there is no rationale to show.
|
|
53
|
+
*/
|
|
54
|
+
renderedRationale: string;
|
|
55
|
+
/** Anchors of this concept that cover the target (empty on warning blocks). */
|
|
56
|
+
anchors: Anchor[];
|
|
57
|
+
edges: BlameEdges;
|
|
58
|
+
citations: BlameCitation[];
|
|
59
|
+
/** Short citation labels — the `evidence ▸` line. */
|
|
60
|
+
evidence: string[];
|
|
61
|
+
/** Expired constraints only: active decisions reachable via `# Led to`. */
|
|
62
|
+
downstream: BlameEdge[];
|
|
63
|
+
}
|
|
64
|
+
export interface NearbyConcept {
|
|
65
|
+
id: string;
|
|
66
|
+
title: string;
|
|
67
|
+
type: string;
|
|
68
|
+
status?: string;
|
|
69
|
+
anchor: Anchor;
|
|
70
|
+
}
|
|
71
|
+
/** Major version of the story payload — see docs/ui-contract.md for the policy. */
|
|
72
|
+
export declare const STORY_SCHEMA_VERSION = 1;
|
|
73
|
+
export interface BlameReport {
|
|
74
|
+
schemaVersion: typeof STORY_SCHEMA_VERSION;
|
|
75
|
+
target: BlameTarget;
|
|
76
|
+
/** Narrowest covering anchor span — the output header. */
|
|
77
|
+
span?: string;
|
|
78
|
+
/** Concepts anchored on the target, newest first. */
|
|
79
|
+
hits: BlameBlock[];
|
|
80
|
+
/** Expired constraints not already matched: never invisible (DESIGN.md §7). */
|
|
81
|
+
warnings: BlameBlock[];
|
|
82
|
+
/** When nothing matches: anchored concepts nearest the target's directory. */
|
|
83
|
+
nearby: NearbyConcept[];
|
|
84
|
+
}
|
|
85
|
+
export declare function buildBlameReport(bundle: WhyBundle, target: BlameTarget, index?: AnchorIndex): BlameReport;
|
|
86
|
+
/** Status glyph vocabulary — shared with `why export ui-index` (docs/ui-contract.md). */
|
|
87
|
+
export type Glyph = "●" | "⚠" | "?";
|
|
88
|
+
export declare function glyphFor(type: string, status: string | undefined): Glyph;
|
|
89
|
+
/** `path[:lines]` — also the span format `why anchor` and `why doctor` print. */
|
|
90
|
+
export declare function anchorSpan(anchor: Pick<Anchor, "path" | "lines">): string;
|
|
91
|
+
export declare function renderBlameReport(report: BlameReport): string[];
|