@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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Symbol resolver (DESIGN.md §4, resolution order step 1).
|
|
2
|
+
//
|
|
3
|
+
// findSymbol re-evaluates the claim "this concept is about the symbol
|
|
4
|
+
// `symbol`, last seen in `path` at commit `as_of`" against HEAD:
|
|
5
|
+
//
|
|
6
|
+
// - The same file still declares the symbol exactly once → its current span.
|
|
7
|
+
// - The symbol is gone from that file (or the file is gone) → search files
|
|
8
|
+
// that git history *connects* to the original path (rename/copy detection
|
|
9
|
+
// via `git log --follow -M -C` over as_of..HEAD). A same-named symbol in
|
|
10
|
+
// a file git does not connect is NOT followed — the never-silently-wrong
|
|
11
|
+
// invariant beats recall, so the result is not-found instead.
|
|
12
|
+
// - More than one declaration in the deciding file, or more than one
|
|
13
|
+
// connected file declaring the symbol → ambiguous, never a guess.
|
|
14
|
+
//
|
|
15
|
+
// Parsing: tree-sitter via WASM (web-tree-sitter + the prebuilt grammars in
|
|
16
|
+
// tree-sitter-wasms, both installed through package.json — nothing is fetched
|
|
17
|
+
// at run time) for TypeScript/TSX/JavaScript, Rust, Python and Go, matching
|
|
18
|
+
// declaration nodes by their `name` field. Any other extension falls back to
|
|
19
|
+
// a line-regex heuristic over common declaration keywords (function / fn /
|
|
20
|
+
// def / class / func). Heuristic results carry confidence: "heuristic" and
|
|
21
|
+
// span only the declaration line itself — the heuristic can locate a
|
|
22
|
+
// declaration but must never fabricate its extent. Tree-sitter results carry
|
|
23
|
+
// confidence: "syntactic" and the full declaration node's span.
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { dirname, extname, join } from "node:path";
|
|
26
|
+
import Parser from "web-tree-sitter";
|
|
27
|
+
import { git, gitOrThrow, resolveAsOfCommit, showFile } from "./git.js";
|
|
28
|
+
// --- tree-sitter setup -------------------------------------------------------
|
|
29
|
+
const GRAMMAR_BY_EXTENSION = {
|
|
30
|
+
".ts": "typescript",
|
|
31
|
+
".mts": "typescript",
|
|
32
|
+
".cts": "typescript",
|
|
33
|
+
".tsx": "tsx",
|
|
34
|
+
".js": "javascript",
|
|
35
|
+
".mjs": "javascript",
|
|
36
|
+
".cjs": "javascript",
|
|
37
|
+
".jsx": "javascript",
|
|
38
|
+
".rs": "rust",
|
|
39
|
+
".py": "python",
|
|
40
|
+
".pyi": "python",
|
|
41
|
+
".go": "go",
|
|
42
|
+
};
|
|
43
|
+
const NAME = { nameField: "name" };
|
|
44
|
+
const MODULE_VAR = { nameField: "name", spanIsParent: true, moduleScopeOnly: true };
|
|
45
|
+
const DECL_RULES = {
|
|
46
|
+
typescript: {
|
|
47
|
+
function_declaration: NAME,
|
|
48
|
+
generator_function_declaration: NAME,
|
|
49
|
+
function_signature: NAME,
|
|
50
|
+
class_declaration: NAME,
|
|
51
|
+
abstract_class_declaration: NAME,
|
|
52
|
+
interface_declaration: NAME,
|
|
53
|
+
type_alias_declaration: NAME,
|
|
54
|
+
enum_declaration: NAME,
|
|
55
|
+
method_definition: NAME,
|
|
56
|
+
method_signature: NAME,
|
|
57
|
+
abstract_method_signature: NAME,
|
|
58
|
+
public_field_definition: NAME,
|
|
59
|
+
variable_declarator: MODULE_VAR,
|
|
60
|
+
},
|
|
61
|
+
javascript: {
|
|
62
|
+
function_declaration: NAME,
|
|
63
|
+
generator_function_declaration: NAME,
|
|
64
|
+
class_declaration: NAME,
|
|
65
|
+
method_definition: NAME,
|
|
66
|
+
field_definition: { nameField: "property" },
|
|
67
|
+
variable_declarator: MODULE_VAR,
|
|
68
|
+
},
|
|
69
|
+
rust: {
|
|
70
|
+
function_item: NAME,
|
|
71
|
+
function_signature_item: NAME,
|
|
72
|
+
struct_item: NAME,
|
|
73
|
+
enum_item: NAME,
|
|
74
|
+
union_item: NAME,
|
|
75
|
+
trait_item: NAME,
|
|
76
|
+
type_item: NAME,
|
|
77
|
+
mod_item: NAME,
|
|
78
|
+
const_item: NAME,
|
|
79
|
+
static_item: NAME,
|
|
80
|
+
macro_definition: NAME,
|
|
81
|
+
},
|
|
82
|
+
python: {
|
|
83
|
+
function_definition: NAME,
|
|
84
|
+
class_definition: NAME,
|
|
85
|
+
assignment: { nameField: "left", nameTypes: ["identifier"], moduleScopeOnly: true },
|
|
86
|
+
},
|
|
87
|
+
go: {
|
|
88
|
+
function_declaration: NAME,
|
|
89
|
+
method_declaration: NAME,
|
|
90
|
+
type_spec: NAME,
|
|
91
|
+
const_spec: NAME,
|
|
92
|
+
var_spec: NAME,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
DECL_RULES.tsx = DECL_RULES.typescript;
|
|
96
|
+
let parserReady;
|
|
97
|
+
let sharedParser;
|
|
98
|
+
const loadedLanguages = new Map();
|
|
99
|
+
function loadLanguage(grammar) {
|
|
100
|
+
let language = loadedLanguages.get(grammar);
|
|
101
|
+
if (language === undefined) {
|
|
102
|
+
language = (async () => {
|
|
103
|
+
parserReady ??= Parser.init();
|
|
104
|
+
await parserReady;
|
|
105
|
+
const require = createRequire(import.meta.url);
|
|
106
|
+
const wasmDir = join(dirname(require.resolve("tree-sitter-wasms/package.json")), "out");
|
|
107
|
+
return Parser.Language.load(join(wasmDir, `tree-sitter-${grammar}.wasm`));
|
|
108
|
+
})();
|
|
109
|
+
loadedLanguages.set(grammar, language);
|
|
110
|
+
}
|
|
111
|
+
return language;
|
|
112
|
+
}
|
|
113
|
+
function nodeSpan(node) {
|
|
114
|
+
const start = node.startPosition.row + 1;
|
|
115
|
+
// Tree-sitter end positions are exclusive: ending at column 0 means the
|
|
116
|
+
// node really ends with the previous line's newline.
|
|
117
|
+
const end = node.endPosition.column === 0 ? node.endPosition.row : node.endPosition.row + 1;
|
|
118
|
+
return { start, end: Math.max(start, end) };
|
|
119
|
+
}
|
|
120
|
+
// Wrappers a module-scope declaration may sit inside without leaving module
|
|
121
|
+
// scope; anything else on the way up (function bodies, blocks) means local.
|
|
122
|
+
const SCOPE_WRAPPERS = new Set([
|
|
123
|
+
"export_statement",
|
|
124
|
+
"expression_statement",
|
|
125
|
+
"lexical_declaration",
|
|
126
|
+
"variable_declaration",
|
|
127
|
+
]);
|
|
128
|
+
const MODULE_ROOTS = new Set(["program", "module", "source_file"]);
|
|
129
|
+
function atModuleScope(node) {
|
|
130
|
+
let cur = node.parent;
|
|
131
|
+
while (cur !== null && SCOPE_WRAPPERS.has(cur.type))
|
|
132
|
+
cur = cur.parent;
|
|
133
|
+
return cur !== null && MODULE_ROOTS.has(cur.type);
|
|
134
|
+
}
|
|
135
|
+
function collectDeclarations(root, rules, symbol) {
|
|
136
|
+
const matches = [];
|
|
137
|
+
const walk = (node) => {
|
|
138
|
+
const rule = rules[node.type];
|
|
139
|
+
if (rule !== undefined) {
|
|
140
|
+
const names = node
|
|
141
|
+
.childrenForFieldName(rule.nameField)
|
|
142
|
+
.filter((n) => rule.nameTypes === undefined || rule.nameTypes.includes(n.type));
|
|
143
|
+
if (names.some((n) => n.text === symbol) &&
|
|
144
|
+
(rule.moduleScopeOnly !== true || atModuleScope(node))) {
|
|
145
|
+
const spanNode = rule.spanIsParent === true && node.parent !== null ? node.parent : node;
|
|
146
|
+
matches.push(nodeSpan(spanNode));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const child of node.namedChildren)
|
|
150
|
+
walk(child);
|
|
151
|
+
};
|
|
152
|
+
walk(root);
|
|
153
|
+
return matches;
|
|
154
|
+
}
|
|
155
|
+
async function syntacticMatches(grammar, content, symbol) {
|
|
156
|
+
const language = await loadLanguage(grammar);
|
|
157
|
+
sharedParser ??= new Parser();
|
|
158
|
+
sharedParser.setLanguage(language);
|
|
159
|
+
const tree = sharedParser.parse(content);
|
|
160
|
+
try {
|
|
161
|
+
return collectDeclarations(tree.rootNode, DECL_RULES[grammar], symbol);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
tree.delete();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// --- regex-heuristic fallback ------------------------------------------------
|
|
168
|
+
function escapeRegExp(text) {
|
|
169
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
170
|
+
}
|
|
171
|
+
function heuristicMatches(content, symbol) {
|
|
172
|
+
const sym = escapeRegExp(symbol);
|
|
173
|
+
const patterns = [
|
|
174
|
+
new RegExp(`(?:^|[^\\w.])function\\*?\\s+${sym}\\s*[(<]`),
|
|
175
|
+
new RegExp(`(?:^|[^\\w.])fn\\s+${sym}\\s*[(<]`),
|
|
176
|
+
new RegExp(`(?:^|[^\\w.])def\\s+${sym}\\s*\\(`),
|
|
177
|
+
new RegExp(`(?:^|[^\\w.])class\\s+${sym}(?:$|[^\\w])`),
|
|
178
|
+
new RegExp(`^\\s*func\\s+(?:\\([^)]*\\)\\s*)?${sym}\\s*\\(`),
|
|
179
|
+
];
|
|
180
|
+
const matches = [];
|
|
181
|
+
const lines = content.split("\n");
|
|
182
|
+
for (let i = 0; i < lines.length; i++) {
|
|
183
|
+
if (patterns.some((p) => p.test(lines[i]))) {
|
|
184
|
+
matches.push({ start: i + 1, end: i + 1 });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return matches;
|
|
188
|
+
}
|
|
189
|
+
/** Declarations of `symbol` in `path` at HEAD, or null if the path is absent. */
|
|
190
|
+
async function matchesInFile(repo, path, symbol) {
|
|
191
|
+
const content = showFile(repo, "HEAD", path);
|
|
192
|
+
if (content === null)
|
|
193
|
+
return null;
|
|
194
|
+
const grammar = GRAMMAR_BY_EXTENSION[extname(path).toLowerCase()];
|
|
195
|
+
if (grammar !== undefined) {
|
|
196
|
+
return { matches: await syntacticMatches(grammar, content, symbol), confidence: "syntactic" };
|
|
197
|
+
}
|
|
198
|
+
return { matches: heuristicMatches(content, symbol), confidence: "heuristic" };
|
|
199
|
+
}
|
|
200
|
+
/** Files at HEAD whose content mentions `symbol` at all (textual pre-filter). */
|
|
201
|
+
function grepCandidates(repo, symbol) {
|
|
202
|
+
const r = git(repo, ["grep", "-l", "--fixed-strings", "-e", symbol, "HEAD"]);
|
|
203
|
+
if (r.status === 1)
|
|
204
|
+
return [];
|
|
205
|
+
if (r.status !== 0)
|
|
206
|
+
throw new Error(`git grep failed: ${r.stderr.trim()}`);
|
|
207
|
+
return r.stdout
|
|
208
|
+
.split("\n")
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
.map((line) => line.replace(/^HEAD:/, ""));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* True when git history connects `candidate` (a path at HEAD) to the anchor's
|
|
214
|
+
* original path within as_of..HEAD: the original name appears in the
|
|
215
|
+
* candidate's `git log --follow -M -C --name-status` output for that window
|
|
216
|
+
* (as the old side of a rename/copy, or as the candidate's own earlier name).
|
|
217
|
+
*/
|
|
218
|
+
function connectedByHistory(repo, asOfSha, originalPath, candidate) {
|
|
219
|
+
const out = gitOrThrow(repo, [
|
|
220
|
+
"log",
|
|
221
|
+
"--follow",
|
|
222
|
+
"-M",
|
|
223
|
+
"-C",
|
|
224
|
+
"--name-status",
|
|
225
|
+
"--format=%H",
|
|
226
|
+
`${asOfSha}..HEAD`,
|
|
227
|
+
"--",
|
|
228
|
+
candidate,
|
|
229
|
+
]);
|
|
230
|
+
for (const line of out.split("\n")) {
|
|
231
|
+
const fields = line.split("\t");
|
|
232
|
+
if (fields.length >= 2 && fields.slice(1).includes(originalPath))
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
export async function findSymbol(repo, anchor) {
|
|
238
|
+
const { path, symbol, asOf } = anchor;
|
|
239
|
+
if (symbol.trim() === "") {
|
|
240
|
+
throw new Error("symbol must be non-empty");
|
|
241
|
+
}
|
|
242
|
+
const asOfSha = resolveAsOfCommit(repo, asOf);
|
|
243
|
+
// Step 1: the anchored file itself, at HEAD.
|
|
244
|
+
const sameFile = await matchesInFile(repo, path, symbol);
|
|
245
|
+
if (sameFile !== null && sameFile.matches.length > 0) {
|
|
246
|
+
if (sameFile.matches.length > 1)
|
|
247
|
+
return { found: false, reason: "ambiguous" };
|
|
248
|
+
return { found: true, path, lines: sameFile.matches[0], confidence: sameFile.confidence };
|
|
249
|
+
}
|
|
250
|
+
// Step 2: only files git history connects to the original path.
|
|
251
|
+
const hits = [];
|
|
252
|
+
for (const candidate of grepCandidates(repo, symbol)) {
|
|
253
|
+
if (candidate === path)
|
|
254
|
+
continue;
|
|
255
|
+
const m = await matchesInFile(repo, candidate, symbol);
|
|
256
|
+
if (m === null || m.matches.length === 0)
|
|
257
|
+
continue;
|
|
258
|
+
if (!connectedByHistory(repo, asOfSha, path, candidate))
|
|
259
|
+
continue;
|
|
260
|
+
hits.push({ path: candidate, ...m });
|
|
261
|
+
}
|
|
262
|
+
if (hits.length === 0)
|
|
263
|
+
return { found: false, reason: "not-found" };
|
|
264
|
+
if (hits.length > 1 || hits[0].matches.length > 1)
|
|
265
|
+
return { found: false, reason: "ambiguous" };
|
|
266
|
+
return { found: true, path: hits[0].path, lines: hits[0].matches[0], confidence: hits[0].confidence };
|
|
267
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface GitResult {
|
|
2
|
+
status: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function git(repo: string, args: string[]): GitResult;
|
|
7
|
+
export declare function gitOrThrow(repo: string, args: string[]): string;
|
|
8
|
+
/**
|
|
9
|
+
* Resolve an anchor's `as_of` to a full commit sha. Both resolvers share the
|
|
10
|
+
* same precondition: the claim is only traceable forward if `as_of` is an
|
|
11
|
+
* ancestor of HEAD.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveAsOfCommit(repo: string, asOf: string): string;
|
|
14
|
+
/** Raw file content at rev, or null if the path is absent. */
|
|
15
|
+
export declare function showFile(repo: string, rev: string, path: string): string | null;
|
|
16
|
+
/** File content at rev as an array of lines, or null if the path is absent. */
|
|
17
|
+
export declare function showLines(repo: string, rev: string, path: string): string[] | null;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Thin wrappers over read-only git plumbing, shared by the anchor resolvers
|
|
2
|
+
// (trace-range, find-symbol). Nothing here mutates a repository.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
export function git(repo, args) {
|
|
5
|
+
const r = spawnSync("git", ["-C", repo, "-c", "core.quotePath=false", ...args], {
|
|
6
|
+
encoding: "utf8",
|
|
7
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
8
|
+
});
|
|
9
|
+
if (r.error) {
|
|
10
|
+
throw new Error(`failed to run git ${args[0]}: ${r.error.message}`);
|
|
11
|
+
}
|
|
12
|
+
return { status: r.status ?? 1, stdout: r.stdout, stderr: r.stderr };
|
|
13
|
+
}
|
|
14
|
+
export function gitOrThrow(repo, args) {
|
|
15
|
+
const r = git(repo, args);
|
|
16
|
+
if (r.status !== 0) {
|
|
17
|
+
throw new Error(`git ${args.join(" ")} failed: ${r.stderr.trim()}`);
|
|
18
|
+
}
|
|
19
|
+
return r.stdout;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve an anchor's `as_of` to a full commit sha. Both resolvers share the
|
|
23
|
+
* same precondition: the claim is only traceable forward if `as_of` is an
|
|
24
|
+
* ancestor of HEAD.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveAsOfCommit(repo, asOf) {
|
|
27
|
+
const rev = git(repo, ["rev-parse", "--verify", "--quiet", `${asOf}^{commit}`]);
|
|
28
|
+
if (rev.status !== 0) {
|
|
29
|
+
throw new Error(`as_of "${asOf}" does not resolve to a commit in ${repo}`);
|
|
30
|
+
}
|
|
31
|
+
const sha = rev.stdout.trim();
|
|
32
|
+
if (git(repo, ["merge-base", "--is-ancestor", sha, "HEAD"]).status !== 0) {
|
|
33
|
+
throw new Error(`as_of ${asOf} is not an ancestor of HEAD; cannot resolve forward from it`);
|
|
34
|
+
}
|
|
35
|
+
return sha;
|
|
36
|
+
}
|
|
37
|
+
/** Raw file content at rev, or null if the path is absent. */
|
|
38
|
+
export function showFile(repo, rev, path) {
|
|
39
|
+
const r = git(repo, ["show", `${rev}:${path}`]);
|
|
40
|
+
return r.status === 0 ? r.stdout : null;
|
|
41
|
+
}
|
|
42
|
+
/** File content at rev as an array of lines, or null if the path is absent. */
|
|
43
|
+
export function showLines(repo, rev, path) {
|
|
44
|
+
const content = showFile(repo, rev, path);
|
|
45
|
+
if (content === null)
|
|
46
|
+
return null;
|
|
47
|
+
const lines = content.split("\n");
|
|
48
|
+
if (lines[lines.length - 1] === "")
|
|
49
|
+
lines.pop();
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** An init step that must stop the command cleanly (exit 1), not crash. */
|
|
2
|
+
export declare class InitError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** One directory per concept type (DESIGN.md §1). */
|
|
5
|
+
export declare const TYPE_DIRECTORIES: string[];
|
|
6
|
+
export declare const SNIPPET_BEGIN = "<!-- why:begin -->";
|
|
7
|
+
export declare const SNIPPET_END = "<!-- why:end -->";
|
|
8
|
+
/** Root of the git repo enclosing `cwd`; the bundle always lives at its top. */
|
|
9
|
+
export declare function findRepoRoot(cwd: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Create `.why/` with the five type directories, a curated root index, and a
|
|
12
|
+
* log seeded with an init entry. Refuses to touch an existing `.why/` — an
|
|
13
|
+
* archive is history, so there is deliberately no --force.
|
|
14
|
+
*/
|
|
15
|
+
export declare function scaffoldBundle(repoRoot: string): Promise<string>;
|
|
16
|
+
/** The knowledge-capture block dropped into CLAUDE.md (DESIGN.md §8). */
|
|
17
|
+
export declare function captureSnippet(repoName: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Write the capture snippet into the repo's CLAUDE.md, idempotently: create
|
|
20
|
+
* the file, append the block, or replace whatever sits between the existing
|
|
21
|
+
* markers. Unbalanced markers are refused — rewriting around them could
|
|
22
|
+
* silently mangle a hand-edited file.
|
|
23
|
+
*/
|
|
24
|
+
export declare function writeCaptureSnippet(repoRoot: string): Promise<"created" | "appended" | "replaced">;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// `why init`: scaffold a fresh `.why/` bundle at the repo root (DESIGN.md §1,
|
|
2
|
+
// §8). Everything written here must load through okf-mcp with zero validation
|
|
3
|
+
// problems — the bundle is plain OKF from its very first byte.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
import { appendLogEntry, OKF_VERSION, serializeDocument } from "@copperbox/okf-mcp";
|
|
9
|
+
import { CONCEPT_TYPES } from "./bundle.js";
|
|
10
|
+
import { BUNDLE_DIRNAME } from "./discover.js";
|
|
11
|
+
/** An init step that must stop the command cleanly (exit 1), not crash. */
|
|
12
|
+
export class InitError extends Error {
|
|
13
|
+
}
|
|
14
|
+
/** One directory per concept type (DESIGN.md §1). */
|
|
15
|
+
export const TYPE_DIRECTORIES = CONCEPT_TYPES.map((type) => `${type}s`);
|
|
16
|
+
export const SNIPPET_BEGIN = "<!-- why:begin -->";
|
|
17
|
+
export const SNIPPET_END = "<!-- why:end -->";
|
|
18
|
+
/** Root of the git repo enclosing `cwd`; the bundle always lives at its top. */
|
|
19
|
+
export function findRepoRoot(cwd) {
|
|
20
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
21
|
+
cwd,
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
});
|
|
24
|
+
const root = result.status === 0 ? result.stdout.trim() : "";
|
|
25
|
+
if (root === "") {
|
|
26
|
+
throw new InitError(`why init: ${cwd} is not inside a git repository — init scaffolds ${BUNDLE_DIRNAME}/ at the repo root`);
|
|
27
|
+
}
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create `.why/` with the five type directories, a curated root index, and a
|
|
32
|
+
* log seeded with an init entry. Refuses to touch an existing `.why/` — an
|
|
33
|
+
* archive is history, so there is deliberately no --force.
|
|
34
|
+
*/
|
|
35
|
+
export async function scaffoldBundle(repoRoot) {
|
|
36
|
+
const root = join(repoRoot, BUNDLE_DIRNAME);
|
|
37
|
+
if (existsSync(root)) {
|
|
38
|
+
throw new InitError(`why init: ${root} already exists — refusing to touch an existing archive. ` +
|
|
39
|
+
"There is no --force; if you truly want to start over, remove the directory yourself.");
|
|
40
|
+
}
|
|
41
|
+
for (const dir of TYPE_DIRECTORIES) {
|
|
42
|
+
await mkdir(join(root, dir), { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
const name = basename(repoRoot);
|
|
45
|
+
const index = serializeDocument({
|
|
46
|
+
okf_version: OKF_VERSION,
|
|
47
|
+
generated: false,
|
|
48
|
+
description: `Decision archive for ${name} — the recovered why behind its code.`,
|
|
49
|
+
}, `# ${name} — decision archive\n`);
|
|
50
|
+
await writeFile(join(root, "index.md"), index, "utf8");
|
|
51
|
+
await appendLogEntry(root, `why init: scaffolded the empty bundle (${TYPE_DIRECTORIES.join(", ")})`);
|
|
52
|
+
return root;
|
|
53
|
+
}
|
|
54
|
+
/** The knowledge-capture block dropped into CLAUDE.md (DESIGN.md §8). */
|
|
55
|
+
export function captureSnippet(repoName) {
|
|
56
|
+
return [
|
|
57
|
+
SNIPPET_BEGIN,
|
|
58
|
+
"## Decision archive (`.why/`)",
|
|
59
|
+
"",
|
|
60
|
+
"This repo keeps the *why* behind its code in `.why/`, an OKF bundle of",
|
|
61
|
+
"decisions, constraints, attempts, incidents, and open questions.",
|
|
62
|
+
"",
|
|
63
|
+
"- Before non-trivial work, consult the archive for the decisions and",
|
|
64
|
+
" constraints shaping the code you are about to change (mount it:",
|
|
65
|
+
` \`npx -y @copperbox/okf-mcp --bundle ${repoName}=.why --writable\`).`,
|
|
66
|
+
"- After making a durable decision — choosing an approach, ruling one out,",
|
|
67
|
+
" hitting a constraint — record it in `.why/` while the context is fresh.",
|
|
68
|
+
SNIPPET_END,
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Write the capture snippet into the repo's CLAUDE.md, idempotently: create
|
|
73
|
+
* the file, append the block, or replace whatever sits between the existing
|
|
74
|
+
* markers. Unbalanced markers are refused — rewriting around them could
|
|
75
|
+
* silently mangle a hand-edited file.
|
|
76
|
+
*/
|
|
77
|
+
export async function writeCaptureSnippet(repoRoot) {
|
|
78
|
+
const path = join(repoRoot, "CLAUDE.md");
|
|
79
|
+
const block = captureSnippet(basename(repoRoot));
|
|
80
|
+
let existing;
|
|
81
|
+
try {
|
|
82
|
+
existing = await readFile(path, "utf8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
await writeFile(path, `${block}\n`, "utf8");
|
|
86
|
+
return "created";
|
|
87
|
+
}
|
|
88
|
+
const begin = existing.indexOf(SNIPPET_BEGIN);
|
|
89
|
+
const end = existing.indexOf(SNIPPET_END);
|
|
90
|
+
if (begin !== -1 && end !== -1 && end >= begin) {
|
|
91
|
+
const updated = existing.slice(0, begin) + block + existing.slice(end + SNIPPET_END.length);
|
|
92
|
+
await writeFile(path, updated, "utf8");
|
|
93
|
+
return "replaced";
|
|
94
|
+
}
|
|
95
|
+
if (begin !== -1 || end !== -1) {
|
|
96
|
+
throw new InitError(`why init: ${path} has an unbalanced ${SNIPPET_BEGIN}/${SNIPPET_END} marker pair — fix it by hand before re-running --capture-snippet`);
|
|
97
|
+
}
|
|
98
|
+
await writeFile(path, `${existing.trimEnd()}\n\n${block}\n`, "utf8");
|
|
99
|
+
return "appended";
|
|
100
|
+
}
|
package/dist/lint.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type WhyBundle } from "./bundle.js";
|
|
2
|
+
export type Severity = "error" | "warning";
|
|
3
|
+
export interface Finding {
|
|
4
|
+
/** Stable rule id, `W###`. */
|
|
5
|
+
rule: RuleId;
|
|
6
|
+
severity: Severity;
|
|
7
|
+
/** Bundle-relative file path; `(bundle)` for bundle-level OKF findings. */
|
|
8
|
+
file: string;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Every rule this linter can report. W0xx: okf-mcp pass-through; W1xx: `why:`
|
|
13
|
+
* field validity (§2 vocab tables); W2xx: required sections; W3xx: edge-section
|
|
14
|
+
* link targets; W4xx: status/section consistency (§3).
|
|
15
|
+
*/
|
|
16
|
+
export declare const RULES: {
|
|
17
|
+
readonly W001: "OKF conformance finding (okf-mcp validateBundle, passed through)";
|
|
18
|
+
readonly W100: "malformed `why:` value";
|
|
19
|
+
readonly W101: "unknown status for the concept's type";
|
|
20
|
+
readonly W102: "unknown confidence";
|
|
21
|
+
readonly W103: "malformed anchors entry";
|
|
22
|
+
readonly W104: "malformed verify block";
|
|
23
|
+
readonly W105: "unrecognized `why:` key";
|
|
24
|
+
readonly W106: "`why:` field not applicable to the concept's type";
|
|
25
|
+
readonly W200: "decision missing its `# Why` section";
|
|
26
|
+
readonly W201: "confidence inferred/speculative without a `# Citations` section";
|
|
27
|
+
readonly W202: "constraint missing its `# Still true?` section";
|
|
28
|
+
readonly W300: "`# Because of` link target must be a constraint/incident/attempt/decision";
|
|
29
|
+
readonly W301: "`# Instead of` link target must be an attempt";
|
|
30
|
+
readonly W302: "`# Superseded by` link target must be a decision";
|
|
31
|
+
readonly W303: "`# Led to` link target must be a decision/incident";
|
|
32
|
+
readonly W400: "status superseded without a `# Superseded by` section with a link";
|
|
33
|
+
readonly W401: "`# Superseded by` section without status superseded";
|
|
34
|
+
readonly W402: "expired constraint missing expired_on";
|
|
35
|
+
};
|
|
36
|
+
export type RuleId = keyof typeof RULES;
|
|
37
|
+
/** Lint one loaded bundle; deterministic order (file, rule, message). */
|
|
38
|
+
export declare function lintBundle(bundle: WhyBundle): Promise<Finding[]>;
|
|
39
|
+
/** Human-readable findings, grouped by file; a clean line when there are none. */
|
|
40
|
+
export declare function renderFindings(bundle: WhyBundle, findings: Finding[]): string[];
|
package/dist/lint.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// `why lint` (DESIGN.md §3): the why-schema layer above OKF conformance.
|
|
2
|
+
// OKF conformance itself is okf-mcp's job — its validateBundle findings pass
|
|
3
|
+
// through unmodified under the single rule id W001 — while the W1xx–W4xx
|
|
4
|
+
// rules enforce the `why:` vocab tables (§2), required sections, edge-target
|
|
5
|
+
// types, and status/section consistency (§3).
|
|
6
|
+
import { validateBundle } from "@copperbox/okf-mcp";
|
|
7
|
+
import { CONCEPT_TYPES, isOneOf, STATUS_VOCAB, } from "./bundle.js";
|
|
8
|
+
/**
|
|
9
|
+
* Every rule this linter can report. W0xx: okf-mcp pass-through; W1xx: `why:`
|
|
10
|
+
* field validity (§2 vocab tables); W2xx: required sections; W3xx: edge-section
|
|
11
|
+
* link targets; W4xx: status/section consistency (§3).
|
|
12
|
+
*/
|
|
13
|
+
export const RULES = {
|
|
14
|
+
W001: "OKF conformance finding (okf-mcp validateBundle, passed through)",
|
|
15
|
+
W100: "malformed `why:` value",
|
|
16
|
+
W101: "unknown status for the concept's type",
|
|
17
|
+
W102: "unknown confidence",
|
|
18
|
+
W103: "malformed anchors entry",
|
|
19
|
+
W104: "malformed verify block",
|
|
20
|
+
W105: "unrecognized `why:` key",
|
|
21
|
+
W106: "`why:` field not applicable to the concept's type",
|
|
22
|
+
W200: "decision missing its `# Why` section",
|
|
23
|
+
W201: "confidence inferred/speculative without a `# Citations` section",
|
|
24
|
+
W202: "constraint missing its `# Still true?` section",
|
|
25
|
+
W300: "`# Because of` link target must be a constraint/incident/attempt/decision",
|
|
26
|
+
W301: "`# Instead of` link target must be an attempt",
|
|
27
|
+
W302: "`# Superseded by` link target must be a decision",
|
|
28
|
+
W303: "`# Led to` link target must be a decision/incident",
|
|
29
|
+
W400: "status superseded without a `# Superseded by` section with a link",
|
|
30
|
+
W401: "`# Superseded by` section without status superseded",
|
|
31
|
+
W402: "expired constraint missing expired_on",
|
|
32
|
+
};
|
|
33
|
+
/** Which W1xx rule a load-time `why:` diagnostic belongs to, by field. */
|
|
34
|
+
const FIELD_RULES = {
|
|
35
|
+
status: "W101",
|
|
36
|
+
confidence: "W102",
|
|
37
|
+
anchors: "W103",
|
|
38
|
+
verify: "W104",
|
|
39
|
+
happened_on: "W100",
|
|
40
|
+
expired_on: "W100",
|
|
41
|
+
};
|
|
42
|
+
function ruleForDiagnostic(field) {
|
|
43
|
+
const segment = /^why\.([a-z_]+)/.exec(field)?.[1];
|
|
44
|
+
if (segment === undefined)
|
|
45
|
+
return "W100"; // `why` itself is not a map
|
|
46
|
+
return FIELD_RULES[segment] ?? "W105"; // any other segment is an unrecognized key
|
|
47
|
+
}
|
|
48
|
+
/** Allowed link-target types per edge section (DESIGN.md §3 table). */
|
|
49
|
+
const EDGE_TARGETS = {
|
|
50
|
+
"because of": { rule: "W300", allowed: ["constraint", "incident", "attempt", "decision"] },
|
|
51
|
+
"instead of": { rule: "W301", allowed: ["attempt"] },
|
|
52
|
+
"superseded by": { rule: "W302", allowed: ["decision"] },
|
|
53
|
+
"led to": { rule: "W303", allowed: ["decision", "incident"] },
|
|
54
|
+
};
|
|
55
|
+
function hasSection(concept, heading) {
|
|
56
|
+
return concept.sections.some((s) => s.heading.toLowerCase() === heading);
|
|
57
|
+
}
|
|
58
|
+
function lintConcept(bundle, concept, push) {
|
|
59
|
+
const type = concept.frontmatter.type;
|
|
60
|
+
const why = concept.why;
|
|
61
|
+
// W106 — the §2 field table scopes some fields to one concept type.
|
|
62
|
+
if (why.verify !== undefined && type !== "constraint") {
|
|
63
|
+
push("W106", "warning", `why.verify: only a constraint carries a verify block, not a ${type} (DESIGN.md §2)`);
|
|
64
|
+
}
|
|
65
|
+
if (why.expired_on !== undefined && type !== "constraint") {
|
|
66
|
+
push("W106", "warning", `why.expired_on: only a constraint expires, not a ${type} (DESIGN.md §2)`);
|
|
67
|
+
}
|
|
68
|
+
if (why.confidence !== undefined && type === "question") {
|
|
69
|
+
push("W106", "warning", "why.confidence: a question is the uncertainty — it carries no confidence (DESIGN.md §2)");
|
|
70
|
+
}
|
|
71
|
+
// W2xx — required sections (§3).
|
|
72
|
+
if (type === "decision" && !hasSection(concept, "why")) {
|
|
73
|
+
push("W200", "error", 'every decision needs a "# Why" section — the narrative is the point');
|
|
74
|
+
}
|
|
75
|
+
if ((why.confidence === "inferred" || why.confidence === "speculative") &&
|
|
76
|
+
!hasSection(concept, "citations")) {
|
|
77
|
+
push("W201", "error", `confidence "${why.confidence}" requires a "# Citations" section backing the claim`);
|
|
78
|
+
}
|
|
79
|
+
if (type === "constraint" && !hasSection(concept, "still true?")) {
|
|
80
|
+
push("W202", "error", 'every constraint needs a "# Still true?" section — a constraint must be falsifiable');
|
|
81
|
+
}
|
|
82
|
+
// W3xx — links in edge sections must target the types the §3 table allows.
|
|
83
|
+
// Unresolved links are okf-mcp's broken-link findings, passed through as W001.
|
|
84
|
+
for (const link of concept.links) {
|
|
85
|
+
const edge = link.section === undefined ? undefined : EDGE_TARGETS[link.section.toLowerCase()];
|
|
86
|
+
if (edge === undefined || link.resolvedId === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const targetType = bundle.concepts.get(link.resolvedId)?.frontmatter.type;
|
|
89
|
+
if (targetType !== undefined && !isOneOf(edge.allowed, targetType)) {
|
|
90
|
+
push(edge.rule, "error", `"# ${link.section}" links to ${link.target}, a ${targetType} — allowed targets: ${edge.allowed.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// W4xx — status ⇔ section consistency.
|
|
94
|
+
const supersededLinks = concept.links.filter((l) => l.section?.toLowerCase() === "superseded by");
|
|
95
|
+
if (why.status === "superseded" && supersededLinks.length === 0) {
|
|
96
|
+
push("W400", "error", 'status "superseded" requires a "# Superseded by" section with at least one link to the successor');
|
|
97
|
+
}
|
|
98
|
+
// The reverse direction only where the type's vocab admits "superseded" —
|
|
99
|
+
// the §3 table allows the section on constraints, whose vocab does not.
|
|
100
|
+
const vocab = isOneOf(CONCEPT_TYPES, type) ? STATUS_VOCAB[type] : undefined;
|
|
101
|
+
if (supersededLinks.length > 0 && why.status !== "superseded" && vocab !== undefined && vocab.includes("superseded")) {
|
|
102
|
+
const actual = why.status === undefined ? "unset" : `"${why.status}"`;
|
|
103
|
+
push("W401", "error", `a "# Superseded by" section requires status "superseded" (status is ${actual})`);
|
|
104
|
+
}
|
|
105
|
+
if (type === "constraint" && why.status === "expired" && why.expired_on === undefined) {
|
|
106
|
+
push("W402", "error", 'status "expired" requires an expired_on date (DESIGN.md §2)');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Lint one loaded bundle; deterministic order (file, rule, message). */
|
|
110
|
+
export async function lintBundle(bundle) {
|
|
111
|
+
const findings = [];
|
|
112
|
+
// OKF-level conformance is delegated wholesale; validateBundle's report
|
|
113
|
+
// already includes the load-time problems.
|
|
114
|
+
const okf = await validateBundle(bundle.okf);
|
|
115
|
+
for (const problem of [...okf.errors, ...okf.warnings]) {
|
|
116
|
+
findings.push({
|
|
117
|
+
rule: "W001",
|
|
118
|
+
severity: problem.severity,
|
|
119
|
+
file: problem.path ?? "(bundle)",
|
|
120
|
+
message: problem.message,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// `why:` field validity was already checked at load time (bundle.ts) —
|
|
124
|
+
// map each diagnostic to its stable rule id.
|
|
125
|
+
for (const diag of bundle.diagnostics) {
|
|
126
|
+
const rule = ruleForDiagnostic(diag.field);
|
|
127
|
+
findings.push({
|
|
128
|
+
rule,
|
|
129
|
+
severity: rule === "W105" ? "warning" : "error",
|
|
130
|
+
file: diag.path,
|
|
131
|
+
message: `${diag.field}: ${diag.message}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
for (const concept of bundle.concepts.values()) {
|
|
135
|
+
lintConcept(bundle, concept, (rule, severity, message) => findings.push({ rule, severity, file: concept.path, message }));
|
|
136
|
+
}
|
|
137
|
+
return findings.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule) || a.message.localeCompare(b.message));
|
|
138
|
+
}
|
|
139
|
+
function count(n, word) {
|
|
140
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
141
|
+
}
|
|
142
|
+
/** Human-readable findings, grouped by file; a clean line when there are none. */
|
|
143
|
+
export function renderFindings(bundle, findings) {
|
|
144
|
+
if (findings.length === 0) {
|
|
145
|
+
return [`${bundle.root}: ${count(bundle.concepts.size, "concept")}, no findings`];
|
|
146
|
+
}
|
|
147
|
+
const lines = [];
|
|
148
|
+
let currentFile;
|
|
149
|
+
for (const finding of findings) {
|
|
150
|
+
if (finding.file !== currentFile) {
|
|
151
|
+
if (currentFile !== undefined)
|
|
152
|
+
lines.push("");
|
|
153
|
+
lines.push(finding.file);
|
|
154
|
+
currentFile = finding.file;
|
|
155
|
+
}
|
|
156
|
+
lines.push(` ${finding.severity.padEnd(7)} ${finding.rule} ${finding.message}`);
|
|
157
|
+
}
|
|
158
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
159
|
+
lines.push("", `${count(findings.length, "finding")} (${count(errors, "error")}, ${count(findings.length - errors, "warning")})`);
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The UI could not be bundled — operational, not a bug in the bundle. */
|
|
2
|
+
export declare class AssetError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export interface UiAssets {
|
|
5
|
+
html: string;
|
|
6
|
+
js: string;
|
|
7
|
+
css: string;
|
|
8
|
+
}
|
|
9
|
+
/** Bundle ui/ into self-contained in-memory assets. */
|
|
10
|
+
export declare function buildUiAssets(): Promise<UiAssets>;
|