@slowcook-ai/cli 0.19.0-alpha.41 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/dist/cli.js +19 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/brand/index.d.ts +3 -35
- package/dist/commands/brand/index.d.ts.map +1 -1
- package/dist/commands/brew/agent.d.ts +7 -2
- package/dist/commands/brew/agent.d.ts.map +1 -1
- package/dist/commands/brew/agent.js +9 -0
- package/dist/commands/brew/agent.js.map +1 -1
- package/dist/commands/brew/halt.d.ts +26 -0
- package/dist/commands/brew/halt.d.ts.map +1 -1
- package/dist/commands/brew/halt.js.map +1 -1
- package/dist/commands/brew/index.d.ts.map +1 -1
- package/dist/commands/brew/index.js +81 -29
- package/dist/commands/brew/index.js.map +1 -1
- package/dist/commands/brew/pair-navigator.d.ts +7 -0
- package/dist/commands/brew/pair-navigator.d.ts.map +1 -1
- package/dist/commands/brew/pair-navigator.js +4 -0
- package/dist/commands/brew/pair-navigator.js.map +1 -1
- package/dist/commands/chef/drift-fix.d.ts +32 -3
- package/dist/commands/chef/drift-fix.d.ts.map +1 -1
- package/dist/commands/chef/drift-fix.js +381 -14
- package/dist/commands/chef/drift-fix.js.map +1 -1
- package/dist/commands/dev-env/config.d.ts +18 -97
- package/dist/commands/dev-env/config.d.ts.map +1 -1
- package/dist/commands/eval/index.d.ts.map +1 -1
- package/dist/commands/eval/index.js +13 -2
- package/dist/commands/eval/index.js.map +1 -1
- package/dist/commands/init/index.d.ts.map +1 -1
- package/dist/commands/init/index.js +33 -0
- package/dist/commands/init/index.js.map +1 -1
- package/dist/commands/init/mock-vite.d.ts.map +1 -1
- package/dist/commands/init/mock-vite.js +2 -0
- package/dist/commands/init/mock-vite.js.map +1 -1
- package/dist/commands/knowledge-add.d.ts +52 -0
- package/dist/commands/knowledge-add.d.ts.map +1 -0
- package/dist/commands/knowledge-add.js +232 -0
- package/dist/commands/knowledge-add.js.map +1 -0
- package/dist/commands/recon/index.d.ts +14 -1
- package/dist/commands/recon/index.d.ts.map +1 -1
- package/dist/commands/recon/index.js +43 -2
- package/dist/commands/recon/index.js.map +1 -1
- package/dist/commands/refine/agent.d.ts +11 -0
- package/dist/commands/refine/agent.d.ts.map +1 -1
- package/dist/commands/refine/agent.js +72 -2
- package/dist/commands/refine/agent.js.map +1 -1
- package/dist/commands/refine/context.d.ts +27 -1
- package/dist/commands/refine/context.d.ts.map +1 -1
- package/dist/commands/refine/context.js +425 -11
- package/dist/commands/refine/context.js.map +1 -1
- package/dist/commands/refine/git-attention.d.ts +123 -0
- package/dist/commands/refine/git-attention.d.ts.map +1 -0
- package/dist/commands/refine/git-attention.js +378 -0
- package/dist/commands/refine/git-attention.js.map +1 -0
- package/dist/commands/refine/history-index.d.ts +7 -0
- package/dist/commands/refine/history-index.d.ts.map +1 -1
- package/dist/commands/refine/history-index.js.map +1 -1
- package/dist/commands/refine/index.d.ts.map +1 -1
- package/dist/commands/refine/index.js +76 -20
- package/dist/commands/refine/index.js.map +1 -1
- package/dist/commands/refine/multifurcate.d.ts +129 -0
- package/dist/commands/refine/multifurcate.d.ts.map +1 -0
- package/dist/commands/refine/multifurcate.js +247 -0
- package/dist/commands/refine/multifurcate.js.map +1 -0
- package/dist/commands/refine/spec-yaml.d.ts +211 -1231
- package/dist/commands/refine/spec-yaml.d.ts.map +1 -1
- package/dist/commands/refresh-knowledge.d.ts +126 -0
- package/dist/commands/refresh-knowledge.d.ts.map +1 -0
- package/dist/commands/refresh-knowledge.js +1010 -0
- package/dist/commands/refresh-knowledge.js.map +1 -0
- package/dist/commands/testgen/agent.d.ts +13 -0
- package/dist/commands/testgen/agent.d.ts.map +1 -1
- package/dist/commands/testgen/agent.js +103 -2
- package/dist/commands/testgen/agent.js.map +1 -1
- package/dist/commands/upsert-agent-docs.d.ts +48 -0
- package/dist/commands/upsert-agent-docs.d.ts.map +1 -0
- package/dist/commands/upsert-agent-docs.js +298 -0
- package/dist/commands/upsert-agent-docs.js.map +1 -0
- package/dist/lib/budget.d.ts +2 -52
- package/dist/lib/budget.d.ts.map +1 -1
- package/dist/lib/mock-shape.d.ts +5 -20
- package/dist/lib/mock-shape.d.ts.map +1 -1
- package/package.json +9 -6
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `slowcook refresh-knowledge` — α.62
|
|
3
|
+
*
|
|
4
|
+
* Rebuilds `.brewing/repo-knowledge/auto/*.md` digests so refine (and any
|
|
5
|
+
* other agent) reads the consumer repo's actual shape instead of
|
|
6
|
+
* re-deriving it via LLM every run.
|
|
7
|
+
*
|
|
8
|
+
* Caching policy (three classes):
|
|
9
|
+
* 1. Cheap deterministic extractions (all of α.62: pure regex/AST,
|
|
10
|
+
* <2s per file). Just rebuild every time. No hash machinery.
|
|
11
|
+
*
|
|
12
|
+
* 2. Expensive deterministic extractions (e.g., git-history mining
|
|
13
|
+
* in α.63 — walking 500 commits to extract conventions). Stamp
|
|
14
|
+
* output with `<!-- last-built: ISO; input-tip-sha: <sha> -->`
|
|
15
|
+
* and resume from the stamped SHA on the next run (delta mining).
|
|
16
|
+
*
|
|
17
|
+
* 3. Expensive INSIGHTS (LLM-derived chef known-fixes, lesson
|
|
18
|
+
* extractions, PR summaries). These do NOT auto-invalidate on
|
|
19
|
+
* commit change — the insight is about a CLASS of problem, not
|
|
20
|
+
* a snapshot of code. An insight like "vitest/config not found
|
|
21
|
+
* means deps missing" stays true even if vitest.config.ts moves.
|
|
22
|
+
* Staleness is a SOFT signal:
|
|
23
|
+
* - Each insight carries `evidence-pr: N` + `last-verified: ISO`
|
|
24
|
+
* - `slowcook knowledge verify` may flag [PRECARIOUS] when the
|
|
25
|
+
* evidence file is substantially rewritten, but does NOT
|
|
26
|
+
* delete. Agents reading insights see staleness as weight,
|
|
27
|
+
* not as a gate.
|
|
28
|
+
* α.62 has no insight extractions yet; this comment locks the
|
|
29
|
+
* design for α.63+ to follow.
|
|
30
|
+
*
|
|
31
|
+
* Output layout (gitignored by convention):
|
|
32
|
+
* .brewing/repo-knowledge/auto/
|
|
33
|
+
* ├── backend-entities.md (TypeORM @Entity classes + columns)
|
|
34
|
+
* ├── backend-routes.md (HTTP controllers + handler names)
|
|
35
|
+
* ├── backend-enums.md (packages/enums/src/*.enum.ts values)
|
|
36
|
+
* ├── frontend-types.md (mock/src/types/*.ts interfaces)
|
|
37
|
+
* ├── frontend-components.md (mock/src/{app,components}/**.tsx)
|
|
38
|
+
* ├── frontend-contexts.md (mock/src/contexts/*-context.tsx hooks)
|
|
39
|
+
* ├── tokens.md (Tailwind brand-token vocabulary)
|
|
40
|
+
* ├── config.md (tsconfig paths + workspace + scripts)
|
|
41
|
+
* ├── migrations.md (migration file timestamps + table names)
|
|
42
|
+
* └── routes-inventory.md (filesystem-derived route URLs)
|
|
43
|
+
*
|
|
44
|
+
* Refine (`refine/context.ts`) reads these from disk and concatenates
|
|
45
|
+
* them. If the dir doesn't exist (first run), refine falls back to the
|
|
46
|
+
* legacy in-memory scan (α.61 readNestJsBackendDigest).
|
|
47
|
+
*/
|
|
48
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { dirname, join } from "node:path";
|
|
50
|
+
import { execSync } from "node:child_process";
|
|
51
|
+
const AUTO_DIR_REL = ".brewing/repo-knowledge/auto";
|
|
52
|
+
const CURATED_DIR_REL = ".brewing/repo-knowledge/curated";
|
|
53
|
+
// --- input discovery helpers ---
|
|
54
|
+
function findFilesByGlob(repoRoot, pattern, opts = {}) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const skipDirs = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo", "coverage", ".brewing"]);
|
|
57
|
+
const maxDepth = opts.maxDepth ?? 8;
|
|
58
|
+
const walk = (dir, depth) => {
|
|
59
|
+
if (depth > maxDepth)
|
|
60
|
+
return;
|
|
61
|
+
let entries = [];
|
|
62
|
+
try {
|
|
63
|
+
entries = readdirSync(dir);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const name of entries) {
|
|
69
|
+
if (skipDirs.has(name))
|
|
70
|
+
continue;
|
|
71
|
+
const abs = join(dir, name);
|
|
72
|
+
let st;
|
|
73
|
+
try {
|
|
74
|
+
st = statSync(abs);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (st.isDirectory())
|
|
80
|
+
walk(abs, depth + 1);
|
|
81
|
+
else {
|
|
82
|
+
const rel = abs.slice(repoRoot.length + 1);
|
|
83
|
+
if (pattern.test(rel))
|
|
84
|
+
out.push(rel);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
walk(repoRoot, 0);
|
|
89
|
+
return out.sort();
|
|
90
|
+
}
|
|
91
|
+
function safeRead(repoRoot, rel) {
|
|
92
|
+
try {
|
|
93
|
+
return readFileSync(join(repoRoot, rel), "utf8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// --- digest writer (cheap extractions = always rebuild) ---
|
|
100
|
+
function writeDigest(repoRoot, name, body) {
|
|
101
|
+
const abs = join(repoRoot, AUTO_DIR_REL, `${name}.md`);
|
|
102
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
103
|
+
writeFileSync(abs, `<!-- regenerated: ${new Date().toISOString()} -->\n${body}\n`, "utf8");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Build one digest. Cheap extractions class — always rebuilds.
|
|
107
|
+
*/
|
|
108
|
+
export function buildDigest(args) {
|
|
109
|
+
const { repoRoot, name, inputFiles, build } = args;
|
|
110
|
+
if (inputFiles.length === 0)
|
|
111
|
+
return { body: "", built: false };
|
|
112
|
+
const body = build(inputFiles);
|
|
113
|
+
writeDigest(repoRoot, name, body);
|
|
114
|
+
return { body, built: true };
|
|
115
|
+
}
|
|
116
|
+
// --- extraction primitives (shared) ---
|
|
117
|
+
function extractTypeOrmColumns(body) {
|
|
118
|
+
const lines = body.split("\n");
|
|
119
|
+
const out = [];
|
|
120
|
+
const fieldRe = /^\s*(?:public|private|readonly|protected)\s+(\w+)(\?)?\s*:\s*([^;]+);/;
|
|
121
|
+
for (const l of lines) {
|
|
122
|
+
const m = l.match(fieldRe);
|
|
123
|
+
if (!m)
|
|
124
|
+
continue;
|
|
125
|
+
const name = m[1];
|
|
126
|
+
const optional = m[2] ? "?" : "";
|
|
127
|
+
const type = (m[3] ?? "").trim().replace(/\s+/g, " ");
|
|
128
|
+
out.push(`${name}${optional}: ${type}`);
|
|
129
|
+
}
|
|
130
|
+
return [...new Set(out)];
|
|
131
|
+
}
|
|
132
|
+
function extractNestRoutes(body) {
|
|
133
|
+
const lines = body.split("\n");
|
|
134
|
+
const out = [];
|
|
135
|
+
const httpVerbRe = /^\s*@(Get|Post|Put|Delete|Patch)\(([^)]*)\)/;
|
|
136
|
+
const nextHttpVerbRe = /^\s*@(Get|Post|Put|Delete|Patch)\(/;
|
|
137
|
+
const handlerRe = /^\s*(?:public|private|protected)?\s*(?:async\s+)?(\w+)\s*\(/;
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
const m = (lines[i] ?? "").match(httpVerbRe);
|
|
140
|
+
if (!m)
|
|
141
|
+
continue;
|
|
142
|
+
const method = m[1].toUpperCase();
|
|
143
|
+
const rawPath = (m[2] ?? "").trim().replace(/^['"`]|['"`]$/g, "");
|
|
144
|
+
let parenDepth = 0;
|
|
145
|
+
let handler = "?";
|
|
146
|
+
for (let j = i + 1; j < Math.min(i + 60, lines.length); j++) {
|
|
147
|
+
const ln = lines[j] ?? "";
|
|
148
|
+
if (parenDepth === 0 && nextHttpVerbRe.test(ln))
|
|
149
|
+
break;
|
|
150
|
+
for (const ch of ln) {
|
|
151
|
+
if (ch === "(")
|
|
152
|
+
parenDepth++;
|
|
153
|
+
else if (ch === ")")
|
|
154
|
+
parenDepth = Math.max(0, parenDepth - 1);
|
|
155
|
+
}
|
|
156
|
+
if (/^\s*@\w+/.test(ln))
|
|
157
|
+
continue;
|
|
158
|
+
if (/^\s*$/.test(ln))
|
|
159
|
+
continue;
|
|
160
|
+
const hm = ln.match(handlerRe);
|
|
161
|
+
if (hm) {
|
|
162
|
+
const word = hm[1];
|
|
163
|
+
if (word === "if" || word === "switch" || word === "while" || word === "for" || /^[A-Z]/.test(word))
|
|
164
|
+
continue;
|
|
165
|
+
handler = word;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
out.push({ method, path: rawPath, handler });
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function joinPath(base, sub) {
|
|
174
|
+
const b = base.replace(/^\/|\/$/g, "");
|
|
175
|
+
const s = sub.replace(/^\/|\/$/g, "");
|
|
176
|
+
if (!b)
|
|
177
|
+
return s;
|
|
178
|
+
if (!s)
|
|
179
|
+
return b;
|
|
180
|
+
return `${b}/${s}`;
|
|
181
|
+
}
|
|
182
|
+
function extractTsInterfaces(body) {
|
|
183
|
+
const out = [];
|
|
184
|
+
const startRe = /export\s+interface\s+(\w+)(?:\s+extends\s+[\w\s,&<>]+?)?\s*\{/g;
|
|
185
|
+
let m;
|
|
186
|
+
while ((m = startRe.exec(body)) !== null) {
|
|
187
|
+
const name = m[1];
|
|
188
|
+
let depth = 1;
|
|
189
|
+
let i = m.index + m[0].length;
|
|
190
|
+
while (i < body.length && depth > 0) {
|
|
191
|
+
const ch = body[i];
|
|
192
|
+
if (ch === "{")
|
|
193
|
+
depth++;
|
|
194
|
+
else if (ch === "}")
|
|
195
|
+
depth--;
|
|
196
|
+
i++;
|
|
197
|
+
}
|
|
198
|
+
if (depth === 0) {
|
|
199
|
+
const ifaceBody = body.slice(m.index + m[0].length, i - 1).trim();
|
|
200
|
+
out.push({ name, body: ifaceBody });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
function extractComponentExports(body) {
|
|
206
|
+
const out = [];
|
|
207
|
+
const compRe = /export\s+(?:default\s+)?function\s+(\w+)\s*\(/g;
|
|
208
|
+
const constRe = /export\s+(?:default\s+)?const\s+(\w+)\s*[:=]/g;
|
|
209
|
+
const names = new Set();
|
|
210
|
+
let m;
|
|
211
|
+
while ((m = compRe.exec(body)) !== null)
|
|
212
|
+
names.add(m[1]);
|
|
213
|
+
while ((m = constRe.exec(body)) !== null)
|
|
214
|
+
names.add(m[1]);
|
|
215
|
+
for (const name of names) {
|
|
216
|
+
if (!/^[A-Z]/.test(name))
|
|
217
|
+
continue;
|
|
218
|
+
const propsName = body.includes(`interface ${name}Props`) ? `${name}Props` : null;
|
|
219
|
+
out.push({ name, propsName });
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
function extractHookSignatures(body) {
|
|
224
|
+
const out = [];
|
|
225
|
+
const fnRe = /export\s+function\s+(use\w+)\s*\(([^)]*)\)(?::\s*([^{]+))?/g;
|
|
226
|
+
let m;
|
|
227
|
+
while ((m = fnRe.exec(body)) !== null) {
|
|
228
|
+
const name = m[1];
|
|
229
|
+
const params = (m[2] ?? "").trim();
|
|
230
|
+
const ret = (m[3] ?? "").trim().replace(/=>.*$/, "").trim() || "?";
|
|
231
|
+
out.push(`${name}(${params}): ${ret}`);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
function extractTailwindTokens(body) {
|
|
236
|
+
const out = new Set();
|
|
237
|
+
const classRe = /className\s*=\s*(?:["'`]([^"'`]+)["'`]|\{["'`]([^"'`]+)["'`]\})/g;
|
|
238
|
+
let m;
|
|
239
|
+
while ((m = classRe.exec(body)) !== null) {
|
|
240
|
+
const classes = (m[1] ?? m[2] ?? "").split(/\s+/);
|
|
241
|
+
for (const c of classes) {
|
|
242
|
+
if (/^(bg|text|border|ring|fill|stroke)-(brand|primary|secondary|accent|surface)-/.test(c))
|
|
243
|
+
out.add(c);
|
|
244
|
+
if (/^bg-(brand|primary|secondary)-?\w*$/.test(c))
|
|
245
|
+
out.add(c);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
// --- digest builders ---
|
|
251
|
+
export function buildBackendEntitiesDigest(repoRoot) {
|
|
252
|
+
const files = findFilesByGlob(repoRoot, /\/entities\/[^/]+\.entity\.ts$/);
|
|
253
|
+
return buildDigest({
|
|
254
|
+
repoRoot, name: "backend-entities", inputFiles: files,
|
|
255
|
+
build: (inputs) => {
|
|
256
|
+
const lines = [];
|
|
257
|
+
lines.push("# Backend entities (TypeORM)\n");
|
|
258
|
+
lines.push("Auto-extracted from `packages/**/entities/*.entity.ts`. Spec / code MUST reference these field names verbatim — do not invent aliases.\n");
|
|
259
|
+
for (const rel of inputs) {
|
|
260
|
+
const body = safeRead(repoRoot, rel);
|
|
261
|
+
if (!body)
|
|
262
|
+
continue;
|
|
263
|
+
const classMatch = body.match(/export class (\w+) extends BaseEntity/) ?? body.match(/export class (\w+)/);
|
|
264
|
+
if (!classMatch)
|
|
265
|
+
continue;
|
|
266
|
+
const className = classMatch[1];
|
|
267
|
+
const cols = extractTypeOrmColumns(body).slice(0, 30);
|
|
268
|
+
lines.push(`## ${className} \`${rel}\``);
|
|
269
|
+
for (const col of cols)
|
|
270
|
+
lines.push(`- ${col}`);
|
|
271
|
+
lines.push("");
|
|
272
|
+
}
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
export function buildBackendRoutesDigest(repoRoot) {
|
|
278
|
+
const files = findFilesByGlob(repoRoot, /\/(modules|controllers)\/[^/]+\/[^/]+\.controller\.ts$/);
|
|
279
|
+
return buildDigest({
|
|
280
|
+
repoRoot, name: "backend-routes", inputFiles: files,
|
|
281
|
+
build: (inputs) => {
|
|
282
|
+
const lines = [];
|
|
283
|
+
lines.push("# Backend HTTP routes (NestJS controllers)\n");
|
|
284
|
+
lines.push("Auto-extracted from `apps/**/modules/**/*.controller.ts`. Spec / code MUST reference these paths + handler names verbatim — do not invent `/api/v1/...`-style paths if they aren't here.\n");
|
|
285
|
+
for (const rel of inputs) {
|
|
286
|
+
const body = safeRead(repoRoot, rel);
|
|
287
|
+
if (!body)
|
|
288
|
+
continue;
|
|
289
|
+
const controllerMatch = body.match(/@Controller\(['"]([^'"]*)['"]\)/);
|
|
290
|
+
const base = controllerMatch ? controllerMatch[1] : "";
|
|
291
|
+
const routes = extractNestRoutes(body);
|
|
292
|
+
if (routes.length === 0)
|
|
293
|
+
continue;
|
|
294
|
+
lines.push(`## \`${rel}\` (base: \`/${base}\`)`);
|
|
295
|
+
for (const r of routes.slice(0, 50))
|
|
296
|
+
lines.push(`- \`${r.method} /${joinPath(base, r.path)}\` → \`${r.handler}\``);
|
|
297
|
+
if (routes.length > 50)
|
|
298
|
+
lines.push(`- … ${routes.length - 50} more`);
|
|
299
|
+
lines.push("");
|
|
300
|
+
}
|
|
301
|
+
return lines.join("\n");
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
export function buildBackendEnumsDigest(repoRoot) {
|
|
306
|
+
const enumsDir = join(repoRoot, "packages/enums/src");
|
|
307
|
+
const files = existsSync(enumsDir)
|
|
308
|
+
? readdirSync(enumsDir).filter((f) => f.endsWith(".enum.ts")).map((f) => `packages/enums/src/${f}`)
|
|
309
|
+
: [];
|
|
310
|
+
return buildDigest({
|
|
311
|
+
repoRoot, name: "backend-enums", inputFiles: files,
|
|
312
|
+
build: (inputs) => {
|
|
313
|
+
const lines = [];
|
|
314
|
+
lines.push("# Backend enums (`packages/enums/src/`)\n");
|
|
315
|
+
lines.push("Authoritative enum values. Spec must not invent values not listed here.\n");
|
|
316
|
+
for (const rel of inputs) {
|
|
317
|
+
const body = safeRead(repoRoot, rel);
|
|
318
|
+
if (!body)
|
|
319
|
+
continue;
|
|
320
|
+
const enumMatch = body.match(/export enum (\w+) \{([\s\S]*?)\}/);
|
|
321
|
+
if (!enumMatch)
|
|
322
|
+
continue;
|
|
323
|
+
const enumName = enumMatch[1];
|
|
324
|
+
const values = (enumMatch[2] ?? "")
|
|
325
|
+
.split(",")
|
|
326
|
+
.map((v) => v.trim().split("=")[0].trim().replace(/['"\s/*]/g, ""))
|
|
327
|
+
.filter((v) => v && /^[A-Z_]+$/.test(v));
|
|
328
|
+
if (values.length === 0)
|
|
329
|
+
continue;
|
|
330
|
+
lines.push(`## ${enumName}`);
|
|
331
|
+
lines.push(values.map((v) => `- ${v}`).join("\n"));
|
|
332
|
+
lines.push("");
|
|
333
|
+
}
|
|
334
|
+
return lines.join("\n");
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
export function buildFrontendTypesDigest(repoRoot) {
|
|
339
|
+
const typesDir = join(repoRoot, "mock/src/types");
|
|
340
|
+
const files = [];
|
|
341
|
+
if (existsSync(typesDir)) {
|
|
342
|
+
for (const f of readdirSync(typesDir)) {
|
|
343
|
+
if (f.endsWith(".ts"))
|
|
344
|
+
files.push(`mock/src/types/${f}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return buildDigest({
|
|
348
|
+
repoRoot, name: "frontend-types", inputFiles: files,
|
|
349
|
+
build: (inputs) => {
|
|
350
|
+
const lines = [];
|
|
351
|
+
lines.push("# Frontend mock types (`mock/src/types/`)\n");
|
|
352
|
+
lines.push("Canonical TypeScript interfaces for the mock UI. Specs targeting mock UI MUST use these field names; adapters between mock + backend should map field-by-field instead of inventing intermediate shapes.\n");
|
|
353
|
+
for (const rel of inputs) {
|
|
354
|
+
const body = safeRead(repoRoot, rel);
|
|
355
|
+
if (!body)
|
|
356
|
+
continue;
|
|
357
|
+
const ifaces = extractTsInterfaces(body);
|
|
358
|
+
if (ifaces.length === 0)
|
|
359
|
+
continue;
|
|
360
|
+
lines.push(`## ${rel}`);
|
|
361
|
+
for (const iface of ifaces) {
|
|
362
|
+
lines.push(`### ${iface.name}`);
|
|
363
|
+
lines.push("```ts");
|
|
364
|
+
lines.push(iface.body.slice(0, 800));
|
|
365
|
+
lines.push("```");
|
|
366
|
+
}
|
|
367
|
+
lines.push("");
|
|
368
|
+
}
|
|
369
|
+
return lines.join("\n");
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
export function buildFrontendComponentsDigest(repoRoot) {
|
|
374
|
+
const files = findFilesByGlob(repoRoot, /^mock\/src\/(app|components)\/.+\.tsx$/);
|
|
375
|
+
return buildDigest({
|
|
376
|
+
repoRoot, name: "frontend-components", inputFiles: files,
|
|
377
|
+
build: (inputs) => {
|
|
378
|
+
const lines = [];
|
|
379
|
+
lines.push("# Frontend mock components (`mock/src/{app,components}/`)\n");
|
|
380
|
+
lines.push("Auto-extracted component exports + their Props interface name (when present). Use these names + prop shapes verbatim when referencing mock components from specs.\n");
|
|
381
|
+
const byDir = {};
|
|
382
|
+
for (const rel of inputs) {
|
|
383
|
+
const body = safeRead(repoRoot, rel);
|
|
384
|
+
if (!body)
|
|
385
|
+
continue;
|
|
386
|
+
const comps = extractComponentExports(body);
|
|
387
|
+
for (const c of comps) {
|
|
388
|
+
const dir = rel.split("/").slice(0, 3).join("/");
|
|
389
|
+
if (!byDir[dir])
|
|
390
|
+
byDir[dir] = [];
|
|
391
|
+
byDir[dir].push({ ...c, rel });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const dir of Object.keys(byDir).sort()) {
|
|
395
|
+
lines.push(`## ${dir}/`);
|
|
396
|
+
for (const c of byDir[dir].slice(0, 80)) {
|
|
397
|
+
const props = c.propsName ? ` <${c.propsName}>` : "";
|
|
398
|
+
lines.push(`- \`${c.name}\`${props} — \`${c.rel}\``);
|
|
399
|
+
}
|
|
400
|
+
if (byDir[dir].length > 80)
|
|
401
|
+
lines.push(`- … ${byDir[dir].length - 80} more`);
|
|
402
|
+
lines.push("");
|
|
403
|
+
}
|
|
404
|
+
return lines.join("\n");
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
export function buildFrontendContextsDigest(repoRoot) {
|
|
409
|
+
const files = findFilesByGlob(repoRoot, /^mock\/src\/contexts\/[^/]+\.tsx?$/);
|
|
410
|
+
return buildDigest({
|
|
411
|
+
repoRoot, name: "frontend-contexts", inputFiles: files,
|
|
412
|
+
build: (inputs) => {
|
|
413
|
+
const lines = [];
|
|
414
|
+
lines.push("# Frontend mock contexts (`mock/src/contexts/`)\n");
|
|
415
|
+
lines.push("Hook signatures exported from mock data-context providers. Use these to consume mock data + mutators in specs.\n");
|
|
416
|
+
for (const rel of inputs) {
|
|
417
|
+
const body = safeRead(repoRoot, rel);
|
|
418
|
+
if (!body)
|
|
419
|
+
continue;
|
|
420
|
+
const sigs = extractHookSignatures(body);
|
|
421
|
+
if (sigs.length === 0)
|
|
422
|
+
continue;
|
|
423
|
+
lines.push(`## \`${rel}\``);
|
|
424
|
+
for (const s of sigs)
|
|
425
|
+
lines.push(`- \`${s}\``);
|
|
426
|
+
lines.push("");
|
|
427
|
+
}
|
|
428
|
+
return lines.join("\n");
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
export function buildTailwindTokensDigest(repoRoot) {
|
|
433
|
+
const files = findFilesByGlob(repoRoot, /^(mock|apps)\/.+\.(tsx|jsx)$/, { maxDepth: 10 });
|
|
434
|
+
return buildDigest({
|
|
435
|
+
repoRoot, name: "tokens", inputFiles: files,
|
|
436
|
+
build: (inputs) => {
|
|
437
|
+
const counts = {};
|
|
438
|
+
for (const rel of inputs) {
|
|
439
|
+
const body = safeRead(repoRoot, rel);
|
|
440
|
+
if (!body)
|
|
441
|
+
continue;
|
|
442
|
+
const toks = extractTailwindTokens(body);
|
|
443
|
+
for (const t of toks)
|
|
444
|
+
counts[t] = (counts[t] ?? 0) + 1;
|
|
445
|
+
}
|
|
446
|
+
const ranked = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
447
|
+
const lines = [];
|
|
448
|
+
lines.push("# Tailwind brand-token vocabulary\n");
|
|
449
|
+
lines.push("Auto-extracted from `mock/**/*.tsx` and `apps/**/*.tsx`. Specs / components SHOULD prefer these tokens over inventing new ones — the project's design system lives in these names.\n");
|
|
450
|
+
if (ranked.length === 0) {
|
|
451
|
+
lines.push("_(No brand-* / primary-* / secondary-* tokens detected.)_");
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
lines.push("| Token | Usages |");
|
|
455
|
+
lines.push("|---|---|");
|
|
456
|
+
for (const [tok, n] of ranked.slice(0, 60))
|
|
457
|
+
lines.push(`| \`${tok}\` | ${n} |`);
|
|
458
|
+
if (ranked.length > 60)
|
|
459
|
+
lines.push(`| … ${ranked.length - 60} more | |`);
|
|
460
|
+
}
|
|
461
|
+
return lines.join("\n");
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
export function buildConfigDigest(repoRoot) {
|
|
466
|
+
const interesting = [
|
|
467
|
+
"tsconfig.json",
|
|
468
|
+
"tsconfig.base.json",
|
|
469
|
+
"tailwind.config.ts",
|
|
470
|
+
"tailwind.config.js",
|
|
471
|
+
"next.config.ts",
|
|
472
|
+
"next.config.js",
|
|
473
|
+
"next.config.mjs",
|
|
474
|
+
"pnpm-workspace.yaml",
|
|
475
|
+
"package.json",
|
|
476
|
+
];
|
|
477
|
+
const files = [];
|
|
478
|
+
for (const p of interesting)
|
|
479
|
+
if (existsSync(join(repoRoot, p)))
|
|
480
|
+
files.push(p);
|
|
481
|
+
for (const p of ["mock/package.json", "apps/back/package.json", "apps/patient/package.json", "apps/therapist/package.json"]) {
|
|
482
|
+
if (existsSync(join(repoRoot, p)))
|
|
483
|
+
files.push(p);
|
|
484
|
+
}
|
|
485
|
+
return buildDigest({
|
|
486
|
+
repoRoot, name: "config", inputFiles: files,
|
|
487
|
+
build: (inputs) => {
|
|
488
|
+
const lines = [];
|
|
489
|
+
lines.push("# Build / config conventions\n");
|
|
490
|
+
lines.push("Auto-extracted from the consumer's tsconfig / tailwind / next / package.json files. Agents should respect path aliases + scripts listed here.\n");
|
|
491
|
+
const tscRoot = safeRead(repoRoot, "tsconfig.json");
|
|
492
|
+
if (tscRoot) {
|
|
493
|
+
try {
|
|
494
|
+
const parsed = JSON.parse(tscRoot.replace(/\/\*[^*]*\*\/|\/\/.*$/gm, ""));
|
|
495
|
+
const paths = parsed.compilerOptions?.paths;
|
|
496
|
+
if (paths) {
|
|
497
|
+
lines.push("## TypeScript path aliases (`tsconfig.json`)");
|
|
498
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
499
|
+
lines.push(`- \`${alias}\` → \`${targets.join(" | ")}\``);
|
|
500
|
+
}
|
|
501
|
+
lines.push("");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch { /* ignore parse errors */ }
|
|
505
|
+
}
|
|
506
|
+
const pkgPaths = inputs.filter((p) => p.endsWith("package.json"));
|
|
507
|
+
if (pkgPaths.length > 0) {
|
|
508
|
+
lines.push("## Workspace packages + run scripts");
|
|
509
|
+
for (const rel of pkgPaths) {
|
|
510
|
+
const body = safeRead(repoRoot, rel);
|
|
511
|
+
if (!body)
|
|
512
|
+
continue;
|
|
513
|
+
try {
|
|
514
|
+
const pkg = JSON.parse(body);
|
|
515
|
+
if (!pkg.name)
|
|
516
|
+
continue;
|
|
517
|
+
lines.push(`### \`${pkg.name}\` (\`${rel}\`)`);
|
|
518
|
+
const scripts = pkg.scripts ?? {};
|
|
519
|
+
for (const [s, cmd] of Object.entries(scripts).slice(0, 12)) {
|
|
520
|
+
lines.push(`- \`${s}\`: \`${cmd}\``);
|
|
521
|
+
}
|
|
522
|
+
lines.push("");
|
|
523
|
+
}
|
|
524
|
+
catch { /* ignore */ }
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return lines.join("\n");
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
export function buildMigrationsDigest(repoRoot) {
|
|
532
|
+
const files = findFilesByGlob(repoRoot, /\/migrations\/\d+[^/]*\.ts$/);
|
|
533
|
+
return buildDigest({
|
|
534
|
+
repoRoot, name: "migrations", inputFiles: files,
|
|
535
|
+
build: (inputs) => {
|
|
536
|
+
const lines = [];
|
|
537
|
+
lines.push("# Database migrations index\n");
|
|
538
|
+
lines.push("All migrations in chronological order with extracted table names. Specs that need NEW tables MUST emit a `database_migrations:` section following the patterns established below.\n");
|
|
539
|
+
for (const rel of inputs) {
|
|
540
|
+
const body = safeRead(repoRoot, rel);
|
|
541
|
+
if (!body)
|
|
542
|
+
continue;
|
|
543
|
+
const fname = rel.split("/").pop();
|
|
544
|
+
const tables = new Set();
|
|
545
|
+
// Variants we've observed across consumers:
|
|
546
|
+
// - TypeORM raw: createTable("foo", ...)
|
|
547
|
+
// - SQL string: CREATE TABLE foo (
|
|
548
|
+
// - delgoosh custom helper: DatabaseCreateTable(qr, "foo", ...)
|
|
549
|
+
// - new Table({name: "foo"})
|
|
550
|
+
const patterns = [
|
|
551
|
+
/createTable\s*\(\s*['"`](\w+)['"`]/g,
|
|
552
|
+
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)/gi,
|
|
553
|
+
/DatabaseCreateTable\s*\(\s*\w+\s*,\s*['"`](\w+)['"`]/g,
|
|
554
|
+
/new\s+Table\s*\(\s*\{[^}]*name\s*:\s*['"`](\w+)['"`]/g,
|
|
555
|
+
];
|
|
556
|
+
for (const re of patterns) {
|
|
557
|
+
let m;
|
|
558
|
+
while ((m = re.exec(body)) !== null)
|
|
559
|
+
tables.add(m[1]);
|
|
560
|
+
}
|
|
561
|
+
lines.push(`- \`${fname}\`${tables.size ? ` — tables: ${[...tables].join(", ")}` : ""}`);
|
|
562
|
+
}
|
|
563
|
+
return lines.join("\n");
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
export function buildRoutesInventoryDigest(repoRoot) {
|
|
568
|
+
const files = findFilesByGlob(repoRoot, /^(mock|apps\/[^/]+)\/src\/app\/.+\/page\.tsx$/);
|
|
569
|
+
return buildDigest({
|
|
570
|
+
repoRoot, name: "routes-inventory", inputFiles: files,
|
|
571
|
+
build: (inputs) => {
|
|
572
|
+
const lines = [];
|
|
573
|
+
lines.push("# Filesystem route inventory (Next.js App Router)\n");
|
|
574
|
+
lines.push("Auto-derived URL paths from `page.tsx` file locations. Specs should reference URLs from this list rather than inventing new ones.\n");
|
|
575
|
+
const byApp = {};
|
|
576
|
+
for (const rel of inputs) {
|
|
577
|
+
const m = rel.match(/^((?:mock|apps\/[^/]+))\/src\/app\/(.+)\/page\.tsx$/);
|
|
578
|
+
if (!m)
|
|
579
|
+
continue;
|
|
580
|
+
const app = m[1];
|
|
581
|
+
const route = "/" + m[2]
|
|
582
|
+
.replace(/\([^)]+\)\//g, "")
|
|
583
|
+
.replace(/\[([^\]]+)\]/g, ":$1");
|
|
584
|
+
if (!byApp[app])
|
|
585
|
+
byApp[app] = [];
|
|
586
|
+
byApp[app].push(route);
|
|
587
|
+
}
|
|
588
|
+
for (const app of Object.keys(byApp).sort()) {
|
|
589
|
+
lines.push(`## \`${app}/\``);
|
|
590
|
+
for (const r of byApp[app].sort())
|
|
591
|
+
lines.push(`- \`${r}\``);
|
|
592
|
+
lines.push("");
|
|
593
|
+
}
|
|
594
|
+
return lines.join("\n");
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
export function refreshKnowledgeAuto(repoRoot, opts = {}) {
|
|
599
|
+
const builders = [
|
|
600
|
+
{ name: "backend-entities", fn: () => buildBackendEntitiesDigest(repoRoot) },
|
|
601
|
+
{ name: "backend-routes", fn: () => buildBackendRoutesDigest(repoRoot) },
|
|
602
|
+
{ name: "backend-enums", fn: () => buildBackendEnumsDigest(repoRoot) },
|
|
603
|
+
{ name: "frontend-types", fn: () => buildFrontendTypesDigest(repoRoot) },
|
|
604
|
+
{ name: "frontend-components", fn: () => buildFrontendComponentsDigest(repoRoot) },
|
|
605
|
+
{ name: "frontend-contexts", fn: () => buildFrontendContextsDigest(repoRoot) },
|
|
606
|
+
{ name: "tokens", fn: () => buildTailwindTokensDigest(repoRoot) },
|
|
607
|
+
{ name: "config", fn: () => buildConfigDigest(repoRoot) },
|
|
608
|
+
{ name: "migrations", fn: () => buildMigrationsDigest(repoRoot) },
|
|
609
|
+
{ name: "routes-inventory", fn: () => buildRoutesInventoryDigest(repoRoot) },
|
|
610
|
+
];
|
|
611
|
+
const built = [];
|
|
612
|
+
const skippedEmpty = [];
|
|
613
|
+
for (const b of builders) {
|
|
614
|
+
if (opts.only && b.name !== opts.only)
|
|
615
|
+
continue;
|
|
616
|
+
const r = b.fn();
|
|
617
|
+
if (r.built)
|
|
618
|
+
built.push(b.name);
|
|
619
|
+
else
|
|
620
|
+
skippedEmpty.push(b.name);
|
|
621
|
+
}
|
|
622
|
+
return { built, skippedEmpty, outDir: join(repoRoot, AUTO_DIR_REL) };
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* CLI entry point. Called from cli.ts.
|
|
626
|
+
*
|
|
627
|
+
* Default behavior (no mode flag) = run both --auto and --mine-history,
|
|
628
|
+
* since they target different output dirs and have no overlap. Either
|
|
629
|
+
* mode can be requested individually with the explicit flag.
|
|
630
|
+
*/
|
|
631
|
+
export async function refreshKnowledge(argv) {
|
|
632
|
+
const args = parseArgs(argv);
|
|
633
|
+
if (args.help) {
|
|
634
|
+
printHelp();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const runAuto = args.mode === "auto" || args.mode === "all";
|
|
638
|
+
const runHistory = args.mode === "mine-history" || args.mode === "all";
|
|
639
|
+
console.log(`slowcook refresh-knowledge · ${args.repoRoot}`);
|
|
640
|
+
if (runAuto) {
|
|
641
|
+
const result = refreshKnowledgeAuto(args.repoRoot, { only: args.only });
|
|
642
|
+
console.log(` [auto] output: ${result.outDir}`);
|
|
643
|
+
console.log(` [auto] built: ${result.built.length > 0 ? result.built.join(", ") : "(nothing built)"}`);
|
|
644
|
+
if (result.skippedEmpty.length > 0) {
|
|
645
|
+
console.log(` [auto] skipped (no inputs): ${result.skippedEmpty.join(", ")}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (runHistory) {
|
|
649
|
+
const result = refreshKnowledgeMineHistory(args.repoRoot, { full: args.fullHistory });
|
|
650
|
+
console.log(` [history] output: ${result.outDir}`);
|
|
651
|
+
console.log(` [history] built: ${result.built.length > 0 ? result.built.join(", ") : "(nothing built)"}`);
|
|
652
|
+
console.log(` [history] commits processed: ${result.commitsProcessed}${result.deltaFromSha ? ` (delta-aware: stamp had ${result.deltaFromSha.slice(0, 8)})` : ""}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function parseArgs(argv) {
|
|
656
|
+
let repoRoot = process.cwd();
|
|
657
|
+
let mode = "all";
|
|
658
|
+
let only;
|
|
659
|
+
let fullHistory = false;
|
|
660
|
+
let help = false;
|
|
661
|
+
let modeExplicit = false;
|
|
662
|
+
for (let i = 0; i < argv.length; i++) {
|
|
663
|
+
const a = argv[i];
|
|
664
|
+
const next = argv[i + 1];
|
|
665
|
+
if (a === "--cwd" && next) {
|
|
666
|
+
repoRoot = next;
|
|
667
|
+
i++;
|
|
668
|
+
}
|
|
669
|
+
else if (a === "--only" && next) {
|
|
670
|
+
only = next;
|
|
671
|
+
i++;
|
|
672
|
+
mode = "auto";
|
|
673
|
+
modeExplicit = true;
|
|
674
|
+
}
|
|
675
|
+
else if (a === "--auto") {
|
|
676
|
+
mode = modeExplicit ? mode : "auto";
|
|
677
|
+
modeExplicit = true;
|
|
678
|
+
}
|
|
679
|
+
else if (a === "--mine-history") {
|
|
680
|
+
mode = modeExplicit ? "all" : "mine-history";
|
|
681
|
+
modeExplicit = true;
|
|
682
|
+
}
|
|
683
|
+
else if (a === "--full") {
|
|
684
|
+
fullHistory = true;
|
|
685
|
+
}
|
|
686
|
+
else if (a === "--help" || a === "-h") {
|
|
687
|
+
help = true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return { repoRoot, mode, only, fullHistory, help };
|
|
691
|
+
}
|
|
692
|
+
function printHelp() {
|
|
693
|
+
console.log(`
|
|
694
|
+
slowcook refresh-knowledge — rebuild repo-knowledge digests
|
|
695
|
+
|
|
696
|
+
Usage:
|
|
697
|
+
slowcook refresh-knowledge [--auto] [--mine-history] [--only <name>] [--cwd <path>]
|
|
698
|
+
|
|
699
|
+
Modes:
|
|
700
|
+
--auto rebuild auto/ digests (default if no mode given)
|
|
701
|
+
cheap extractions, always rebuild
|
|
702
|
+
--mine-history rebuild curated/ files from git history
|
|
703
|
+
expensive deterministic; delta-aware (re-mines new commits only)
|
|
704
|
+
|
|
705
|
+
--only <name> filters to one digest (auto mode only).
|
|
706
|
+
|
|
707
|
+
auto/ outputs are gitignored. curated/ outputs are TRACKED in git —
|
|
708
|
+
they're the durable organizational memory.
|
|
709
|
+
`);
|
|
710
|
+
}
|
|
711
|
+
function writeCurated(repoRoot, name, body) {
|
|
712
|
+
const abs = join(repoRoot, CURATED_DIR_REL, `${name}.md`);
|
|
713
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
714
|
+
writeFileSync(abs, body, "utf8");
|
|
715
|
+
}
|
|
716
|
+
function readStamp(repoRoot) {
|
|
717
|
+
const path = join(repoRoot, CURATED_DIR_REL, ".last-mined.json");
|
|
718
|
+
if (!existsSync(path))
|
|
719
|
+
return null;
|
|
720
|
+
try {
|
|
721
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function writeStamp(repoRoot, stamp) {
|
|
728
|
+
const abs = join(repoRoot, CURATED_DIR_REL, ".last-mined.json");
|
|
729
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
730
|
+
writeFileSync(abs, JSON.stringify(stamp, null, 2) + "\n", "utf8");
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Walk git log, return CommitRow[] sorted oldest-first.
|
|
734
|
+
*
|
|
735
|
+
* Uses `git log --name-only --pretty=format` with a unique separator so
|
|
736
|
+
* we can parse robustly. The default `maxCommits` cap (1500) covers a
|
|
737
|
+
* typical brownfield project's history without timing out.
|
|
738
|
+
*/
|
|
739
|
+
function gitLogRows(repoRoot, maxCommits = 1500) {
|
|
740
|
+
const sep = "<<<COMMIT>>>";
|
|
741
|
+
const fieldSep = "<<<F>>>";
|
|
742
|
+
let raw = "";
|
|
743
|
+
try {
|
|
744
|
+
raw = execSync(`git -C "${repoRoot}" log --no-merges --name-only --pretty=format:'${sep}%H${fieldSep}%P${fieldSep}%an${fieldSep}%aI${fieldSep}%s' -n ${maxCommits}`, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return [];
|
|
748
|
+
}
|
|
749
|
+
const out = [];
|
|
750
|
+
for (const block of raw.split(sep)) {
|
|
751
|
+
const trimmed = block.trim();
|
|
752
|
+
if (!trimmed)
|
|
753
|
+
continue;
|
|
754
|
+
const lines = trimmed.split("\n");
|
|
755
|
+
const header = lines[0] ?? "";
|
|
756
|
+
const parts = header.split(fieldSep);
|
|
757
|
+
if (parts.length < 5)
|
|
758
|
+
continue;
|
|
759
|
+
const sha = parts[0];
|
|
760
|
+
const parent = parts[1].split(" ")[0] ?? "";
|
|
761
|
+
const author = parts[2];
|
|
762
|
+
const date = parts[3];
|
|
763
|
+
const subject = parts[4] ?? "";
|
|
764
|
+
const files = lines.slice(1).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
765
|
+
out.push({ sha, parent, author, date, subject, files });
|
|
766
|
+
}
|
|
767
|
+
// Reverse so oldest is first — easier to reason about temporal accumulation.
|
|
768
|
+
return out.reverse();
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* commit-conventions.md — bucket by type:scope from conventional-commit
|
|
772
|
+
* prefixes. Tells refine which prefixes + scopes are in active use so
|
|
773
|
+
* spec PRs match the local style.
|
|
774
|
+
*/
|
|
775
|
+
function buildCommitConventions(rows) {
|
|
776
|
+
const conventionRe = /^(feat|fix|chore|refactor|docs|test|perf|style|build|ci|revert)(?:\(([^)]+)\))?\s*:/;
|
|
777
|
+
const byType = {};
|
|
778
|
+
const byScope = {};
|
|
779
|
+
let unconventional = 0;
|
|
780
|
+
for (const r of rows) {
|
|
781
|
+
const m = r.subject.match(conventionRe);
|
|
782
|
+
if (!m) {
|
|
783
|
+
unconventional++;
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const type = m[1];
|
|
787
|
+
let scope = m[2] ?? "(none)";
|
|
788
|
+
// Filter out `#NNN`-style "scopes" that are actually issue refs the
|
|
789
|
+
// author accidentally put in the parens (e.g., `fix(#618): ...`).
|
|
790
|
+
if (/^#\d+$/.test(scope))
|
|
791
|
+
scope = "(none)";
|
|
792
|
+
byType[type] = (byType[type] ?? 0) + 1;
|
|
793
|
+
byScope[scope] = (byScope[scope] ?? 0) + 1;
|
|
794
|
+
}
|
|
795
|
+
const total = rows.length;
|
|
796
|
+
const conventional = total - unconventional;
|
|
797
|
+
const lines = [];
|
|
798
|
+
lines.push("# Commit conventions (mined from git history)\n");
|
|
799
|
+
lines.push(`_Sample: ${total} non-merge commits; ${conventional} use conventional-commit prefixes (${Math.round(100 * conventional / Math.max(total, 1))}%)._`);
|
|
800
|
+
lines.push("");
|
|
801
|
+
lines.push("## Active type buckets");
|
|
802
|
+
const typesSorted = Object.entries(byType).sort((a, b) => b[1] - a[1]);
|
|
803
|
+
lines.push("| Type | Count |");
|
|
804
|
+
lines.push("|---|---|");
|
|
805
|
+
for (const [t, n] of typesSorted)
|
|
806
|
+
lines.push(`| \`${t}\` | ${n} |`);
|
|
807
|
+
lines.push("");
|
|
808
|
+
lines.push("## Active scopes (use these in new commit messages)");
|
|
809
|
+
const scopesSorted = Object.entries(byScope).sort((a, b) => b[1] - a[1]).filter(([s]) => s !== "(none)");
|
|
810
|
+
lines.push("| Scope | Count |");
|
|
811
|
+
lines.push("|---|---|");
|
|
812
|
+
for (const [s, n] of scopesSorted.slice(0, 30))
|
|
813
|
+
lines.push(`| \`${s}\` | ${n} |`);
|
|
814
|
+
if (scopesSorted.length > 30)
|
|
815
|
+
lines.push(`| … ${scopesSorted.length - 30} more | |`);
|
|
816
|
+
return lines.join("\n");
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* co-changes.md — file pairs that co-occur in commits >= threshold
|
|
820
|
+
* times. Surfaces temporal coupling that's invisible to static
|
|
821
|
+
* analysis (e.g., "every time `appointment.entity.ts` changes,
|
|
822
|
+
* `appointment.dto.ts` changes too in 12/14 cases").
|
|
823
|
+
*
|
|
824
|
+
* Quadratic in files-per-commit; bounded to commits with <=20 files
|
|
825
|
+
* to prevent O(n²) blow-up on huge refactors that aren't useful
|
|
826
|
+
* signal anyway.
|
|
827
|
+
*/
|
|
828
|
+
function buildCoChanges(rows) {
|
|
829
|
+
const pairCounts = new Map();
|
|
830
|
+
const fileCounts = new Map();
|
|
831
|
+
for (const r of rows) {
|
|
832
|
+
if (r.files.length === 0 || r.files.length > 20)
|
|
833
|
+
continue;
|
|
834
|
+
const interesting = r.files.filter((f) => /\.(ts|tsx|js|jsx|sql|md)$/.test(f) && !f.startsWith(".brewing/") && !f.startsWith("node_modules/"));
|
|
835
|
+
for (const f of interesting)
|
|
836
|
+
fileCounts.set(f, (fileCounts.get(f) ?? 0) + 1);
|
|
837
|
+
for (let i = 0; i < interesting.length; i++) {
|
|
838
|
+
for (let j = i + 1; j < interesting.length; j++) {
|
|
839
|
+
const a = interesting[i];
|
|
840
|
+
const b = interesting[j];
|
|
841
|
+
const key = a < b ? `${a}\t${b}` : `${b}\t${a}`;
|
|
842
|
+
pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const lines = [];
|
|
847
|
+
lines.push("# File co-change map (mined from git history)\n");
|
|
848
|
+
lines.push("Pairs of files that historically change together. When a spec / PR touches one, also consider the other — co-change ≥3 usually signals coupling the type system can't see.\n");
|
|
849
|
+
const pairs = [...pairCounts.entries()]
|
|
850
|
+
.filter(([_, n]) => n >= 3)
|
|
851
|
+
.map(([key, n]) => {
|
|
852
|
+
const [a, b] = key.split("\t");
|
|
853
|
+
return { a: a, b: b, n, support: Math.min(fileCounts.get(a) ?? 1, fileCounts.get(b) ?? 1) };
|
|
854
|
+
})
|
|
855
|
+
.filter((p) => p.n >= Math.max(3, Math.floor(p.support * 0.5))) // co-occur in >=50% of either file's commits
|
|
856
|
+
.sort((a, b) => b.n - a.n);
|
|
857
|
+
lines.push("| File A | File B | Co-changes |");
|
|
858
|
+
lines.push("|---|---|---|");
|
|
859
|
+
for (const p of pairs.slice(0, 60)) {
|
|
860
|
+
lines.push(`| \`${p.a}\` | \`${p.b}\` | ${p.n} |`);
|
|
861
|
+
}
|
|
862
|
+
if (pairs.length > 60)
|
|
863
|
+
lines.push(`| … ${pairs.length - 60} more | | |`);
|
|
864
|
+
return lines.join("\n");
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* ownership.md — top author per top-level directory. Tells refine
|
|
868
|
+
* who owns what (useful for routing PM-facing questions in agent comments).
|
|
869
|
+
*/
|
|
870
|
+
function buildOwnership(rows) {
|
|
871
|
+
// Per-directory granularity at depth 2 means we get "apps/back" or
|
|
872
|
+
// "packages/dtos" but also occasionally "packages/postgres" + sibling
|
|
873
|
+
// entries that explode the table. Special-cased: top-level dirs with
|
|
874
|
+
// a single file (.brewing/*.md, .githooks/*, etc.) collapse to the
|
|
875
|
+
// top-level dir to avoid one row per file.
|
|
876
|
+
const dirAuthorCount = new Map();
|
|
877
|
+
const TOPLEVEL_COLLAPSE = new Set([".brewing", ".github", ".cursor", ".vscode", ".husky", ".githooks"]);
|
|
878
|
+
for (const r of rows) {
|
|
879
|
+
for (const f of r.files) {
|
|
880
|
+
const parts = f.split("/");
|
|
881
|
+
const top = parts[0];
|
|
882
|
+
const dir = TOPLEVEL_COLLAPSE.has(top) ? top : parts.slice(0, 2).join("/") || ".";
|
|
883
|
+
let m = dirAuthorCount.get(dir);
|
|
884
|
+
if (!m) {
|
|
885
|
+
m = new Map();
|
|
886
|
+
dirAuthorCount.set(dir, m);
|
|
887
|
+
}
|
|
888
|
+
m.set(r.author, (m.get(r.author) ?? 0) + 1);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const lines = [];
|
|
892
|
+
lines.push("# Directory ownership (mined from git authorship)\n");
|
|
893
|
+
lines.push("Top contributor per directory. Use this to decide who to route a PM-facing question to when a story spans multiple areas.\n");
|
|
894
|
+
lines.push("| Directory | Top author | Commits |");
|
|
895
|
+
lines.push("|---|---|---|");
|
|
896
|
+
const dirsSorted = [...dirAuthorCount.keys()].sort();
|
|
897
|
+
for (const dir of dirsSorted) {
|
|
898
|
+
const authors = [...dirAuthorCount.get(dir).entries()].sort((a, b) => b[1] - a[1]);
|
|
899
|
+
if (authors.length === 0)
|
|
900
|
+
continue;
|
|
901
|
+
const [topAuthor, topCount] = authors[0];
|
|
902
|
+
lines.push(`| \`${dir}\` | ${topAuthor} | ${topCount} |`);
|
|
903
|
+
}
|
|
904
|
+
return lines.join("\n");
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* issue-traceability.md — for each `#NNN` issue/PR reference in a
|
|
908
|
+
* commit subject, list the commits that mention it. Cheap, useful for
|
|
909
|
+
* agents that want to find the PM context behind a code change.
|
|
910
|
+
*/
|
|
911
|
+
function buildIssueTraceability(rows) {
|
|
912
|
+
const issueRe = /#(\d+)/g;
|
|
913
|
+
const issueToCommits = new Map();
|
|
914
|
+
for (const r of rows) {
|
|
915
|
+
let m;
|
|
916
|
+
issueRe.lastIndex = 0;
|
|
917
|
+
while ((m = issueRe.exec(r.subject)) !== null) {
|
|
918
|
+
const n = m[1];
|
|
919
|
+
if (!issueToCommits.has(n))
|
|
920
|
+
issueToCommits.set(n, []);
|
|
921
|
+
issueToCommits.get(n).push({ sha: r.sha.slice(0, 8), subject: r.subject });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const lines = [];
|
|
925
|
+
lines.push("# Issue / PR traceability (mined from commit subjects)\n");
|
|
926
|
+
lines.push("Maps `#N` references to the commits that mention them. Use to find PM intent behind a body of code changes.\n");
|
|
927
|
+
const issues = [...issueToCommits.entries()].sort((a, b) => parseInt(b[0], 10) - parseInt(a[0], 10));
|
|
928
|
+
for (const [n, commits] of issues.slice(0, 100)) {
|
|
929
|
+
lines.push(`### #${n}`);
|
|
930
|
+
for (const c of commits.slice(0, 8)) {
|
|
931
|
+
lines.push(`- \`${c.sha}\` ${c.subject.replace(/\|/g, "\\|")}`);
|
|
932
|
+
}
|
|
933
|
+
lines.push("");
|
|
934
|
+
}
|
|
935
|
+
if (issues.length > 100)
|
|
936
|
+
lines.push(`_… ${issues.length - 100} more issues with fewer commits._`);
|
|
937
|
+
return lines.join("\n");
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* fix-recipe-seeds.md — for each fix(*) commit, group by the files
|
|
941
|
+
* touched. Refine + chef use this to spot recurring failure classes
|
|
942
|
+
* (e.g., "vitest.config.ts has been fixed twice — known-flaky area").
|
|
943
|
+
* NOT a curated insight (that's the next layer); just file→fix-PRs
|
|
944
|
+
* map to surface the pattern.
|
|
945
|
+
*/
|
|
946
|
+
function buildFixRecipeSeeds(rows) {
|
|
947
|
+
const fileFixCount = new Map();
|
|
948
|
+
for (const r of rows) {
|
|
949
|
+
if (!/^fix\b/.test(r.subject))
|
|
950
|
+
continue;
|
|
951
|
+
for (const f of r.files) {
|
|
952
|
+
if (!/\.(ts|tsx|js|jsx|json|yaml|yml)$/.test(f))
|
|
953
|
+
continue;
|
|
954
|
+
if (!fileFixCount.has(f))
|
|
955
|
+
fileFixCount.set(f, []);
|
|
956
|
+
fileFixCount.get(f).push({ sha: r.sha.slice(0, 8), subject: r.subject, date: r.date.slice(0, 10) });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const lines = [];
|
|
960
|
+
lines.push("# Fix-recipe seeds (mined from fix(*) commits)\n");
|
|
961
|
+
lines.push("Files that have been the target of fix-commits. A file with multiple fixes is a known-fragile area — check the listed commits before re-engineering. (Insights derived from these seeds live in `chef-known-fixes.md` once chef has analysed them.)\n");
|
|
962
|
+
const ranked = [...fileFixCount.entries()].filter(([_, fixes]) => fixes.length >= 2).sort((a, b) => b[1].length - a[1].length);
|
|
963
|
+
for (const [file, fixes] of ranked.slice(0, 40)) {
|
|
964
|
+
lines.push(`### \`${file}\` — fixed ${fixes.length}×`);
|
|
965
|
+
for (const f of fixes.slice(0, 6)) {
|
|
966
|
+
lines.push(`- \`${f.sha}\` (${f.date}) ${f.subject}`);
|
|
967
|
+
}
|
|
968
|
+
lines.push("");
|
|
969
|
+
}
|
|
970
|
+
if (ranked.length > 40)
|
|
971
|
+
lines.push(`_… ${ranked.length - 40} more files with 2+ fixes._`);
|
|
972
|
+
return lines.join("\n");
|
|
973
|
+
}
|
|
974
|
+
export function refreshKnowledgeMineHistory(repoRoot, opts = {}) {
|
|
975
|
+
const stamp = opts.full ? null : readStamp(repoRoot);
|
|
976
|
+
const maxCommits = opts.maxCommits ?? 1500;
|
|
977
|
+
const rows = gitLogRows(repoRoot, maxCommits);
|
|
978
|
+
if (rows.length === 0) {
|
|
979
|
+
return { outDir: join(repoRoot, CURATED_DIR_REL), built: [], commitsProcessed: 0, deltaFromSha: null };
|
|
980
|
+
}
|
|
981
|
+
// For now: always re-mine the full window. Delta-aware merge is
|
|
982
|
+
// wired up via the stamp file (consumers see `last_sha`), but the
|
|
983
|
+
// actual incremental aggregation lives in a later alpha — at 1000
|
|
984
|
+
// commits this still takes <2s, so the full rebuild is fine.
|
|
985
|
+
const built = [];
|
|
986
|
+
const write = (name, body) => {
|
|
987
|
+
writeCurated(repoRoot, name, body);
|
|
988
|
+
built.push(name);
|
|
989
|
+
};
|
|
990
|
+
write("commit-conventions", buildCommitConventions(rows));
|
|
991
|
+
write("co-changes", buildCoChanges(rows));
|
|
992
|
+
write("ownership", buildOwnership(rows));
|
|
993
|
+
write("issue-traceability", buildIssueTraceability(rows));
|
|
994
|
+
write("fix-recipe-seeds", buildFixRecipeSeeds(rows));
|
|
995
|
+
// Stamp updates regardless — captures the most-recent SHA we've
|
|
996
|
+
// seen so future delta-mining knows where to resume from.
|
|
997
|
+
const newest = rows[rows.length - 1];
|
|
998
|
+
writeStamp(repoRoot, {
|
|
999
|
+
last_sha: newest.sha,
|
|
1000
|
+
last_mined_at: new Date().toISOString(),
|
|
1001
|
+
total_commits_seen: rows.length,
|
|
1002
|
+
});
|
|
1003
|
+
return {
|
|
1004
|
+
outDir: join(repoRoot, CURATED_DIR_REL),
|
|
1005
|
+
built,
|
|
1006
|
+
commitsProcessed: rows.length,
|
|
1007
|
+
deltaFromSha: stamp?.last_sha ?? null,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
//# sourceMappingURL=refresh-knowledge.js.map
|