@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/blame.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// `why blame` (static, DESIGN.md §7): match anchors to a target span via the
|
|
2
|
+
// anchor index, expand one hop along typed edges, render the story. Blame
|
|
3
|
+
// trusts anchors as written — no re-resolution — but a `lost` anchor is a
|
|
4
|
+
// claim that no longer holds, so it never matches: anchors are live or lost,
|
|
5
|
+
// never silently wrong.
|
|
6
|
+
import { deriveTitle, extractCitations } from "@copperbox/okf-mcp";
|
|
7
|
+
import { buildAnchorIndex, indexedConcept, lookupAnchors, parseLineRange, } from "./anchors.js";
|
|
8
|
+
export { parseLineRange } from "./anchors.js";
|
|
9
|
+
/** The target spec was malformed — a usage error, not an operational one. */
|
|
10
|
+
export class BlameTargetError extends Error {
|
|
11
|
+
}
|
|
12
|
+
/** Parse `<path>[:line[-line]]`; a non-numeric suffix is part of the path. */
|
|
13
|
+
export function parseBlameTarget(spec) {
|
|
14
|
+
const match = /^(.+):(\d+)(?:-(\d+))?$/.exec(spec);
|
|
15
|
+
if (!match)
|
|
16
|
+
return { path: normalizePath(spec) };
|
|
17
|
+
const start = Number(match[2]);
|
|
18
|
+
const end = match[3] === undefined ? start : Number(match[3]);
|
|
19
|
+
if (start < 1 || end < start) {
|
|
20
|
+
throw new BlameTargetError(`"${spec}": line ranges are 1-based low-high, like src/lock.rs:41-58`);
|
|
21
|
+
}
|
|
22
|
+
return { path: normalizePath(match[1]), lines: { start, end } };
|
|
23
|
+
}
|
|
24
|
+
export function normalizePath(path) {
|
|
25
|
+
return path.replace(/^\.\//, "");
|
|
26
|
+
}
|
|
27
|
+
export function formatTarget(target) {
|
|
28
|
+
if (!target.lines)
|
|
29
|
+
return target.path;
|
|
30
|
+
const { start, end } = target.lines;
|
|
31
|
+
return `${target.path}:${start}${end === start ? "" : `-${end}`}`;
|
|
32
|
+
}
|
|
33
|
+
/** Major version of the story payload — see docs/ui-contract.md for the policy. */
|
|
34
|
+
export const STORY_SCHEMA_VERSION = 1;
|
|
35
|
+
function newestFirst(a, b) {
|
|
36
|
+
const byDate = (b.why.happened_on ?? "").localeCompare(a.why.happened_on ?? "");
|
|
37
|
+
return byDate !== 0 ? byDate : a.id.localeCompare(b.id);
|
|
38
|
+
}
|
|
39
|
+
function edgeFrom(bundle, link) {
|
|
40
|
+
const neighbor = link.resolvedId ? bundle.concepts.get(link.resolvedId) : undefined;
|
|
41
|
+
if (!neighbor)
|
|
42
|
+
return { title: link.text };
|
|
43
|
+
const edge = { title: deriveTitle(neighbor), id: neighbor.id, type: neighbor.frontmatter.type };
|
|
44
|
+
if (neighbor.why.status !== undefined)
|
|
45
|
+
edge.status = neighbor.why.status;
|
|
46
|
+
return edge;
|
|
47
|
+
}
|
|
48
|
+
function edgesIn(bundle, concept, section) {
|
|
49
|
+
return concept.links
|
|
50
|
+
.filter((link) => link.section?.toLowerCase() === section)
|
|
51
|
+
.map((link) => edgeFrom(bundle, link));
|
|
52
|
+
}
|
|
53
|
+
function citationsOf(bundle, concept) {
|
|
54
|
+
const { citations } = extractCitations(concept.body, concept.path, (id) => bundle.concepts.has(id));
|
|
55
|
+
return citations.map((c) => ({ label: c.text, url: c.target }));
|
|
56
|
+
}
|
|
57
|
+
/** `PR #212: replace striped locks…` reads as `PR #212` on one evidence line. */
|
|
58
|
+
function evidenceLabels(citations) {
|
|
59
|
+
const labels = citations.map((c) => c.label.split(":")[0].trim()).filter((label) => label !== "");
|
|
60
|
+
return [...new Set(labels)];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Hedging is engine logic, precomputed into the payload: anything below
|
|
64
|
+
* `corroborated` hedges, and an unstated confidence hedges hardest — the data
|
|
65
|
+
* may never hedge less than the evidence supports. Questions carry no
|
|
66
|
+
* rationale to hedge.
|
|
67
|
+
*/
|
|
68
|
+
function hedgePrefix(type, confidence) {
|
|
69
|
+
if (type === "question")
|
|
70
|
+
return "";
|
|
71
|
+
switch (confidence) {
|
|
72
|
+
case "recorded":
|
|
73
|
+
case "corroborated":
|
|
74
|
+
return "";
|
|
75
|
+
case "inferred":
|
|
76
|
+
return "likely — ";
|
|
77
|
+
default:
|
|
78
|
+
return "speculation, thin evidence — ";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isExpiredConstraint(type, status) {
|
|
82
|
+
return type === "constraint" && status === "expired";
|
|
83
|
+
}
|
|
84
|
+
function toBlock(bundle, concept, anchors) {
|
|
85
|
+
const type = concept.frontmatter.type;
|
|
86
|
+
const description = oneLiner(concept);
|
|
87
|
+
const hedge = hedgePrefix(type, concept.why.confidence);
|
|
88
|
+
const citations = citationsOf(bundle, concept);
|
|
89
|
+
const block = {
|
|
90
|
+
id: concept.id,
|
|
91
|
+
title: deriveTitle(concept),
|
|
92
|
+
type,
|
|
93
|
+
description,
|
|
94
|
+
hedged: hedge !== "",
|
|
95
|
+
renderedRationale: description === "" ? "" : `${hedge}${description}`,
|
|
96
|
+
anchors,
|
|
97
|
+
edges: {
|
|
98
|
+
becauseOf: edgesIn(bundle, concept, "because of"),
|
|
99
|
+
insteadOf: edgesIn(bundle, concept, "instead of"),
|
|
100
|
+
supersededBy: edgesIn(bundle, concept, "superseded by"),
|
|
101
|
+
},
|
|
102
|
+
citations,
|
|
103
|
+
evidence: evidenceLabels(citations),
|
|
104
|
+
downstream: [],
|
|
105
|
+
};
|
|
106
|
+
if (concept.why.status !== undefined)
|
|
107
|
+
block.status = concept.why.status;
|
|
108
|
+
if (concept.why.happened_on !== undefined)
|
|
109
|
+
block.happened_on = concept.why.happened_on;
|
|
110
|
+
if (concept.why.expired_on !== undefined)
|
|
111
|
+
block.expired_on = concept.why.expired_on;
|
|
112
|
+
if (concept.why.confidence !== undefined)
|
|
113
|
+
block.confidence = concept.why.confidence;
|
|
114
|
+
if (isExpiredConstraint(block.type, block.status)) {
|
|
115
|
+
// The §5 payoff: what downstream shape may now be scar tissue.
|
|
116
|
+
block.downstream = edgesIn(bundle, concept, "led to").filter((edge) => edge.type === "decision" && edge.status === "active");
|
|
117
|
+
}
|
|
118
|
+
return block;
|
|
119
|
+
}
|
|
120
|
+
function oneLiner(concept) {
|
|
121
|
+
if (concept.frontmatter.description)
|
|
122
|
+
return concept.frontmatter.description;
|
|
123
|
+
const firstProse = concept.sections.find((s) => s.content !== "")?.content ?? "";
|
|
124
|
+
return firstProse.split("\n")[0] ?? "";
|
|
125
|
+
}
|
|
126
|
+
function dirOf(path) {
|
|
127
|
+
const segments = path.split("/");
|
|
128
|
+
segments.pop();
|
|
129
|
+
return segments;
|
|
130
|
+
}
|
|
131
|
+
function dirDistance(a, b) {
|
|
132
|
+
let shared = 0;
|
|
133
|
+
while (shared < a.length && shared < b.length && a[shared] === b[shared])
|
|
134
|
+
shared++;
|
|
135
|
+
return a.length - shared + (b.length - shared);
|
|
136
|
+
}
|
|
137
|
+
const NEARBY_LIMIT = 5;
|
|
138
|
+
function nearestAnchored(bundle, target) {
|
|
139
|
+
const targetDir = dirOf(target.path);
|
|
140
|
+
const ranked = [];
|
|
141
|
+
for (const concept of bundle.concepts.values()) {
|
|
142
|
+
let best;
|
|
143
|
+
for (const anchor of concept.why.anchors) {
|
|
144
|
+
if (anchor.state === "lost")
|
|
145
|
+
continue;
|
|
146
|
+
const distance = dirDistance(targetDir, dirOf(anchor.path));
|
|
147
|
+
if (!best || distance < best.distance)
|
|
148
|
+
best = { distance, anchor };
|
|
149
|
+
}
|
|
150
|
+
if (!best)
|
|
151
|
+
continue;
|
|
152
|
+
const entry = {
|
|
153
|
+
id: concept.id,
|
|
154
|
+
title: deriveTitle(concept),
|
|
155
|
+
type: concept.frontmatter.type,
|
|
156
|
+
anchor: best.anchor,
|
|
157
|
+
};
|
|
158
|
+
if (concept.why.status !== undefined)
|
|
159
|
+
entry.status = concept.why.status;
|
|
160
|
+
ranked.push({ distance: best.distance, entry });
|
|
161
|
+
}
|
|
162
|
+
ranked.sort((a, b) => a.distance - b.distance || a.entry.title.localeCompare(b.entry.title));
|
|
163
|
+
return ranked.slice(0, NEARBY_LIMIT).map((r) => r.entry);
|
|
164
|
+
}
|
|
165
|
+
/** The narrowest covering span, README-style: `src/lock.rs:41-58 · acquire_shared`. */
|
|
166
|
+
function spanOf(anchors) {
|
|
167
|
+
let best;
|
|
168
|
+
for (const anchor of anchors) {
|
|
169
|
+
const range = anchor.lines === undefined ? undefined : parseLineRange(anchor.lines);
|
|
170
|
+
const width = range ? range.end - range.start : Number.POSITIVE_INFINITY;
|
|
171
|
+
if (!best || width < best.width)
|
|
172
|
+
best = { anchor, width };
|
|
173
|
+
}
|
|
174
|
+
if (!best)
|
|
175
|
+
return undefined;
|
|
176
|
+
const { anchor } = best;
|
|
177
|
+
const lines = anchor.lines === undefined ? "" : `:${anchor.lines}`;
|
|
178
|
+
const symbol = anchor.symbol === undefined ? "" : ` · ${anchor.symbol}`;
|
|
179
|
+
return `${anchor.path}${lines}${symbol}`;
|
|
180
|
+
}
|
|
181
|
+
export function buildBlameReport(bundle, target, index = buildAnchorIndex(bundle)) {
|
|
182
|
+
const hitsByConcept = new Map();
|
|
183
|
+
for (const hit of lookupAnchors(index, target)) {
|
|
184
|
+
const hits = hitsByConcept.get(hit.conceptId);
|
|
185
|
+
if (hits)
|
|
186
|
+
hits.push(hit);
|
|
187
|
+
else
|
|
188
|
+
hitsByConcept.set(hit.conceptId, [hit]);
|
|
189
|
+
}
|
|
190
|
+
const matched = [...hitsByConcept.entries()].map(([id, hits]) => {
|
|
191
|
+
const concept = indexedConcept(bundle, id);
|
|
192
|
+
hits.sort((a, b) => a.anchorIndex - b.anchorIndex); // anchors in written order
|
|
193
|
+
return { concept, anchors: hits.map((hit) => hit.anchor) };
|
|
194
|
+
});
|
|
195
|
+
matched.sort((a, b) => newestFirst(a.concept, b.concept));
|
|
196
|
+
const hits = matched.map(({ concept, anchors }) => toBlock(bundle, concept, anchors));
|
|
197
|
+
const matchedIds = new Set(hits.map((block) => block.id));
|
|
198
|
+
const warnings = [...bundle.concepts.values()]
|
|
199
|
+
.filter((concept) => isExpiredConstraint(concept.frontmatter.type, concept.why.status) && !matchedIds.has(concept.id))
|
|
200
|
+
.sort(newestFirst)
|
|
201
|
+
.map((concept) => toBlock(bundle, concept, []));
|
|
202
|
+
const report = { schemaVersion: STORY_SCHEMA_VERSION, target, hits, warnings, nearby: [] };
|
|
203
|
+
const span = spanOf(matched.flatMap((m) => m.anchors));
|
|
204
|
+
if (span !== undefined)
|
|
205
|
+
report.span = span;
|
|
206
|
+
if (hits.length === 0)
|
|
207
|
+
report.nearby = nearestAnchored(bundle, target);
|
|
208
|
+
return report;
|
|
209
|
+
}
|
|
210
|
+
// --- Rendering ---------------------------------------------------------------
|
|
211
|
+
const TITLE_COLUMN = 40;
|
|
212
|
+
export function glyphFor(type, status) {
|
|
213
|
+
if (type === "question")
|
|
214
|
+
return "?";
|
|
215
|
+
if (status === "expired" || status === "superseded")
|
|
216
|
+
return "⚠";
|
|
217
|
+
return "●";
|
|
218
|
+
}
|
|
219
|
+
function metaLine(block) {
|
|
220
|
+
const parts = [block.type];
|
|
221
|
+
if (isExpiredConstraint(block.type, block.status)) {
|
|
222
|
+
parts.push(`EXPIRED ${block.expired_on ?? "(date unknown)"}`);
|
|
223
|
+
return parts.join(" · ");
|
|
224
|
+
}
|
|
225
|
+
if (block.happened_on !== undefined)
|
|
226
|
+
parts.push(block.happened_on);
|
|
227
|
+
if (block.type === "question") {
|
|
228
|
+
if (block.status !== undefined)
|
|
229
|
+
parts.push(block.status);
|
|
230
|
+
}
|
|
231
|
+
else if (block.confidence !== undefined) {
|
|
232
|
+
parts.push(block.confidence);
|
|
233
|
+
}
|
|
234
|
+
return parts.join(" · ");
|
|
235
|
+
}
|
|
236
|
+
function edgeText(edge, withStatus) {
|
|
237
|
+
if (edge.type === undefined)
|
|
238
|
+
return edge.title;
|
|
239
|
+
const status = withStatus && edge.status !== undefined ? ` — ${edge.status}` : "";
|
|
240
|
+
return `${edge.title} (${edge.type}${status})`;
|
|
241
|
+
}
|
|
242
|
+
function renderBlock(block) {
|
|
243
|
+
const lines = [];
|
|
244
|
+
const head = `${glyphFor(block.type, block.status)} ${block.title}`;
|
|
245
|
+
lines.push(` ${head.padEnd(TITLE_COLUMN)} ${metaLine(block)}`);
|
|
246
|
+
// The hedge is already baked into renderedRationale — display it verbatim.
|
|
247
|
+
if (block.renderedRationale !== "")
|
|
248
|
+
lines.push(` ${block.renderedRationale}`);
|
|
249
|
+
const edgeLines = [];
|
|
250
|
+
for (const edge of block.edges.becauseOf)
|
|
251
|
+
edgeLines.push(["because of", edgeText(edge, false)]);
|
|
252
|
+
for (const edge of block.edges.insteadOf)
|
|
253
|
+
edgeLines.push(["instead of", edgeText(edge, true)]);
|
|
254
|
+
for (const edge of block.edges.supersededBy)
|
|
255
|
+
edgeLines.push(["superseded by", edgeText(edge, false)]);
|
|
256
|
+
if (block.evidence.length > 0)
|
|
257
|
+
edgeLines.push(["evidence", block.evidence.join(", ")]);
|
|
258
|
+
const labelWidth = Math.max(10, ...edgeLines.map(([label]) => label.length));
|
|
259
|
+
for (const [label, text] of edgeLines)
|
|
260
|
+
lines.push(` ${label.padEnd(labelWidth)} ▸ ${text}`);
|
|
261
|
+
for (const edge of block.downstream) {
|
|
262
|
+
lines.push(` → downstream decision "${edge.title}" may now be scar tissue.`);
|
|
263
|
+
}
|
|
264
|
+
return lines;
|
|
265
|
+
}
|
|
266
|
+
/** `path[:lines]` — also the span format `why anchor` and `why doctor` print. */
|
|
267
|
+
export function anchorSpan(anchor) {
|
|
268
|
+
return anchor.lines === undefined ? anchor.path : `${anchor.path}:${anchor.lines}`;
|
|
269
|
+
}
|
|
270
|
+
export function renderBlameReport(report) {
|
|
271
|
+
const lines = [];
|
|
272
|
+
if (report.hits.length > 0) {
|
|
273
|
+
if (report.span !== undefined)
|
|
274
|
+
lines.push(report.span);
|
|
275
|
+
for (const block of report.hits)
|
|
276
|
+
lines.push("", ...renderBlock(block));
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
lines.push(`No concepts anchor ${formatTarget(report.target)}.`);
|
|
280
|
+
if (report.nearby.length > 0) {
|
|
281
|
+
lines.push("", "Anchored concepts nearby (nearest first):");
|
|
282
|
+
for (const near of report.nearby) {
|
|
283
|
+
const head = `${glyphFor(near.type, near.status)} ${near.title}`;
|
|
284
|
+
lines.push(` ${head.padEnd(TITLE_COLUMN)} ${near.type} · ${anchorSpan(near.anchor)}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
lines.push("", "This bundle has no anchored concepts yet — add why.anchors frontmatter to place its story in the code.");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
for (const block of report.warnings)
|
|
292
|
+
lines.push("", ...renderBlock(block));
|
|
293
|
+
return lines;
|
|
294
|
+
}
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { BodySection, ConceptFrontmatter, ConceptLink, LoadedBundle } from "@copperbox/okf-mcp";
|
|
2
|
+
export declare const CONCEPT_TYPES: readonly ["decision", "constraint", "attempt", "incident", "question"];
|
|
3
|
+
export type ConceptType = (typeof CONCEPT_TYPES)[number];
|
|
4
|
+
/** Per-type `status` vocabulary (DESIGN.md §2 field table). */
|
|
5
|
+
export declare const STATUS_VOCAB: Record<ConceptType, readonly string[]>;
|
|
6
|
+
/** The confidence ladder (DESIGN.md §2), strongest first. */
|
|
7
|
+
export declare const CONFIDENCE_LEVELS: readonly ["recorded", "corroborated", "inferred", "speculative"];
|
|
8
|
+
export type Confidence = (typeof CONFIDENCE_LEVELS)[number];
|
|
9
|
+
export declare const ANCHOR_STATES: readonly ["live", "lost"];
|
|
10
|
+
export type AnchorState = (typeof ANCHOR_STATES)[number];
|
|
11
|
+
/** An anchor is a claim: at commit `as_of`, this concept was about this span. */
|
|
12
|
+
export interface Anchor {
|
|
13
|
+
path: string;
|
|
14
|
+
symbol?: string;
|
|
15
|
+
/** Line span as written (`"41-58"` or `"31"`); absent = whole file. */
|
|
16
|
+
lines?: string;
|
|
17
|
+
as_of?: string;
|
|
18
|
+
state?: AnchorState;
|
|
19
|
+
}
|
|
20
|
+
export declare const VERIFY_METHODS: readonly ["check", "ask", "review-by"];
|
|
21
|
+
export type VerifyMethod = (typeof VERIFY_METHODS)[number];
|
|
22
|
+
/** A constraint's falsifiability contract (DESIGN.md §5). */
|
|
23
|
+
export interface VerifySpec {
|
|
24
|
+
method: VerifyMethod;
|
|
25
|
+
check?: string;
|
|
26
|
+
ask?: string;
|
|
27
|
+
review_by?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The typed `why:` extension map. Invalid values are dropped to undefined
|
|
31
|
+
* with a diagnostic — in particular an unrecognized `confidence` must never
|
|
32
|
+
* survive into the typed view, or rendering could hedge less than the
|
|
33
|
+
* evidence supports.
|
|
34
|
+
*/
|
|
35
|
+
export interface WhyMeta {
|
|
36
|
+
status?: string;
|
|
37
|
+
happened_on?: string;
|
|
38
|
+
expired_on?: string;
|
|
39
|
+
confidence?: Confidence;
|
|
40
|
+
anchors: Anchor[];
|
|
41
|
+
verify?: VerifySpec;
|
|
42
|
+
}
|
|
43
|
+
/** One problem with a concept's `why:` data, addressed for `why lint`. */
|
|
44
|
+
export interface Diagnostic {
|
|
45
|
+
/** Bundle-relative file path. */
|
|
46
|
+
path: string;
|
|
47
|
+
/** Dotted field the problem is about, e.g. `why.anchors[1].path`. */
|
|
48
|
+
field: string;
|
|
49
|
+
message: string;
|
|
50
|
+
}
|
|
51
|
+
/** A body link plus the heading of the section it appears under (DESIGN.md §3). */
|
|
52
|
+
export interface SectionedLink extends ConceptLink {
|
|
53
|
+
section?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface WhyConcept {
|
|
56
|
+
id: string;
|
|
57
|
+
/** Bundle-relative file path. */
|
|
58
|
+
path: string;
|
|
59
|
+
frontmatter: ConceptFrontmatter;
|
|
60
|
+
why: WhyMeta;
|
|
61
|
+
body: string;
|
|
62
|
+
sections: BodySection[];
|
|
63
|
+
links: SectionedLink[];
|
|
64
|
+
}
|
|
65
|
+
export interface WhyBundle {
|
|
66
|
+
/** Absolute path to the bundle root. */
|
|
67
|
+
root: string;
|
|
68
|
+
concepts: Map<string, WhyConcept>;
|
|
69
|
+
/** `why:` schema problems, per concept. */
|
|
70
|
+
diagnostics: Diagnostic[];
|
|
71
|
+
/** The underlying okf-mcp bundle — OKF-level problems and validation live there. */
|
|
72
|
+
okf: LoadedBundle;
|
|
73
|
+
}
|
|
74
|
+
export declare function isPlainMap(value: unknown): value is Record<string, unknown>;
|
|
75
|
+
/** Vocabulary membership as a type guard, so callers narrow instead of casting. */
|
|
76
|
+
export declare function isOneOf<T extends string>(vocab: readonly T[], value: unknown): value is T;
|
|
77
|
+
/** Load a `.why/` bundle from disk into the typed view. Never throws on content. */
|
|
78
|
+
export declare function loadBundle(root: string): Promise<WhyBundle>;
|
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Schema-aware bundle loading: okf-mcp parses the OKF layer (frontmatter,
|
|
2
|
+
// links, sections); this module types the `why:` extension map on top
|
|
3
|
+
// (DESIGN.md §2) and tags each link with the section it appears in (§3).
|
|
4
|
+
// Loading is permissive in the OKF spirit: malformed `why:` data becomes a
|
|
5
|
+
// diagnostic, never an exception — `why lint` renders the diagnostics.
|
|
6
|
+
import { loadBundle as loadOkfBundle, sectionAt, splitSections, } from "@copperbox/okf-mcp";
|
|
7
|
+
export const CONCEPT_TYPES = ["decision", "constraint", "attempt", "incident", "question"];
|
|
8
|
+
/** Per-type `status` vocabulary (DESIGN.md §2 field table). */
|
|
9
|
+
export const STATUS_VOCAB = {
|
|
10
|
+
decision: ["active", "superseded", "reversed"],
|
|
11
|
+
constraint: ["active", "expired", "unknown"],
|
|
12
|
+
attempt: ["failed", "abandoned", "partial"],
|
|
13
|
+
incident: ["resolved", "recurring"],
|
|
14
|
+
question: ["open", "answered"],
|
|
15
|
+
};
|
|
16
|
+
/** The confidence ladder (DESIGN.md §2), strongest first. */
|
|
17
|
+
export const CONFIDENCE_LEVELS = ["recorded", "corroborated", "inferred", "speculative"];
|
|
18
|
+
export const ANCHOR_STATES = ["live", "lost"];
|
|
19
|
+
export const VERIFY_METHODS = ["check", "ask", "review-by"];
|
|
20
|
+
const WHY_KEYS = new Set(["status", "happened_on", "expired_on", "confidence", "anchors", "verify"]);
|
|
21
|
+
export function isPlainMap(value) {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
/** Vocabulary membership as a type guard, so callers narrow instead of casting. */
|
|
25
|
+
export function isOneOf(vocab, value) {
|
|
26
|
+
return vocab.includes(value);
|
|
27
|
+
}
|
|
28
|
+
/** YAML reads a bare `31` or all-digit sha as a number; both mean the string. */
|
|
29
|
+
function asStringy(value) {
|
|
30
|
+
if (typeof value === "string")
|
|
31
|
+
return value;
|
|
32
|
+
if (typeof value === "number")
|
|
33
|
+
return String(value);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
function list(values) {
|
|
37
|
+
return values.join(", ");
|
|
38
|
+
}
|
|
39
|
+
function readAnchors(value, push) {
|
|
40
|
+
if (!Array.isArray(value)) {
|
|
41
|
+
push("why.anchors", "must be a list of anchor maps");
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const anchors = [];
|
|
45
|
+
value.forEach((entry, i) => {
|
|
46
|
+
const at = `why.anchors[${i}]`;
|
|
47
|
+
if (!isPlainMap(entry)) {
|
|
48
|
+
push(at, "must be a map with at least a path");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const path = asStringy(entry.path);
|
|
52
|
+
if (path === undefined) {
|
|
53
|
+
push(`${at}.path`, "is required and must be a string");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const anchor = { path };
|
|
57
|
+
if (entry.symbol !== undefined) {
|
|
58
|
+
const symbol = asStringy(entry.symbol);
|
|
59
|
+
if (symbol === undefined)
|
|
60
|
+
push(`${at}.symbol`, "must be a string");
|
|
61
|
+
else
|
|
62
|
+
anchor.symbol = symbol;
|
|
63
|
+
}
|
|
64
|
+
if (entry.lines !== undefined) {
|
|
65
|
+
const lines = asStringy(entry.lines);
|
|
66
|
+
if (lines === undefined)
|
|
67
|
+
push(`${at}.lines`, 'must be a line or range like "41-58"');
|
|
68
|
+
else
|
|
69
|
+
anchor.lines = lines;
|
|
70
|
+
}
|
|
71
|
+
if (entry.as_of !== undefined) {
|
|
72
|
+
const asOf = asStringy(entry.as_of);
|
|
73
|
+
if (asOf === undefined)
|
|
74
|
+
push(`${at}.as_of`, "must be a commit id string");
|
|
75
|
+
else
|
|
76
|
+
anchor.as_of = asOf;
|
|
77
|
+
}
|
|
78
|
+
if (entry.state !== undefined) {
|
|
79
|
+
if (isOneOf(ANCHOR_STATES, entry.state)) {
|
|
80
|
+
anchor.state = entry.state;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
push(`${at}.state`, `"${String(entry.state)}" is not an anchor state: allowed values are ${list(ANCHOR_STATES)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
anchors.push(anchor);
|
|
87
|
+
});
|
|
88
|
+
return anchors;
|
|
89
|
+
}
|
|
90
|
+
/** The frontmatter key each verify method requires its detail under. */
|
|
91
|
+
const VERIFY_DETAIL_KEYS = {
|
|
92
|
+
check: "check",
|
|
93
|
+
ask: "ask",
|
|
94
|
+
"review-by": "review_by",
|
|
95
|
+
};
|
|
96
|
+
function readVerify(value, push) {
|
|
97
|
+
if (!isPlainMap(value)) {
|
|
98
|
+
push("why.verify", "must be a map with a method (DESIGN.md §5)");
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const method = value.method;
|
|
102
|
+
if (!isOneOf(VERIFY_METHODS, method)) {
|
|
103
|
+
push("why.verify.method", `"${String(method)}" is not a verify method: allowed values are ${list(VERIFY_METHODS)}`);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const spec = { method };
|
|
107
|
+
const detailKey = VERIFY_DETAIL_KEYS[spec.method];
|
|
108
|
+
const detail = asStringy(value[detailKey]);
|
|
109
|
+
if (detail === undefined) {
|
|
110
|
+
push(`why.verify.${detailKey}`, `method "${spec.method}" requires a "${detailKey}" value`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
spec[detailKey] = detail;
|
|
114
|
+
}
|
|
115
|
+
return spec;
|
|
116
|
+
}
|
|
117
|
+
function readWhyMeta(frontmatter, path, diagnostics) {
|
|
118
|
+
const push = (field, message) => diagnostics.push({ path, field, message });
|
|
119
|
+
const meta = { anchors: [] };
|
|
120
|
+
const raw = frontmatter.why;
|
|
121
|
+
if (raw === undefined)
|
|
122
|
+
return meta;
|
|
123
|
+
if (!isPlainMap(raw)) {
|
|
124
|
+
push("why", "must be a map (DESIGN.md §2)");
|
|
125
|
+
return meta;
|
|
126
|
+
}
|
|
127
|
+
for (const key of Object.keys(raw)) {
|
|
128
|
+
if (!WHY_KEYS.has(key)) {
|
|
129
|
+
push(`why.${key}`, `unrecognized key: known keys are ${list([...WHY_KEYS])}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (raw.status !== undefined) {
|
|
133
|
+
const status = asStringy(raw.status);
|
|
134
|
+
if (status === undefined) {
|
|
135
|
+
push("why.status", "must be a string");
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
meta.status = status;
|
|
139
|
+
const vocab = isOneOf(CONCEPT_TYPES, frontmatter.type) ? STATUS_VOCAB[frontmatter.type] : undefined;
|
|
140
|
+
if (vocab && !vocab.includes(status)) {
|
|
141
|
+
push("why.status", `"${status}" is not a ${frontmatter.type} status: allowed values are ${list(vocab)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const key of ["happened_on", "expired_on"]) {
|
|
146
|
+
if (raw[key] !== undefined) {
|
|
147
|
+
const date = asStringy(raw[key]);
|
|
148
|
+
if (date === undefined)
|
|
149
|
+
push(`why.${key}`, "must be a date string");
|
|
150
|
+
else
|
|
151
|
+
meta[key] = date;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (raw.confidence !== undefined) {
|
|
155
|
+
if (isOneOf(CONFIDENCE_LEVELS, raw.confidence)) {
|
|
156
|
+
meta.confidence = raw.confidence;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
push("why.confidence", `"${String(raw.confidence)}" is not a confidence level: allowed values are ${list(CONFIDENCE_LEVELS)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (raw.anchors !== undefined)
|
|
163
|
+
meta.anchors = readAnchors(raw.anchors, push);
|
|
164
|
+
if (raw.verify !== undefined)
|
|
165
|
+
meta.verify = readVerify(raw.verify, push);
|
|
166
|
+
return meta;
|
|
167
|
+
}
|
|
168
|
+
/** Load a `.why/` bundle from disk into the typed view. Never throws on content. */
|
|
169
|
+
export async function loadBundle(root) {
|
|
170
|
+
const okf = await loadOkfBundle({ id: "why", root });
|
|
171
|
+
const concepts = new Map();
|
|
172
|
+
const diagnostics = [];
|
|
173
|
+
for (const [id, concept] of okf.concepts) {
|
|
174
|
+
concepts.set(id, {
|
|
175
|
+
id,
|
|
176
|
+
path: concept.path,
|
|
177
|
+
frontmatter: concept.frontmatter,
|
|
178
|
+
why: readWhyMeta(concept.frontmatter, concept.path, diagnostics),
|
|
179
|
+
body: concept.body,
|
|
180
|
+
sections: splitSections(concept.body),
|
|
181
|
+
links: concept.links.map((link) => ({
|
|
182
|
+
...link,
|
|
183
|
+
section: sectionAt(concept.body, link.targetStart),
|
|
184
|
+
})),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return { root: okf.root, concepts, diagnostics, okf };
|
|
188
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type WhyBundle } from "./bundle.js";
|
|
2
|
+
import { type CommandRunner } from "./evidence.js";
|
|
3
|
+
import { type Finding } from "./lint.js";
|
|
4
|
+
/** A capture step that must stop the command cleanly (exit 1), not crash. */
|
|
5
|
+
export declare class CaptureError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Where drafts live, relative to the bundle root. Must stay a dot-directory:
|
|
9
|
+
* okf-mcp's bundle walk skips dot-dirs only, and drafts must never serve.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DRAFTS_DIRNAME = ".drafts";
|
|
12
|
+
/** Suffix of the evidence pack written beside each draft. */
|
|
13
|
+
export declare const EVIDENCE_SUFFIX = ".evidence.md";
|
|
14
|
+
/** Above this many hunks a file gets one whole-file anchor, said out loud. */
|
|
15
|
+
export declare const MAX_HUNK_ANCHORS_PER_FILE = 4;
|
|
16
|
+
/** Rationale candidates quoted into the draft; the rest point at the pack. */
|
|
17
|
+
export declare const MAX_RATIONALE_CANDIDATES = 12;
|
|
18
|
+
/**
|
|
19
|
+
* A discussion paragraph is a rationale candidate when it matches this —
|
|
20
|
+
* dig's comment-tell vocabulary widened with decision language.
|
|
21
|
+
*/
|
|
22
|
+
export declare const RATIONALE_TELL_RE: RegExp;
|
|
23
|
+
export interface CaptureResult {
|
|
24
|
+
/** Absolute path of the draft concept file. */
|
|
25
|
+
draftPath: string;
|
|
26
|
+
/** Absolute path of the evidence pack written beside it. */
|
|
27
|
+
evidencePath: string;
|
|
28
|
+
type: "decision" | "attempt";
|
|
29
|
+
candidateCount: number;
|
|
30
|
+
anchorCount: number;
|
|
31
|
+
/** Degradations and collapses, said out loud (also embedded in the draft). */
|
|
32
|
+
notes: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface CaptureOptions {
|
|
35
|
+
/** Injectable so tests answer gh from fixtures and never hit the network. */
|
|
36
|
+
runner?: CommandRunner;
|
|
37
|
+
}
|
|
38
|
+
interface CapturedAnchor {
|
|
39
|
+
path: string;
|
|
40
|
+
lines?: string;
|
|
41
|
+
as_of: string;
|
|
42
|
+
state: "live";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Derive anchor claims from a zero-context (`-U0`) patch: one anchor per
|
|
46
|
+
* hunk's new-side span, exact to the changed lines. A pure deletion anchors
|
|
47
|
+
* the single line the cut sits after; a file with no hunks (binary,
|
|
48
|
+
* rename-only) anchors whole-file; a deleted file anchors nothing; a file
|
|
49
|
+
* with more than MAX_HUNK_ANCHORS_PER_FILE hunks collapses to one whole-file
|
|
50
|
+
* anchor with a note — never a silent cap.
|
|
51
|
+
*/
|
|
52
|
+
export declare function anchorsFromPatch(patch: string, asOf: string): {
|
|
53
|
+
anchors: CapturedAnchor[];
|
|
54
|
+
files: string[];
|
|
55
|
+
notes: string[];
|
|
56
|
+
};
|
|
57
|
+
/** Short kebab-case slug from a title; empty when nothing survives. */
|
|
58
|
+
export declare function slugify(text: string): string;
|
|
59
|
+
export declare function capturePr(bundle: WhyBundle, n: number, options?: CaptureOptions): Promise<CaptureResult>;
|
|
60
|
+
export declare function captureCommit(bundle: WhyBundle, ref: string, options?: CaptureOptions): Promise<CaptureResult>;
|
|
61
|
+
export interface PromoteResult {
|
|
62
|
+
promoted: boolean;
|
|
63
|
+
/** Bundle-relative target path in the type directory. */
|
|
64
|
+
path: string;
|
|
65
|
+
/** Lint findings on the promoted file (errors when refused, else warnings). */
|
|
66
|
+
findings: Finding[];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Move one draft into its type directory, gated on lint: the concept is
|
|
70
|
+
* written through okf-mcp writeConcept (which stamps `timestamp` and
|
|
71
|
+
* normalizes citations), the reloaded bundle is linted, and any error-severity
|
|
72
|
+
* finding on the new file rolls the write back and keeps the draft. Findings
|
|
73
|
+
* elsewhere in the bundle never block a promotion.
|
|
74
|
+
*/
|
|
75
|
+
export declare function promoteDraft(bundle: WhyBundle, ref: string, cwd: string): Promise<PromoteResult>;
|
|
76
|
+
export {};
|