etymd 0.1.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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/approve-WM6OJHQG.js +37 -0
- package/dist/audit-OHOSWOWA.js +8 -0
- package/dist/brief-2MPNTITQ.js +94 -0
- package/dist/chunk-5D7WNJ6N.js +1148 -0
- package/dist/chunk-FXL4554F.js +176 -0
- package/dist/chunk-I566LDX7.js +120 -0
- package/dist/chunk-N64ZTBCW.js +105 -0
- package/dist/chunk-SMM2TW35.js +67 -0
- package/dist/chunk-TCWKIMCU.js +502 -0
- package/dist/chunk-U33XLT5I.js +9 -0
- package/dist/chunk-WVYKYPKS.js +332 -0
- package/dist/cli.js +104 -0
- package/dist/context-TT5H76LA.js +18 -0
- package/dist/doctor-NNRJTNIY.js +15 -0
- package/dist/gates-KPDEUC36.js +71 -0
- package/dist/index.d.ts +444 -0
- package/dist/index.js +2095 -0
- package/dist/init-V627UXKU.js +116 -0
- package/dist/ledger-YFTYCOIC.js +39 -0
- package/dist/scan-YL7JHLUA.js +22 -0
- package/package.json +83 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2095 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises } from 'node:fs';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import YAML from 'yaml';
|
|
6
|
+
|
|
7
|
+
// src/core/scan.ts
|
|
8
|
+
|
|
9
|
+
// src/pack/version.ts
|
|
10
|
+
var PACK_VERSION = "2";
|
|
11
|
+
|
|
12
|
+
// package.json
|
|
13
|
+
var package_default = {
|
|
14
|
+
name: "etymd",
|
|
15
|
+
version: "0.1.0"};
|
|
16
|
+
|
|
17
|
+
// src/version.ts
|
|
18
|
+
var VERSION = package_default.version;
|
|
19
|
+
var NAME = package_default.name;
|
|
20
|
+
var pExecFile = promisify(execFile);
|
|
21
|
+
async function pathExists(p) {
|
|
22
|
+
try {
|
|
23
|
+
await promises.access(p);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function readJson(p) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await promises.readFile(p, "utf8");
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function readText(p) {
|
|
38
|
+
try {
|
|
39
|
+
return await promises.readFile(p, "utf8");
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function isDirectory(p) {
|
|
45
|
+
try {
|
|
46
|
+
return (await promises.stat(p)).isDirectory();
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function git(root, args) {
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await pExecFile("git", args, { cwd: root, timeout: 4e3 });
|
|
54
|
+
return stdout.trim();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function wordCount(text) {
|
|
60
|
+
const m = text.trim().match(/\S+/g);
|
|
61
|
+
return m ? m.length : 0;
|
|
62
|
+
}
|
|
63
|
+
function approxTokens(words) {
|
|
64
|
+
return Math.round(words / 0.75);
|
|
65
|
+
}
|
|
66
|
+
var CI_ENV_VARS = [
|
|
67
|
+
"CI",
|
|
68
|
+
"GITHUB_ACTIONS",
|
|
69
|
+
"GITLAB_CI",
|
|
70
|
+
"BUILDKITE",
|
|
71
|
+
"CIRCLECI",
|
|
72
|
+
"JENKINS_URL",
|
|
73
|
+
"TF_BUILD"
|
|
74
|
+
];
|
|
75
|
+
function isCiEnvironment() {
|
|
76
|
+
return CI_ENV_VARS.some((v) => Boolean(process.env[v]));
|
|
77
|
+
}
|
|
78
|
+
function normalizeRelPath(p) {
|
|
79
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
80
|
+
}
|
|
81
|
+
function globToRegExp(glob) {
|
|
82
|
+
let re = "";
|
|
83
|
+
for (let i = 0; i < glob.length; i++) {
|
|
84
|
+
const c = glob[i];
|
|
85
|
+
if (c === "*") {
|
|
86
|
+
if (glob[i + 1] === "*") {
|
|
87
|
+
i += 1;
|
|
88
|
+
if (glob[i + 1] === "/") {
|
|
89
|
+
i += 1;
|
|
90
|
+
re += "(?:.*/)?";
|
|
91
|
+
} else {
|
|
92
|
+
re += ".*";
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
re += "[^/]*";
|
|
96
|
+
}
|
|
97
|
+
} else if (c === "?") {
|
|
98
|
+
re += "[^/]";
|
|
99
|
+
} else {
|
|
100
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return new RegExp(`^${re}$`);
|
|
104
|
+
}
|
|
105
|
+
function matchesGlob(p, glob) {
|
|
106
|
+
const target = normalizeRelPath(p);
|
|
107
|
+
const pattern = normalizeRelPath(glob);
|
|
108
|
+
if (!pattern) return false;
|
|
109
|
+
if (!/[*?]/.test(pattern)) return target === pattern || target.startsWith(pattern + "/");
|
|
110
|
+
return globToRegExp(pattern).test(target);
|
|
111
|
+
}
|
|
112
|
+
function matchesAnyGlob(p, globs) {
|
|
113
|
+
return globs.some((g) => matchesGlob(p, g));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/core/detect.ts
|
|
117
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
118
|
+
"node_modules",
|
|
119
|
+
".git",
|
|
120
|
+
"dist",
|
|
121
|
+
"build",
|
|
122
|
+
"coverage",
|
|
123
|
+
".next",
|
|
124
|
+
".turbo",
|
|
125
|
+
".cache",
|
|
126
|
+
".pnpm",
|
|
127
|
+
"out"
|
|
128
|
+
]);
|
|
129
|
+
var MEANINGFUL_DOT_DIRS = /* @__PURE__ */ new Set([
|
|
130
|
+
".github",
|
|
131
|
+
".gitlab",
|
|
132
|
+
".claude",
|
|
133
|
+
".cursor",
|
|
134
|
+
".githooks",
|
|
135
|
+
".husky",
|
|
136
|
+
".changeset",
|
|
137
|
+
".vscode",
|
|
138
|
+
".storybook"
|
|
139
|
+
]);
|
|
140
|
+
async function detectPackageManager(root, pkg) {
|
|
141
|
+
if (pkg?.packageManager) {
|
|
142
|
+
const name = pkg.packageManager.split("@")[0];
|
|
143
|
+
if (name === "pnpm" || name === "yarn" || name === "npm" || name === "bun") return name;
|
|
144
|
+
}
|
|
145
|
+
const lock = [
|
|
146
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
147
|
+
["yarn.lock", "yarn"],
|
|
148
|
+
["package-lock.json", "npm"],
|
|
149
|
+
["bun.lockb", "bun"]
|
|
150
|
+
];
|
|
151
|
+
for (const [file, pm] of lock) {
|
|
152
|
+
if (await pathExists(path.join(root, file))) return pm;
|
|
153
|
+
}
|
|
154
|
+
return "unknown";
|
|
155
|
+
}
|
|
156
|
+
async function detectWorkspace(root, pkg) {
|
|
157
|
+
if (await pathExists(path.join(root, "nx.json"))) return { kind: "nx", packageGlobs: [] };
|
|
158
|
+
if (await pathExists(path.join(root, "turbo.json"))) return { kind: "turbo", packageGlobs: [] };
|
|
159
|
+
if (await pathExists(path.join(root, "lerna.json"))) return { kind: "lerna", packageGlobs: [] };
|
|
160
|
+
const pnpmWs = await readText(path.join(root, "pnpm-workspace.yaml"));
|
|
161
|
+
if (pnpmWs) {
|
|
162
|
+
const globs = pnpmWs.split("\n").map((l) => l.match(/^\s*-\s*["']?([^"'#]+)["']?/)?.[1]?.trim()).filter((g) => Boolean(g));
|
|
163
|
+
return { kind: "pnpm", packageGlobs: globs };
|
|
164
|
+
}
|
|
165
|
+
if (pkg?.workspaces) {
|
|
166
|
+
const globs = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? [];
|
|
167
|
+
return {
|
|
168
|
+
kind: pkg.packageManager?.startsWith("yarn") ? "yarn" : "npm",
|
|
169
|
+
packageGlobs: globs
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { kind: "none", packageGlobs: [] };
|
|
173
|
+
}
|
|
174
|
+
async function listWorkspacePackages(root, globs) {
|
|
175
|
+
const found = [];
|
|
176
|
+
const seen = /* @__PURE__ */ new Set();
|
|
177
|
+
const addFromDir = async (dir) => {
|
|
178
|
+
const pkgPath = path.join(dir, "package.json");
|
|
179
|
+
if (seen.has(pkgPath)) return;
|
|
180
|
+
const pkg = await readJson(pkgPath);
|
|
181
|
+
if (!pkg?.name) return;
|
|
182
|
+
seen.add(pkgPath);
|
|
183
|
+
found.push({
|
|
184
|
+
name: pkg.name,
|
|
185
|
+
dir: path.relative(root, dir) || ".",
|
|
186
|
+
private: Boolean(pkg.private),
|
|
187
|
+
version: pkg.version
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
for (const glob of globs) {
|
|
191
|
+
const base = glob.replace(/\/\*\*?$/, "");
|
|
192
|
+
const parent = path.join(root, base);
|
|
193
|
+
if (!await isDirectory(parent)) continue;
|
|
194
|
+
let entries;
|
|
195
|
+
try {
|
|
196
|
+
entries = await promises.readdir(parent);
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
if (IGNORED_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
202
|
+
const child = path.join(parent, entry);
|
|
203
|
+
if (await isDirectory(child)) await addFromDir(child);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return found.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
207
|
+
}
|
|
208
|
+
async function expandFileGlobs(root, globs, cap = 500) {
|
|
209
|
+
if (!globs.length) return [];
|
|
210
|
+
const out = /* @__PURE__ */ new Set();
|
|
211
|
+
const walk = async (rel) => {
|
|
212
|
+
if (out.size >= cap) return;
|
|
213
|
+
let entries;
|
|
214
|
+
try {
|
|
215
|
+
entries = await promises.readdir(path.join(root, rel), { withFileTypes: true });
|
|
216
|
+
} catch {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (out.size >= cap) return;
|
|
221
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
222
|
+
const child = rel ? `${rel}/${entry.name}` : entry.name;
|
|
223
|
+
if (entry.isDirectory()) await walk(child);
|
|
224
|
+
else if (entry.isFile() && matchesAnyGlob(child, globs)) out.add(child);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const roots = /* @__PURE__ */ new Set();
|
|
228
|
+
for (const glob of globs) {
|
|
229
|
+
const segments = normalizeRelPath(glob).split("/");
|
|
230
|
+
const literal = [];
|
|
231
|
+
for (const seg of segments) {
|
|
232
|
+
if (/[*?]/.test(seg)) break;
|
|
233
|
+
literal.push(seg);
|
|
234
|
+
}
|
|
235
|
+
if (literal.length === segments.length) literal.pop();
|
|
236
|
+
roots.add(literal.join("/"));
|
|
237
|
+
}
|
|
238
|
+
const minimal = [...roots].filter(
|
|
239
|
+
(r) => ![...roots].some((other) => other !== r && (other === "" || r.startsWith(other + "/")))
|
|
240
|
+
);
|
|
241
|
+
for (const r of minimal) {
|
|
242
|
+
if (r && !await isDirectory(path.join(root, r))) continue;
|
|
243
|
+
await walk(r);
|
|
244
|
+
}
|
|
245
|
+
return [...out].sort();
|
|
246
|
+
}
|
|
247
|
+
var isMeta = (value) => /npm-run-all|run-s\b|run-p\b|concurrently|&&/.test(value);
|
|
248
|
+
var ROLE_LADDERS = [
|
|
249
|
+
{
|
|
250
|
+
role: "formatCheck",
|
|
251
|
+
ladder: [
|
|
252
|
+
(k) => /^format:check$/.test(k),
|
|
253
|
+
(k) => /^test:format(:local)?$/.test(k),
|
|
254
|
+
(k) => /^(prettier:check|check:format|format:ci)$/.test(k),
|
|
255
|
+
(_k, v) => /prettier(\s|.*\s)(-l\b|--check)/.test(v)
|
|
256
|
+
]
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
role: "typecheck",
|
|
260
|
+
exclude: /generate|codegen|build|emit|watch/,
|
|
261
|
+
ladder: [
|
|
262
|
+
(k) => /^(typecheck|type-check)$/.test(k),
|
|
263
|
+
(k) => /^test:types(:local)?$/.test(k),
|
|
264
|
+
(k) => /^(types|check:types|types:check)$/.test(k),
|
|
265
|
+
(_k, v) => /tsc\b.*--noemit/i.test(v)
|
|
266
|
+
]
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
role: "test",
|
|
270
|
+
exclude: /watch|coverage|snapshot|e2e|integration|debug|notsilent|mdx/,
|
|
271
|
+
ladder: [
|
|
272
|
+
(k) => /^test:unit(:local|:ci)?$/.test(k),
|
|
273
|
+
(k) => /^test:jest$/.test(k),
|
|
274
|
+
(k, v) => /^test$/.test(k) && !isMeta(v),
|
|
275
|
+
(k) => /^test$/.test(k)
|
|
276
|
+
]
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
role: "lint",
|
|
280
|
+
exclude: /fix|format|staged|watch|style/,
|
|
281
|
+
ladder: [
|
|
282
|
+
(k) => /^lint$/.test(k),
|
|
283
|
+
(k) => /^test:lint(:local)?$/.test(k),
|
|
284
|
+
(k) => /^lint:/.test(k),
|
|
285
|
+
(_k, v) => /eslint\b/.test(v) && !/--fix/.test(v)
|
|
286
|
+
]
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
role: "format",
|
|
290
|
+
exclude: /check|:ci$/,
|
|
291
|
+
ladder: [
|
|
292
|
+
(k) => /^format$/.test(k),
|
|
293
|
+
(k) => /^(format:write|format:prettier|format:fix|prettier)$/.test(k),
|
|
294
|
+
(_k, v) => /prettier\b.*--write/.test(v)
|
|
295
|
+
]
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
role: "build",
|
|
299
|
+
exclude: /watch|storybook/,
|
|
300
|
+
ladder: [(k) => /^build$/.test(k), (k) => /^build:/.test(k)]
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
role: "dev",
|
|
304
|
+
ladder: [(k) => /^dev$/.test(k), (k) => /^start$/.test(k), (k) => /^dev:/.test(k)]
|
|
305
|
+
}
|
|
306
|
+
];
|
|
307
|
+
function classifyCommands(scripts = {}) {
|
|
308
|
+
const out = { raw: scripts };
|
|
309
|
+
const entries = Object.entries(scripts);
|
|
310
|
+
for (const { role, exclude, ladder } of ROLE_LADDERS) {
|
|
311
|
+
for (const pattern of ladder) {
|
|
312
|
+
const hit = entries.find(([k, v]) => !exclude?.test(k) && pattern(k, v));
|
|
313
|
+
if (hit) {
|
|
314
|
+
out[role] = hit[0];
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
var FRAMEWORK_MARKERS = [
|
|
322
|
+
["next", "Next.js"],
|
|
323
|
+
["@tanstack/react-start", "TanStack Start"],
|
|
324
|
+
["@tanstack/start", "TanStack Start"],
|
|
325
|
+
["@tanstack/react-router", "TanStack Router"],
|
|
326
|
+
["react", "React"],
|
|
327
|
+
["vue", "Vue"],
|
|
328
|
+
["svelte", "Svelte"],
|
|
329
|
+
["@angular/core", "Angular"],
|
|
330
|
+
["express", "Express"],
|
|
331
|
+
["fastify", "Fastify"],
|
|
332
|
+
["@nestjs/core", "NestJS"],
|
|
333
|
+
["@trpc/server", "tRPC"],
|
|
334
|
+
["vite", "Vite"],
|
|
335
|
+
["react-scripts", "CRA"],
|
|
336
|
+
["@apollo/client", "Apollo"],
|
|
337
|
+
["drizzle-orm", "Drizzle"],
|
|
338
|
+
["prisma", "Prisma"],
|
|
339
|
+
["@sanity/client", "Sanity"],
|
|
340
|
+
["nx", "Nx"]
|
|
341
|
+
];
|
|
342
|
+
function detectFrameworks(pkg) {
|
|
343
|
+
if (!pkg) return [];
|
|
344
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
345
|
+
const out = [];
|
|
346
|
+
for (const [marker, label] of FRAMEWORK_MARKERS) {
|
|
347
|
+
if (deps[marker] && !out.includes(label)) out.push(label);
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
async function detectCi(root) {
|
|
352
|
+
if (await pathExists(path.join(root, ".gitlab-ci.yml"))) {
|
|
353
|
+
return { system: "gitlab", files: [".gitlab-ci.yml"] };
|
|
354
|
+
}
|
|
355
|
+
const workflows = path.join(root, ".github", "workflows");
|
|
356
|
+
if (await isDirectory(workflows)) {
|
|
357
|
+
try {
|
|
358
|
+
const files = (await promises.readdir(workflows)).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")).map((f) => path.join(".github/workflows", f));
|
|
359
|
+
if (files.length) return { system: "github", files };
|
|
360
|
+
} catch {
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { system: "none", files: [] };
|
|
364
|
+
}
|
|
365
|
+
async function hasLintStagedConfig(root, pkg) {
|
|
366
|
+
if (pkg && pkg["lint-staged"]) return true;
|
|
367
|
+
for (const f of [
|
|
368
|
+
".lintstagedrc",
|
|
369
|
+
".lintstagedrc.json",
|
|
370
|
+
".lintstagedrc.js",
|
|
371
|
+
"lint-staged.config.js"
|
|
372
|
+
]) {
|
|
373
|
+
if (await pathExists(path.join(root, f))) return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
async function detectHooks(root, hooksPath, pkg) {
|
|
378
|
+
const lintStaged = await hasLintStagedConfig(root, pkg);
|
|
379
|
+
if (hooksPath) {
|
|
380
|
+
if (hooksPath.replace(/\/+$/, "") === ".husky/_") {
|
|
381
|
+
const base2 = path.join(root, ".husky");
|
|
382
|
+
return {
|
|
383
|
+
source: "husky",
|
|
384
|
+
dir: ".husky",
|
|
385
|
+
preCommit: await pathExists(path.join(base2, "pre-commit")),
|
|
386
|
+
prePush: await pathExists(path.join(base2, "pre-push")),
|
|
387
|
+
commitMsg: await pathExists(path.join(base2, "commit-msg")),
|
|
388
|
+
lintStaged
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const base = path.join(root, hooksPath);
|
|
392
|
+
return {
|
|
393
|
+
source: hooksPath === ".githooks" ? "githooks" : "custom",
|
|
394
|
+
dir: hooksPath,
|
|
395
|
+
preCommit: await pathExists(path.join(base, "pre-commit")),
|
|
396
|
+
prePush: await pathExists(path.join(base, "pre-push")),
|
|
397
|
+
commitMsg: await pathExists(path.join(base, "commit-msg")),
|
|
398
|
+
lintStaged
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (await isDirectory(path.join(root, ".githooks"))) {
|
|
402
|
+
const base = path.join(root, ".githooks");
|
|
403
|
+
return {
|
|
404
|
+
source: "githooks",
|
|
405
|
+
dir: ".githooks",
|
|
406
|
+
preCommit: await pathExists(path.join(base, "pre-commit")),
|
|
407
|
+
prePush: await pathExists(path.join(base, "pre-push")),
|
|
408
|
+
commitMsg: await pathExists(path.join(base, "commit-msg")),
|
|
409
|
+
lintStaged
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (await isDirectory(path.join(root, ".husky"))) {
|
|
413
|
+
const base = path.join(root, ".husky");
|
|
414
|
+
return {
|
|
415
|
+
source: "husky",
|
|
416
|
+
dir: ".husky",
|
|
417
|
+
preCommit: await pathExists(path.join(base, "pre-commit")),
|
|
418
|
+
prePush: await pathExists(path.join(base, "pre-push")),
|
|
419
|
+
commitMsg: await pathExists(path.join(base, "commit-msg")),
|
|
420
|
+
lintStaged
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const legacyHooks = pkg?.husky?.hooks;
|
|
424
|
+
const configText = legacyHooks ? null : await readText(path.join(root, "husky.config.js"));
|
|
425
|
+
if (legacyHooks || configText) {
|
|
426
|
+
const hookNamed = (name) => legacyHooks ? Boolean(legacyHooks[name]) : Boolean(configText && configText.includes(name));
|
|
427
|
+
return {
|
|
428
|
+
source: "husky-legacy",
|
|
429
|
+
preCommit: hookNamed("pre-commit"),
|
|
430
|
+
prePush: hookNamed("pre-push"),
|
|
431
|
+
commitMsg: hookNamed("commit-msg"),
|
|
432
|
+
lintStaged
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
return { source: "none", preCommit: false, prePush: false, commitMsg: false, lintStaged };
|
|
436
|
+
}
|
|
437
|
+
var ARTIFACT_SPECS = [
|
|
438
|
+
{ id: "agents", label: "AGENTS.md (operating contract)", path: "AGENTS.md", kind: "contract" },
|
|
439
|
+
{
|
|
440
|
+
id: "project-context",
|
|
441
|
+
label: "PROJECT_CONTEXT.md (ground-truth state)",
|
|
442
|
+
path: "PROJECT_CONTEXT.md",
|
|
443
|
+
kind: "state"
|
|
444
|
+
},
|
|
445
|
+
{ id: "claude", label: "CLAUDE.md (Claude Code pointer)", path: "CLAUDE.md", kind: "adapter" },
|
|
446
|
+
{
|
|
447
|
+
id: "copilot",
|
|
448
|
+
label: "Copilot instructions",
|
|
449
|
+
path: ".github/copilot-instructions.md",
|
|
450
|
+
kind: "adapter"
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
id: "cursorrules",
|
|
454
|
+
label: ".cursorrules (legacy Cursor)",
|
|
455
|
+
path: ".cursorrules",
|
|
456
|
+
kind: "adapter"
|
|
457
|
+
},
|
|
458
|
+
{ id: "cursor-rules", label: ".cursor/rules", path: ".cursor/rules", kind: "adapter" },
|
|
459
|
+
{ id: "gemini", label: "GEMINI.md", path: "GEMINI.md", kind: "adapter" },
|
|
460
|
+
{ id: "cline", label: ".clinerules", path: ".clinerules", kind: "adapter" },
|
|
461
|
+
{ id: "windsurf", label: ".windsurfrules", path: ".windsurfrules", kind: "adapter" },
|
|
462
|
+
{ id: "skills", label: "Agent skills (.claude/skills)", path: ".claude/skills", kind: "skill" },
|
|
463
|
+
{
|
|
464
|
+
id: "failure-modes-skill",
|
|
465
|
+
label: "Failure-modes register (skill)",
|
|
466
|
+
path: ".claude/skills/failure-modes",
|
|
467
|
+
kind: "skill"
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
id: "failure-modes-doc",
|
|
471
|
+
label: "Failure-modes register (doc)",
|
|
472
|
+
path: "docs/failure-modes.md",
|
|
473
|
+
kind: "other"
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
id: "sessions",
|
|
477
|
+
label: "Session archive (docs/sessions)",
|
|
478
|
+
path: "docs/sessions",
|
|
479
|
+
kind: "sessions"
|
|
480
|
+
},
|
|
481
|
+
{ id: "githooks", label: "Tracked git hooks (.githooks)", path: ".githooks", kind: "gate" },
|
|
482
|
+
{
|
|
483
|
+
id: "inventory",
|
|
484
|
+
label: "Reuse inventory (docs/INVENTORY.md)",
|
|
485
|
+
path: "docs/INVENTORY.md",
|
|
486
|
+
kind: "map"
|
|
487
|
+
}
|
|
488
|
+
];
|
|
489
|
+
async function detectArtifacts(root) {
|
|
490
|
+
return Promise.all(
|
|
491
|
+
ARTIFACT_SPECS.map(async (spec) => ({
|
|
492
|
+
...spec,
|
|
493
|
+
exists: await pathExists(path.join(root, spec.path))
|
|
494
|
+
}))
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
async function walkTree(root) {
|
|
498
|
+
const state = { budget: 2e4, truncated: false };
|
|
499
|
+
const countFiles = async (dir) => {
|
|
500
|
+
if (state.budget <= 0) {
|
|
501
|
+
state.truncated = true;
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
let total = 0;
|
|
505
|
+
let entries;
|
|
506
|
+
try {
|
|
507
|
+
entries = await promises.readdir(dir, { withFileTypes: true });
|
|
508
|
+
} catch {
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
511
|
+
for (const entry of entries) {
|
|
512
|
+
if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
|
|
513
|
+
if (entry.isDirectory()) {
|
|
514
|
+
total += await countFiles(path.join(dir, entry.name));
|
|
515
|
+
} else if (entry.isFile()) {
|
|
516
|
+
total += 1;
|
|
517
|
+
state.budget -= 1;
|
|
518
|
+
if (state.budget <= 0) {
|
|
519
|
+
state.truncated = true;
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return total;
|
|
525
|
+
};
|
|
526
|
+
let top;
|
|
527
|
+
try {
|
|
528
|
+
top = await promises.readdir(root, { withFileTypes: true });
|
|
529
|
+
} catch {
|
|
530
|
+
return { dirs: [], truncated: false };
|
|
531
|
+
}
|
|
532
|
+
const dirs = [];
|
|
533
|
+
for (const entry of top) {
|
|
534
|
+
if (!entry.isDirectory()) continue;
|
|
535
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
536
|
+
if (entry.name.startsWith(".") && !MEANINGFUL_DOT_DIRS.has(entry.name)) continue;
|
|
537
|
+
dirs.push({ name: entry.name, files: await countFiles(path.join(root, entry.name)) });
|
|
538
|
+
}
|
|
539
|
+
dirs.sort((a, b) => b.files - a.files);
|
|
540
|
+
return { dirs, truncated: state.truncated };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/core/scan.ts
|
|
544
|
+
function inferTicketKey(subjects) {
|
|
545
|
+
const counts = /* @__PURE__ */ new Map();
|
|
546
|
+
for (const m of subjects.matchAll(/\b([A-Z][A-Z0-9]{1,9})-\d+\b/g)) {
|
|
547
|
+
const key = m[1];
|
|
548
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
549
|
+
}
|
|
550
|
+
let best;
|
|
551
|
+
let bestCount = 0;
|
|
552
|
+
for (const [key, count] of counts) {
|
|
553
|
+
if (count > bestCount) {
|
|
554
|
+
best = key;
|
|
555
|
+
bestCount = count;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return bestCount >= 3 ? best : void 0;
|
|
559
|
+
}
|
|
560
|
+
async function scanProject(root) {
|
|
561
|
+
const abs = path.resolve(root);
|
|
562
|
+
const rootPkg = await readJson(path.join(abs, "package.json"));
|
|
563
|
+
const [packageManager, workspace, ci, artifacts, tree, isRepo] = await Promise.all([
|
|
564
|
+
detectPackageManager(abs, rootPkg),
|
|
565
|
+
detectWorkspace(abs, rootPkg),
|
|
566
|
+
detectCi(abs),
|
|
567
|
+
detectArtifacts(abs),
|
|
568
|
+
walkTree(abs),
|
|
569
|
+
git(abs, ["rev-parse", "--is-inside-work-tree"]).then((r) => r === "true")
|
|
570
|
+
]);
|
|
571
|
+
const [branch, head, hooksPathRaw, authorsRaw, subjectsRaw] = isRepo ? await Promise.all([
|
|
572
|
+
git(abs, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
573
|
+
git(abs, ["rev-parse", "--short", "HEAD"]),
|
|
574
|
+
git(abs, ["config", "--get", "core.hooksPath"]),
|
|
575
|
+
git(abs, ["log", "-200", "--format=%ae"]),
|
|
576
|
+
git(abs, ["log", "-50", "--format=%s"])
|
|
577
|
+
]) : [null, null, null, null, null];
|
|
578
|
+
const hooksPath = hooksPathRaw ?? void 0;
|
|
579
|
+
const hooks = await detectHooks(abs, hooksPath, rootPkg);
|
|
580
|
+
const packages = workspace.packageGlobs.length ? await listWorkspacePackages(abs, workspace.packageGlobs) : [];
|
|
581
|
+
const recentAuthors = authorsRaw ? new Set(authorsRaw.split("\n").filter(Boolean)).size : void 0;
|
|
582
|
+
return {
|
|
583
|
+
etymdVersion: VERSION,
|
|
584
|
+
packVersion: PACK_VERSION,
|
|
585
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
586
|
+
root: abs,
|
|
587
|
+
name: rootPkg?.name ?? path.basename(abs),
|
|
588
|
+
git: {
|
|
589
|
+
isRepo,
|
|
590
|
+
branch: branch ?? void 0,
|
|
591
|
+
head: head ?? void 0,
|
|
592
|
+
hooksPath,
|
|
593
|
+
husky: hooks.source === "husky" || hooks.source === "husky-legacy",
|
|
594
|
+
recentAuthors,
|
|
595
|
+
ticketKey: subjectsRaw ? inferTicketKey(subjectsRaw) : void 0
|
|
596
|
+
},
|
|
597
|
+
packageManager,
|
|
598
|
+
node: rootPkg?.engines?.node,
|
|
599
|
+
workspace: { kind: workspace.kind, packageGlobs: workspace.packageGlobs },
|
|
600
|
+
packages,
|
|
601
|
+
frameworks: detectFrameworks(rootPkg),
|
|
602
|
+
commands: classifyCommands(rootPkg?.scripts),
|
|
603
|
+
ci,
|
|
604
|
+
hooks,
|
|
605
|
+
artifacts,
|
|
606
|
+
tree
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
var ETYMD_DIR = ".etymd";
|
|
610
|
+
var CACHE_DIR = path.join(ETYMD_DIR, "cache");
|
|
611
|
+
var CACHE_FACTS_FILE = path.join(CACHE_DIR, "facts.json");
|
|
612
|
+
var BASELINE_FILE = path.join(ETYMD_DIR, "baseline.json");
|
|
613
|
+
function cacheFactsPath(root) {
|
|
614
|
+
return path.join(root, CACHE_FACTS_FILE);
|
|
615
|
+
}
|
|
616
|
+
function baselinePath(root) {
|
|
617
|
+
return path.join(root, BASELINE_FILE);
|
|
618
|
+
}
|
|
619
|
+
async function writeJsonFile(target, data) {
|
|
620
|
+
await promises.mkdir(path.dirname(target), { recursive: true });
|
|
621
|
+
await promises.writeFile(target, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
622
|
+
return target;
|
|
623
|
+
}
|
|
624
|
+
async function writeCachedFacts(root, facts) {
|
|
625
|
+
return writeJsonFile(cacheFactsPath(root), facts);
|
|
626
|
+
}
|
|
627
|
+
async function readCachedFacts(root) {
|
|
628
|
+
return readJson(cacheFactsPath(root));
|
|
629
|
+
}
|
|
630
|
+
function withoutMachinePath(facts) {
|
|
631
|
+
return { ...facts, root: "." };
|
|
632
|
+
}
|
|
633
|
+
function baselineCarriesMachinePath(baseline) {
|
|
634
|
+
const root = baseline.facts?.root;
|
|
635
|
+
return typeof root === "string" && root !== "." && (root.startsWith("/") || /^[A-Za-z]:[\\/]/.test(root));
|
|
636
|
+
}
|
|
637
|
+
async function writeBaseline(root, baseline) {
|
|
638
|
+
return writeJsonFile(baselinePath(root), {
|
|
639
|
+
...baseline,
|
|
640
|
+
facts: withoutMachinePath(baseline.facts)
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
async function readBaseline(root) {
|
|
644
|
+
return readJson(baselinePath(root));
|
|
645
|
+
}
|
|
646
|
+
function deriveProfile(facts) {
|
|
647
|
+
return (facts.git.recentAuthors ?? 1) > 2 ? "team" : "solo";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/core/config.ts
|
|
651
|
+
var CONFIG_FILE = path.join(ETYMD_DIR, "config.json");
|
|
652
|
+
var DEFAULT_CONFIG = {
|
|
653
|
+
instructions: { include: [], exclude: [] },
|
|
654
|
+
context: { perFileWords: 4e3, totalWords: 8e3 }
|
|
655
|
+
};
|
|
656
|
+
function configPath(root) {
|
|
657
|
+
return path.join(root, CONFIG_FILE);
|
|
658
|
+
}
|
|
659
|
+
function readGlobList(value, key, problems) {
|
|
660
|
+
if (value === void 0) return void 0;
|
|
661
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
662
|
+
problems.push(`${CONFIG_FILE}: \`${key}\` must be an array of glob strings \u2014 ignored.`);
|
|
663
|
+
return void 0;
|
|
664
|
+
}
|
|
665
|
+
return value.map((g) => g.trim()).filter(Boolean);
|
|
666
|
+
}
|
|
667
|
+
function readBudget(value, key, problems) {
|
|
668
|
+
if (value === void 0) return void 0;
|
|
669
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
670
|
+
problems.push(`${CONFIG_FILE}: \`${key}\` must be a positive number \u2014 ignored.`);
|
|
671
|
+
return void 0;
|
|
672
|
+
}
|
|
673
|
+
return value;
|
|
674
|
+
}
|
|
675
|
+
async function readConfig(root) {
|
|
676
|
+
const target = configPath(root);
|
|
677
|
+
const problems = [];
|
|
678
|
+
if (!await pathExists(target)) {
|
|
679
|
+
return { config: DEFAULT_CONFIG, present: false, problems };
|
|
680
|
+
}
|
|
681
|
+
const raw = await readText(target);
|
|
682
|
+
let parsed;
|
|
683
|
+
try {
|
|
684
|
+
parsed = JSON.parse(raw ?? "");
|
|
685
|
+
} catch (err) {
|
|
686
|
+
problems.push(
|
|
687
|
+
`${CONFIG_FILE} exists but is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 defaults used.`
|
|
688
|
+
);
|
|
689
|
+
return { config: DEFAULT_CONFIG, present: true, problems };
|
|
690
|
+
}
|
|
691
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
692
|
+
problems.push(`${CONFIG_FILE} must contain a JSON object \u2014 defaults used.`);
|
|
693
|
+
return { config: DEFAULT_CONFIG, present: true, problems };
|
|
694
|
+
}
|
|
695
|
+
const obj = parsed;
|
|
696
|
+
const instructions = obj.instructions ?? {};
|
|
697
|
+
const context = obj.context ?? {};
|
|
698
|
+
return {
|
|
699
|
+
present: true,
|
|
700
|
+
problems,
|
|
701
|
+
config: {
|
|
702
|
+
instructions: {
|
|
703
|
+
include: readGlobList(instructions.include, "instructions.include", problems) ?? DEFAULT_CONFIG.instructions.include,
|
|
704
|
+
exclude: readGlobList(instructions.exclude, "instructions.exclude", problems) ?? DEFAULT_CONFIG.instructions.exclude
|
|
705
|
+
},
|
|
706
|
+
context: {
|
|
707
|
+
perFileWords: readBudget(context.perFileWords, "context.perFileWords", problems) ?? DEFAULT_CONFIG.context.perFileWords,
|
|
708
|
+
totalWords: readBudget(context.totalWords, "context.totalWords", problems) ?? DEFAULT_CONFIG.context.totalWords
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/core/context.ts
|
|
715
|
+
var ALWAYS_LOADED = [
|
|
716
|
+
{ path: "AGENTS.md", role: "operating contract" },
|
|
717
|
+
{ path: "CLAUDE.md", role: "Claude Code pointer" },
|
|
718
|
+
{ path: "GEMINI.md", role: "Gemini pointer" },
|
|
719
|
+
{ path: ".github/copilot-instructions.md", role: "Copilot instructions" },
|
|
720
|
+
{ path: ".cursorrules", role: "Cursor rules (legacy)" }
|
|
721
|
+
];
|
|
722
|
+
var EXTRACTION_THRESHOLD = DEFAULT_CONFIG.context.perFileWords;
|
|
723
|
+
function isAlwaysAppliedCursorRule(text) {
|
|
724
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
725
|
+
if (!fm) return true;
|
|
726
|
+
const frontmatter = fm[1] ?? "";
|
|
727
|
+
return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
|
|
728
|
+
}
|
|
729
|
+
async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
|
|
730
|
+
const files = [];
|
|
731
|
+
for (const spec of ALWAYS_LOADED) {
|
|
732
|
+
const abs = path.join(root, spec.path);
|
|
733
|
+
const text = await readText(abs);
|
|
734
|
+
if (text === null) continue;
|
|
735
|
+
const words = wordCount(text);
|
|
736
|
+
files.push({ path: spec.path, role: spec.role, words, approxTokens: approxTokens(words) });
|
|
737
|
+
}
|
|
738
|
+
const rulesDir = path.join(root, ".cursor", "rules");
|
|
739
|
+
if (await pathExists(rulesDir)) {
|
|
740
|
+
try {
|
|
741
|
+
for (const entry of await promises.readdir(rulesDir)) {
|
|
742
|
+
if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
|
|
743
|
+
const text = await readText(path.join(rulesDir, entry));
|
|
744
|
+
if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
|
|
745
|
+
const words = wordCount(text);
|
|
746
|
+
files.push({
|
|
747
|
+
path: `.cursor/rules/${entry}`,
|
|
748
|
+
role: "Cursor rule (always applied)",
|
|
749
|
+
words,
|
|
750
|
+
approxTokens: approxTokens(words)
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
} catch {
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const totalWords = files.reduce((s, f) => s + f.words, 0);
|
|
757
|
+
return {
|
|
758
|
+
files: files.sort((a, b) => b.words - a.words),
|
|
759
|
+
totalWords,
|
|
760
|
+
totalApproxTokens: approxTokens(totalWords),
|
|
761
|
+
perFileWords,
|
|
762
|
+
extractionCandidates: files.filter((f) => f.words >= perFileWords)
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// src/pack/templates.ts
|
|
767
|
+
function runPrefix(pm) {
|
|
768
|
+
switch (pm) {
|
|
769
|
+
case "pnpm":
|
|
770
|
+
return "pnpm";
|
|
771
|
+
case "yarn":
|
|
772
|
+
return "yarn";
|
|
773
|
+
case "bun":
|
|
774
|
+
return "bun run";
|
|
775
|
+
case "npm":
|
|
776
|
+
case "unknown":
|
|
777
|
+
default:
|
|
778
|
+
return "npm run";
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
function isSafeGateCommand(value) {
|
|
782
|
+
if (!value) return false;
|
|
783
|
+
return !/--write|--fix|\bcodegen\b|\bgenerate\b|-w\s|--watch/.test(value);
|
|
784
|
+
}
|
|
785
|
+
function mapVerifyCommand(facts) {
|
|
786
|
+
switch (facts.workspace.kind) {
|
|
787
|
+
case "nx":
|
|
788
|
+
return `${facts.packageManager === "yarn" ? "yarn" : "npx"} nx show projects`;
|
|
789
|
+
case "pnpm":
|
|
790
|
+
return "pnpm -r ls --depth -1";
|
|
791
|
+
case "yarn":
|
|
792
|
+
return "yarn workspaces info";
|
|
793
|
+
case "npm":
|
|
794
|
+
return "npm ls --workspaces --depth=0";
|
|
795
|
+
case "turbo":
|
|
796
|
+
case "lerna":
|
|
797
|
+
return "git ls-files '*/package.json'";
|
|
798
|
+
default:
|
|
799
|
+
return "git ls-files | head -50";
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function doneDefinition(facts) {
|
|
803
|
+
const run = runPrefix(facts.packageManager);
|
|
804
|
+
const parts = [];
|
|
805
|
+
const c = facts.commands;
|
|
806
|
+
const formatCmd = c.formatCheck ?? (isSafeGateCommand(c.format ? c.raw[c.format] : void 0) ? c.format : void 0);
|
|
807
|
+
if (formatCmd) parts.push(`\`${run} ${formatCmd}\``);
|
|
808
|
+
if (c.typecheck) parts.push(`\`${run} ${c.typecheck}\``);
|
|
809
|
+
if (c.lint) parts.push(`\`${run} ${c.lint}\``);
|
|
810
|
+
if (c.test) parts.push(`\`${run} ${c.test}\``);
|
|
811
|
+
return parts;
|
|
812
|
+
}
|
|
813
|
+
function generateAgentsMd(facts) {
|
|
814
|
+
const run = runPrefix(facts.packageManager);
|
|
815
|
+
const done = doneDefinition(facts);
|
|
816
|
+
const frameworks = facts.frameworks.length ? facts.frameworks.join(", ") : "none detected";
|
|
817
|
+
const workspace = facts.workspace.kind === "none" ? "single package" : `${facts.workspace.kind} workspace`;
|
|
818
|
+
const topDirs = facts.tree.dirs.slice(0, 14);
|
|
819
|
+
return `# AGENTS.md
|
|
820
|
+
|
|
821
|
+
Operating contract for AI agents working in **${facts.name}**. One source of truth \u2014 most agents
|
|
822
|
+
(Claude Code, Codex, Cursor, Copilot, Gemini, \u2026) read this file natively. Kept true by
|
|
823
|
+
[etymd](https://www.npmjs.com/package/etymd): the commands, paths, and claims below are audited
|
|
824
|
+
against the actual repo \u2014 update this file when the repo changes, or \`etymd audit\` will tell you.
|
|
825
|
+
|
|
826
|
+
## What this project is
|
|
827
|
+
|
|
828
|
+
<!-- One paragraph: what this does and who it is for. Run \`etymd brief\` to have your agent
|
|
829
|
+
draft it from the reckoning; refine by hand. -->
|
|
830
|
+
|
|
831
|
+
## Stack
|
|
832
|
+
|
|
833
|
+
- **Shape:** ${workspace}${facts.packages.length ? ` (${facts.packages.length} packages)` : ""}, package manager **${facts.packageManager}**${facts.node ? `, Node ${facts.node}` : ""}.
|
|
834
|
+
- **Frameworks:** ${frameworks}.
|
|
835
|
+
- **CI:** ${facts.ci.system === "none" ? "none detected" : facts.ci.system}.
|
|
836
|
+
|
|
837
|
+
## Working rules
|
|
838
|
+
|
|
839
|
+
- **Reuse-first.** Before writing any new helper/component/type: check the map below and the
|
|
840
|
+
surrounding code \u2014 a "new" thing usually exists.
|
|
841
|
+
- **Minimal diffs.** Never touch files outside the task's scope.
|
|
842
|
+
<!-- Add your project's own rules: commit/branch conventions, what the agent may and may not do,
|
|
843
|
+
org tooling constraints. Keep every rule TRUE \u2014 stale rules erode trust in the rest. -->
|
|
844
|
+
|
|
845
|
+
## Repo map
|
|
846
|
+
|
|
847
|
+
> **Advisory, not authoritative** \u2014 re-verify with \`${mapVerifyCommand(facts)}\` before
|
|
848
|
+
> structure-sensitive changes, and update this section in the same change that moves files.
|
|
849
|
+
|
|
850
|
+
${topDirs.length ? topDirs.map((d) => `- \`${d.name}/\` \u2014 ${d.files} files`).join("\n") : "- (single package; list the key files here)"}
|
|
851
|
+
|
|
852
|
+
## Done =
|
|
853
|
+
|
|
854
|
+
${done.length ? `A change is done when these are green:
|
|
855
|
+
|
|
856
|
+
${done.map((d) => `- ${d}`).join("\n")}` : `Define the check commands that gate a change (test / lint / typecheck / format).`}
|
|
857
|
+
|
|
858
|
+
## Commands
|
|
859
|
+
|
|
860
|
+
\`\`\`bash
|
|
861
|
+
${[
|
|
862
|
+
facts.commands.dev && `${run} ${facts.commands.dev}`,
|
|
863
|
+
facts.commands.build && `${run} ${facts.commands.build}`,
|
|
864
|
+
facts.commands.test && `${run} ${facts.commands.test}`,
|
|
865
|
+
facts.commands.lint && `${run} ${facts.commands.lint}`,
|
|
866
|
+
facts.commands.typecheck && `${run} ${facts.commands.typecheck}`
|
|
867
|
+
].filter(Boolean).join("\n") || "# add the project's key commands"}
|
|
868
|
+
\`\`\`
|
|
869
|
+
|
|
870
|
+
<!-- etymd pack v${PACK_VERSION} -->
|
|
871
|
+
`;
|
|
872
|
+
}
|
|
873
|
+
function generatePreCommitHook() {
|
|
874
|
+
return `#!/usr/bin/env sh
|
|
875
|
+
# etymd: process gate. Cheap, locally-knowable checks belong here (fast, blocks the commit).
|
|
876
|
+
exit 0
|
|
877
|
+
`;
|
|
878
|
+
}
|
|
879
|
+
function generatePrePushHook(facts) {
|
|
880
|
+
const run = runPrefix(facts.packageManager);
|
|
881
|
+
const c = facts.commands;
|
|
882
|
+
const candidates = [c.formatCheck, c.typecheck, c.lint];
|
|
883
|
+
const steps = candidates.filter((key) => Boolean(key) && isSafeGateCommand(c.raw[key])).map((key) => `${run} ${key}`);
|
|
884
|
+
const body = steps.length ? steps.map((s) => `echo "\u203A ${s}"
|
|
885
|
+
${s} || exit 1`).join("\n") : 'echo "etymd: no correctness commands detected \u2014 add format:check / typecheck / lint"';
|
|
886
|
+
return `#!/usr/bin/env sh
|
|
887
|
+
# etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
|
|
888
|
+
${body}
|
|
889
|
+
`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/core/generate.ts
|
|
893
|
+
async function planWorkflow(root, facts, opts) {
|
|
894
|
+
const out = [];
|
|
895
|
+
const add = async (rel, contents, label, executable = false) => {
|
|
896
|
+
const abs = path.join(root, rel);
|
|
897
|
+
const exists = await pathExists(abs);
|
|
898
|
+
const existing = exists ? await readText(abs) : null;
|
|
899
|
+
out.push({
|
|
900
|
+
path: rel,
|
|
901
|
+
contents,
|
|
902
|
+
exists,
|
|
903
|
+
differs: exists ? existing !== contents : void 0,
|
|
904
|
+
executable,
|
|
905
|
+
label
|
|
906
|
+
});
|
|
907
|
+
};
|
|
908
|
+
if (opts.agents) {
|
|
909
|
+
await add("AGENTS.md", generateAgentsMd(facts), "Minimal operating contract (scaffold)");
|
|
910
|
+
}
|
|
911
|
+
if (opts.gates) {
|
|
912
|
+
await add(".githooks/pre-commit", generatePreCommitHook(), "Process gate (pre-commit)", true);
|
|
913
|
+
await add(".githooks/pre-push", generatePrePushHook(facts), "Correctness gate (pre-push)", true);
|
|
914
|
+
}
|
|
915
|
+
return out;
|
|
916
|
+
}
|
|
917
|
+
async function applyFiles(root, files, overwrite = /* @__PURE__ */ new Set()) {
|
|
918
|
+
const written = [];
|
|
919
|
+
const skipped = [];
|
|
920
|
+
for (const file of files) {
|
|
921
|
+
if (file.exists && !overwrite.has(file.path)) {
|
|
922
|
+
skipped.push(file.path);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
const abs = path.join(root, file.path);
|
|
926
|
+
await promises.mkdir(path.dirname(abs), { recursive: true });
|
|
927
|
+
await promises.writeFile(abs, file.contents, "utf8");
|
|
928
|
+
if (file.executable) await promises.chmod(abs, 493);
|
|
929
|
+
written.push(file.path);
|
|
930
|
+
}
|
|
931
|
+
return { written, skipped };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/lenses/context-economy.ts
|
|
935
|
+
var LENS_ID = "context-economy";
|
|
936
|
+
var TOTAL_BUDGET_WORDS = DEFAULT_CONFIG.context.totalWords;
|
|
937
|
+
var contextEconomyLens = {
|
|
938
|
+
id: LENS_ID,
|
|
939
|
+
version: "1",
|
|
940
|
+
title: "Context economy",
|
|
941
|
+
kind: "improvement",
|
|
942
|
+
async run(ctx) {
|
|
943
|
+
const budgets = ctx.config?.config.context ?? DEFAULT_CONFIG.context;
|
|
944
|
+
const budget = await measureContext(ctx.root, budgets.perFileWords);
|
|
945
|
+
const findings = [];
|
|
946
|
+
for (const f of budget.extractionCandidates) {
|
|
947
|
+
findings.push({
|
|
948
|
+
id: `${LENS_ID}/heavy-file:${f.path}`,
|
|
949
|
+
lens: LENS_ID,
|
|
950
|
+
tier: "gap",
|
|
951
|
+
claim: `${f.path} loads ${f.words} words (~${f.approxTokens} tokens) into every session`,
|
|
952
|
+
evidence: [`${f.path}: ${f.words} words`],
|
|
953
|
+
why: "Reference material that loads every session taxes attention and cost on tasks that never need it.",
|
|
954
|
+
action: "Extract the reference bulk into an on-demand skill/doc and keep a pointer.",
|
|
955
|
+
effort: "M",
|
|
956
|
+
confidence: "high"
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
if (budget.totalWords >= budgets.totalWords) {
|
|
960
|
+
findings.push({
|
|
961
|
+
id: `${LENS_ID}/total-over-budget`,
|
|
962
|
+
lens: LENS_ID,
|
|
963
|
+
tier: "gap",
|
|
964
|
+
claim: `The always-loaded footprint is ${budget.totalWords} words (~${budget.totalApproxTokens} tokens) \u2014 over the ${budgets.totalWords}-word budget`,
|
|
965
|
+
evidence: budget.files.slice(0, 5).map((f) => `${f.path}: ${f.words}w`),
|
|
966
|
+
why: "Every session pays this before the task begins; instruction-following degrades as the resident context grows.",
|
|
967
|
+
action: "Run `etymd context` for the per-file breakdown and extract the heaviest block.",
|
|
968
|
+
effort: "M",
|
|
969
|
+
confidence: "high"
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
lens: LENS_ID,
|
|
974
|
+
version: "1",
|
|
975
|
+
title: "Context economy",
|
|
976
|
+
kind: "improvement",
|
|
977
|
+
status: "ran",
|
|
978
|
+
disclosures: [
|
|
979
|
+
...ctx.config?.problems ?? [],
|
|
980
|
+
`Budgets: ${budgets.perFileWords} words/file, ${budgets.totalWords} words total (${budgets.perFileWords === DEFAULT_CONFIG.context.perFileWords && budgets.totalWords === DEFAULT_CONFIG.context.totalWords ? `defaults \u2014 override under \`context\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Scoped Cursor rules excluded.`
|
|
981
|
+
],
|
|
982
|
+
findings
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
var TOOL_MATCHERS = [
|
|
987
|
+
["sonar", /sonar-scanner|sonarqube|\bsonar\b/i],
|
|
988
|
+
["codecov", /codecov/i],
|
|
989
|
+
["commitlint", /commitlint/],
|
|
990
|
+
["chromatic", /chromatic/],
|
|
991
|
+
["size", /size-limit|bundlesize/],
|
|
992
|
+
["e2e", /\bcypress\b|\bplaywright\b/],
|
|
993
|
+
["coverage", /--coverage|\bnyc\b|\bc8\b/],
|
|
994
|
+
["format-check", /prettier[^&|;]*(\s-l\b|\s--check)|format:check/],
|
|
995
|
+
["format-write", /prettier[^&|;]*--write|--fix\b/],
|
|
996
|
+
["typecheck", /\btsc\b(?![^&|;]*--watch)/],
|
|
997
|
+
["lint", /\beslint\b/],
|
|
998
|
+
["test", /\bjest\b|\bvitest\b|react-scripts test|\bnx\b[^&|;]*\btest\b/]
|
|
999
|
+
];
|
|
1000
|
+
function expandScriptRefs(command, scripts, depth = 2) {
|
|
1001
|
+
if (depth <= 0) return command;
|
|
1002
|
+
let expanded = command;
|
|
1003
|
+
for (const m of command.matchAll(
|
|
1004
|
+
/(?:yarn|pnpm|npm run|bun run|npx run-s|npx run-p)\s+([A-Za-z0-9:._-]+)/g
|
|
1005
|
+
)) {
|
|
1006
|
+
const name = m[1];
|
|
1007
|
+
const body = scripts[name];
|
|
1008
|
+
if (body) expanded += `
|
|
1009
|
+
${expandScriptRefs(body, scripts, depth - 1)}`;
|
|
1010
|
+
}
|
|
1011
|
+
if (/npm-run-all|run-s|run-p/.test(command)) {
|
|
1012
|
+
for (const token of command.split(/\s+/)) {
|
|
1013
|
+
const clean = token.replace(/["']/g, "");
|
|
1014
|
+
const body = scripts[clean];
|
|
1015
|
+
if (body) expanded += `
|
|
1016
|
+
${expandScriptRefs(body, scripts, depth - 1)}`;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return expanded;
|
|
1020
|
+
}
|
|
1021
|
+
function matchTools(command, scripts) {
|
|
1022
|
+
const expanded = expandScriptRefs(command, scripts);
|
|
1023
|
+
const tools = /* @__PURE__ */ new Set();
|
|
1024
|
+
for (const [tool, re] of TOOL_MATCHERS) {
|
|
1025
|
+
if (re.test(expanded)) tools.add(tool);
|
|
1026
|
+
}
|
|
1027
|
+
return [...tools];
|
|
1028
|
+
}
|
|
1029
|
+
var GITLAB_TAGS = [
|
|
1030
|
+
{ tag: "!reference", collection: "seq", resolve: () => [] }
|
|
1031
|
+
];
|
|
1032
|
+
var GITLAB_RESERVED = /* @__PURE__ */ new Set([
|
|
1033
|
+
"stages",
|
|
1034
|
+
"variables",
|
|
1035
|
+
"include",
|
|
1036
|
+
"default",
|
|
1037
|
+
"workflow",
|
|
1038
|
+
"image",
|
|
1039
|
+
"services",
|
|
1040
|
+
"before_script",
|
|
1041
|
+
"after_script",
|
|
1042
|
+
"cache",
|
|
1043
|
+
"types"
|
|
1044
|
+
]);
|
|
1045
|
+
function looksLikeJob(job) {
|
|
1046
|
+
return job.script !== void 0 || job.before_script !== void 0 || job.extends !== void 0 || job.stage !== void 0 || job.image !== void 0 || job.trigger !== void 0 || job.rules !== void 0 || job.needs !== void 0 || job.allow_failure !== void 0;
|
|
1047
|
+
}
|
|
1048
|
+
function inferToolsFromShape(name, job) {
|
|
1049
|
+
const tools = /* @__PURE__ */ new Set();
|
|
1050
|
+
if (/sonar/i.test(name)) tools.add("sonar");
|
|
1051
|
+
if (/chromatic/i.test(name)) tools.add("chromatic");
|
|
1052
|
+
if (job.variables && typeof job.variables === "object") {
|
|
1053
|
+
for (const key of Object.keys(job.variables)) {
|
|
1054
|
+
if (key.startsWith("SONAR_")) tools.add("sonar");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return [...tools];
|
|
1058
|
+
}
|
|
1059
|
+
function asLines(value) {
|
|
1060
|
+
if (typeof value === "string") return [value];
|
|
1061
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
|
|
1062
|
+
return [];
|
|
1063
|
+
}
|
|
1064
|
+
function parseGitlabCi(text, file, scripts) {
|
|
1065
|
+
const parseErrors = [];
|
|
1066
|
+
let doc;
|
|
1067
|
+
try {
|
|
1068
|
+
doc = YAML.parse(text, { customTags: GITLAB_TAGS, logLevel: "silent" });
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
parseErrors.push(
|
|
1071
|
+
`${file}: ${err instanceof Error ? err.message.split("\n")[0] : "unparseable YAML"}`
|
|
1072
|
+
);
|
|
1073
|
+
return { jobs: [], inheritedIncludes: [], parseErrors };
|
|
1074
|
+
}
|
|
1075
|
+
if (!doc || typeof doc !== "object") return { jobs: [], inheritedIncludes: [], parseErrors };
|
|
1076
|
+
const inheritedIncludes = [];
|
|
1077
|
+
const include = doc.include;
|
|
1078
|
+
const includeEntries = Array.isArray(include) ? include : include ? [include] : [];
|
|
1079
|
+
for (const entry of includeEntries) {
|
|
1080
|
+
if (typeof entry === "string") inheritedIncludes.push(entry);
|
|
1081
|
+
else if (entry && typeof entry === "object") {
|
|
1082
|
+
const e = entry;
|
|
1083
|
+
if (typeof e.local === "string") inheritedIncludes.push(`local: ${e.local}`);
|
|
1084
|
+
else if (typeof e.project === "string") inheritedIncludes.push(`project: ${e.project}`);
|
|
1085
|
+
else if (typeof e.template === "string") inheritedIncludes.push(`template: ${e.template}`);
|
|
1086
|
+
else if (typeof e.remote === "string") inheritedIncludes.push(`remote: ${e.remote}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const jobs = [];
|
|
1090
|
+
const resolveScript = (job, seen = /* @__PURE__ */ new Set()) => {
|
|
1091
|
+
const lines = [...asLines(job.before_script), ...asLines(job.script)];
|
|
1092
|
+
const bases = asLines(job.extends).length ? asLines(job.extends) : typeof job.extends === "string" ? [job.extends] : [];
|
|
1093
|
+
for (const base of bases) {
|
|
1094
|
+
if (seen.has(base)) continue;
|
|
1095
|
+
seen.add(base);
|
|
1096
|
+
const tpl = doc[base];
|
|
1097
|
+
if (tpl && typeof tpl === "object") lines.push(...resolveScript(tpl, seen));
|
|
1098
|
+
}
|
|
1099
|
+
return lines;
|
|
1100
|
+
};
|
|
1101
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
1102
|
+
if (GITLAB_RESERVED.has(key) || key.startsWith(".")) continue;
|
|
1103
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
1104
|
+
const job = value;
|
|
1105
|
+
if (!looksLikeJob(job)) continue;
|
|
1106
|
+
const lines = resolveScript(job);
|
|
1107
|
+
const tools = new Set(inferToolsFromShape(key, job));
|
|
1108
|
+
for (const line of lines) for (const t of matchTools(line, scripts)) tools.add(t);
|
|
1109
|
+
if (!lines.length && !tools.size) continue;
|
|
1110
|
+
jobs.push({
|
|
1111
|
+
job: key,
|
|
1112
|
+
file,
|
|
1113
|
+
tools: [...tools],
|
|
1114
|
+
advisory: job.allow_failure === true || typeof job.allow_failure === "object" && job.allow_failure !== null,
|
|
1115
|
+
branchScoped: Boolean(job.rules ?? job.only ?? job.except),
|
|
1116
|
+
scriptVisible: lines.length > 0
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
return { jobs, inheritedIncludes, parseErrors };
|
|
1120
|
+
}
|
|
1121
|
+
function parseGithubWorkflow(text, file, scripts) {
|
|
1122
|
+
const parseErrors = [];
|
|
1123
|
+
let doc;
|
|
1124
|
+
try {
|
|
1125
|
+
doc = YAML.parse(text, { logLevel: "silent" });
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
parseErrors.push(
|
|
1128
|
+
`${file}: ${err instanceof Error ? err.message.split("\n")[0] : "unparseable YAML"}`
|
|
1129
|
+
);
|
|
1130
|
+
return { jobs: [], parseErrors };
|
|
1131
|
+
}
|
|
1132
|
+
const jobs = [];
|
|
1133
|
+
const jobsObj = doc?.jobs ?? {};
|
|
1134
|
+
for (const [key, value] of Object.entries(jobsObj)) {
|
|
1135
|
+
if (!value || typeof value !== "object") continue;
|
|
1136
|
+
const job = value;
|
|
1137
|
+
const steps = Array.isArray(job.steps) ? job.steps : [];
|
|
1138
|
+
const tools = /* @__PURE__ */ new Set();
|
|
1139
|
+
for (const step of steps) {
|
|
1140
|
+
const run = step?.run;
|
|
1141
|
+
if (typeof run === "string") for (const t of matchTools(run, scripts)) tools.add(t);
|
|
1142
|
+
}
|
|
1143
|
+
if (!tools.size) continue;
|
|
1144
|
+
jobs.push({
|
|
1145
|
+
job: key,
|
|
1146
|
+
file,
|
|
1147
|
+
tools: [...tools],
|
|
1148
|
+
advisory: job["continue-on-error"] === true,
|
|
1149
|
+
branchScoped: false,
|
|
1150
|
+
scriptVisible: true
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
return { jobs, parseErrors };
|
|
1154
|
+
}
|
|
1155
|
+
async function localHookTools(root, facts, scripts) {
|
|
1156
|
+
const hooks = facts.hooks;
|
|
1157
|
+
const empty = [];
|
|
1158
|
+
const readHook = async (name) => {
|
|
1159
|
+
if (!hooks.dir) return empty;
|
|
1160
|
+
const text = await readText(path.join(root, hooks.dir, name));
|
|
1161
|
+
return text ? matchTools(text, scripts) : empty;
|
|
1162
|
+
};
|
|
1163
|
+
let preCommit = [];
|
|
1164
|
+
let prePush = [];
|
|
1165
|
+
let commitMsg = [];
|
|
1166
|
+
if (hooks.dir) {
|
|
1167
|
+
[preCommit, prePush, commitMsg] = await Promise.all([
|
|
1168
|
+
readHook("pre-commit"),
|
|
1169
|
+
readHook("pre-push"),
|
|
1170
|
+
readHook("commit-msg")
|
|
1171
|
+
]);
|
|
1172
|
+
} else if (hooks.source === "husky-legacy") {
|
|
1173
|
+
const pkg = await readJson(
|
|
1174
|
+
path.join(root, "package.json")
|
|
1175
|
+
);
|
|
1176
|
+
const legacy = pkg?.husky?.hooks ?? {};
|
|
1177
|
+
const configText = await readText(path.join(root, "husky.config.js"));
|
|
1178
|
+
const forHook = (name) => {
|
|
1179
|
+
const cmd = legacy[name] ?? (configText?.includes(name) ? configText : void 0);
|
|
1180
|
+
return cmd ? matchTools(cmd, scripts) : [];
|
|
1181
|
+
};
|
|
1182
|
+
preCommit = forHook("pre-commit");
|
|
1183
|
+
prePush = forHook("pre-push");
|
|
1184
|
+
commitMsg = forHook("commit-msg");
|
|
1185
|
+
}
|
|
1186
|
+
let lintStaged = [];
|
|
1187
|
+
if (hooks.lintStaged) {
|
|
1188
|
+
const pkg = await readJson(path.join(root, "package.json"));
|
|
1189
|
+
const config = pkg?.["lint-staged"];
|
|
1190
|
+
const commands = [];
|
|
1191
|
+
const collect = (v) => {
|
|
1192
|
+
if (typeof v === "string") commands.push(v);
|
|
1193
|
+
else if (Array.isArray(v)) v.forEach(collect);
|
|
1194
|
+
else if (v && typeof v === "object") Object.values(v).forEach(collect);
|
|
1195
|
+
};
|
|
1196
|
+
collect(config);
|
|
1197
|
+
const tools = /* @__PURE__ */ new Set();
|
|
1198
|
+
for (const cmd of commands) for (const t of matchTools(cmd, scripts)) tools.add(t);
|
|
1199
|
+
lintStaged = [...tools];
|
|
1200
|
+
if ((hooks.preCommit || hooks.source === "husky" || hooks.source === "husky-legacy") && lintStaged.length) {
|
|
1201
|
+
preCommit = [.../* @__PURE__ */ new Set([...preCommit, ...lintStaged])];
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const wired = hooks.source === "custom" || hooks.source === "husky" || hooks.source === "husky-legacy" ? true : hooks.source === "githooks" ? facts.git.hooksPath === hooks.dir : false;
|
|
1205
|
+
return { source: hooks.source, wired, preCommit, prePush, commitMsg, lintStaged };
|
|
1206
|
+
}
|
|
1207
|
+
async function detectThresholds(root, scripts, ciJobs) {
|
|
1208
|
+
const sonarConfigured = await pathExists(path.join(root, "sonar-project.properties")) || ciJobs.some((j) => j.tools.includes("sonar"));
|
|
1209
|
+
let coverageThresholdLocal = false;
|
|
1210
|
+
for (const f of [
|
|
1211
|
+
"jest.config.js",
|
|
1212
|
+
"jest.config.ts",
|
|
1213
|
+
"jest.config.cjs",
|
|
1214
|
+
"jest.config.mjs",
|
|
1215
|
+
"jest.config.json",
|
|
1216
|
+
"vitest.config.ts",
|
|
1217
|
+
"vitest.config.js",
|
|
1218
|
+
"vitest.config.mts"
|
|
1219
|
+
]) {
|
|
1220
|
+
const text = await readText(path.join(root, f));
|
|
1221
|
+
if (text && /coverageThreshold|thresholds\s*:/.test(text)) {
|
|
1222
|
+
coverageThresholdLocal = true;
|
|
1223
|
+
break;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (!coverageThresholdLocal) {
|
|
1227
|
+
const pkg = await readJson(
|
|
1228
|
+
path.join(root, "package.json")
|
|
1229
|
+
);
|
|
1230
|
+
coverageThresholdLocal = Boolean(pkg?.jest?.coverageThreshold);
|
|
1231
|
+
}
|
|
1232
|
+
const coverageCollected = Object.values(scripts).some((v) => /--coverage|\bnyc\b|\bc8\b/.test(v)) || ciJobs.some((j) => j.tools.includes("coverage") || j.tools.includes("sonar"));
|
|
1233
|
+
return { sonarConfigured, coverageThresholdLocal, coverageCollected };
|
|
1234
|
+
}
|
|
1235
|
+
async function buildGateInventory(root, facts) {
|
|
1236
|
+
const scripts = facts.commands.raw;
|
|
1237
|
+
const jobs = [];
|
|
1238
|
+
const inheritedIncludes = [];
|
|
1239
|
+
const parseErrors = [];
|
|
1240
|
+
if (facts.ci.system === "gitlab") {
|
|
1241
|
+
const text = await readText(path.join(root, ".gitlab-ci.yml"));
|
|
1242
|
+
if (text) {
|
|
1243
|
+
const parsed = parseGitlabCi(text, ".gitlab-ci.yml", scripts);
|
|
1244
|
+
jobs.push(...parsed.jobs);
|
|
1245
|
+
parseErrors.push(...parsed.parseErrors);
|
|
1246
|
+
for (const inc of parsed.inheritedIncludes) {
|
|
1247
|
+
if (inc.startsWith("local: ")) {
|
|
1248
|
+
const rel = inc.slice("local: ".length).replace(/^\//, "");
|
|
1249
|
+
const subText = await readText(path.join(root, rel));
|
|
1250
|
+
if (subText) {
|
|
1251
|
+
const sub = parseGitlabCi(subText, rel, scripts);
|
|
1252
|
+
jobs.push(...sub.jobs);
|
|
1253
|
+
parseErrors.push(...sub.parseErrors);
|
|
1254
|
+
inheritedIncludes.push(...sub.inheritedIncludes);
|
|
1255
|
+
} else {
|
|
1256
|
+
inheritedIncludes.push(inc);
|
|
1257
|
+
}
|
|
1258
|
+
} else {
|
|
1259
|
+
inheritedIncludes.push(inc);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
} else if (facts.ci.system === "github") {
|
|
1264
|
+
for (const file of facts.ci.files) {
|
|
1265
|
+
const text = await readText(path.join(root, file));
|
|
1266
|
+
if (!text) continue;
|
|
1267
|
+
const parsed = parseGithubWorkflow(text, file, scripts);
|
|
1268
|
+
jobs.push(...parsed.jobs);
|
|
1269
|
+
parseErrors.push(...parsed.parseErrors);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
const [local, thresholds] = await Promise.all([
|
|
1273
|
+
localHookTools(root, facts, scripts),
|
|
1274
|
+
detectThresholds(root, scripts, jobs)
|
|
1275
|
+
]);
|
|
1276
|
+
const pkg = await readJson(path.join(root, "package.json"));
|
|
1277
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
1278
|
+
const commitlintDep = Boolean(deps["@commitlint/cli"] ?? deps["commitlint"]);
|
|
1279
|
+
return {
|
|
1280
|
+
local,
|
|
1281
|
+
ci: { system: facts.ci.system, jobs, inheritedIncludes, parseErrors },
|
|
1282
|
+
thresholds,
|
|
1283
|
+
commitlintDep
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// src/lenses/gate-integrity/lens.ts
|
|
1288
|
+
var LENS_ID2 = "gate-integrity";
|
|
1289
|
+
var CORRECTNESS_TOOLS = ["typecheck", "lint", "format-check", "test"];
|
|
1290
|
+
var TOOL_LABEL = {
|
|
1291
|
+
typecheck: "type checking",
|
|
1292
|
+
lint: "linting",
|
|
1293
|
+
"format-check": "format checking",
|
|
1294
|
+
"format-write": "format writing",
|
|
1295
|
+
test: "unit tests",
|
|
1296
|
+
coverage: "coverage",
|
|
1297
|
+
e2e: "e2e tests",
|
|
1298
|
+
sonar: "SonarQube analysis",
|
|
1299
|
+
codecov: "Codecov upload",
|
|
1300
|
+
commitlint: "commit-message linting",
|
|
1301
|
+
size: "bundle-size check",
|
|
1302
|
+
chromatic: "visual review"
|
|
1303
|
+
};
|
|
1304
|
+
function finding(partial) {
|
|
1305
|
+
return { lens: LENS_ID2, ...partial };
|
|
1306
|
+
}
|
|
1307
|
+
function deriveGateFindings(inv, disclosures = []) {
|
|
1308
|
+
const findings = [];
|
|
1309
|
+
const localTools = /* @__PURE__ */ new Set([
|
|
1310
|
+
...inv.local.preCommit,
|
|
1311
|
+
...inv.local.prePush,
|
|
1312
|
+
...inv.local.commitMsg
|
|
1313
|
+
]);
|
|
1314
|
+
const enforcedCiJobs = inv.ci.jobs.filter((j) => !j.advisory);
|
|
1315
|
+
const enforcedCiTools = new Set(enforcedCiJobs.flatMap((j) => j.tools));
|
|
1316
|
+
const anyCiTools = new Set(inv.ci.jobs.flatMap((j) => j.tools));
|
|
1317
|
+
if (inv.local.source === "githooks" && !inv.local.wired) {
|
|
1318
|
+
if (isCiEnvironment()) {
|
|
1319
|
+
disclosures.push(
|
|
1320
|
+
"Running in CI: git hook wiring (core.hooksPath) is a developer-machine fact and cannot be judged from an ephemeral checkout \u2014 not flagged here. Run `etymd audit` locally to check it."
|
|
1321
|
+
);
|
|
1322
|
+
} else {
|
|
1323
|
+
findings.push(
|
|
1324
|
+
finding({
|
|
1325
|
+
id: `${LENS_ID2}/hooks-not-wired`,
|
|
1326
|
+
tier: "risk",
|
|
1327
|
+
claim: "Tracked .githooks/ exist but git core.hooksPath is unset \u2014 the hooks never run",
|
|
1328
|
+
evidence: [".githooks/"],
|
|
1329
|
+
why: "Every check in those hooks is silently skipped on this clone.",
|
|
1330
|
+
action: "Run `git config core.hooksPath .githooks` (etymd gates does this).",
|
|
1331
|
+
effort: "S",
|
|
1332
|
+
confidence: "high"
|
|
1333
|
+
})
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
for (const tool of CORRECTNESS_TOOLS) {
|
|
1338
|
+
if (!enforcedCiTools.has(tool) || localTools.has(tool)) continue;
|
|
1339
|
+
const jobs = enforcedCiJobs.filter((j) => j.tools.includes(tool));
|
|
1340
|
+
const branchNote = jobs.every((j) => j.branchScoped) ? " (rule-scoped \u2014 blocking-ness may vary per branch)" : "";
|
|
1341
|
+
findings.push(
|
|
1342
|
+
finding({
|
|
1343
|
+
id: `${LENS_ID2}/ci-only-${tool}`,
|
|
1344
|
+
tier: "gap",
|
|
1345
|
+
claim: `${TOOL_LABEL[tool]} is enforced only in CI \u2014 no local hook runs it`,
|
|
1346
|
+
evidence: jobs.map((j) => `${j.file} job \`${j.job}\`${branchNote}`),
|
|
1347
|
+
why: "A failure is invisible until the pipeline runs \u2014 the slowest possible feedback loop.",
|
|
1348
|
+
action: "Wire it into a pre-push hook \u2014 `etymd gates` generates one from your own scripts.",
|
|
1349
|
+
effort: "S",
|
|
1350
|
+
confidence: "high"
|
|
1351
|
+
})
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
if (inv.ci.system !== "none" && inv.ci.jobs.length) {
|
|
1355
|
+
for (const tool of CORRECTNESS_TOOLS) {
|
|
1356
|
+
if (!localTools.has(tool) || anyCiTools.has(tool)) continue;
|
|
1357
|
+
if (inv.ci.inheritedIncludes.length) continue;
|
|
1358
|
+
findings.push(
|
|
1359
|
+
finding({
|
|
1360
|
+
id: `${LENS_ID2}/local-only-${tool}`,
|
|
1361
|
+
tier: "gap",
|
|
1362
|
+
claim: `${TOOL_LABEL[tool]} runs only in local hooks \u2014 CI never re-checks it`,
|
|
1363
|
+
evidence: [`local hooks (${inv.local.source})`],
|
|
1364
|
+
why: "Hooks are skippable (`--no-verify`, fresh clones without hook setup) \u2014 CI is the backstop.",
|
|
1365
|
+
action: "Add the same check to the CI pipeline.",
|
|
1366
|
+
effort: "M",
|
|
1367
|
+
confidence: "medium"
|
|
1368
|
+
})
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (inv.thresholds.coverageCollected && !inv.thresholds.coverageThresholdLocal) {
|
|
1373
|
+
const sonarJobs = inv.ci.jobs.filter((j) => j.tools.includes("sonar"));
|
|
1374
|
+
const advisoryNote = sonarJobs.length && sonarJobs.every((j) => j.advisory) ? " (the job is allow_failure \u2014 advisory on this pipeline)" : "";
|
|
1375
|
+
findings.push(
|
|
1376
|
+
finding({
|
|
1377
|
+
id: `${LENS_ID2}/coverage-no-local-threshold`,
|
|
1378
|
+
tier: inv.thresholds.sonarConfigured ? "gap" : "polish",
|
|
1379
|
+
claim: inv.thresholds.sonarConfigured ? `A coverage gate exists only server-side (SonarQube)${advisoryNote} \u2014 its threshold is not visible in this repo and nothing local mirrors it` : "Coverage is collected but no threshold gates it anywhere",
|
|
1380
|
+
evidence: [
|
|
1381
|
+
...sonarJobs.map((j) => `${j.file} job \`${j.job}\``),
|
|
1382
|
+
"no coverageThreshold in jest/vitest config"
|
|
1383
|
+
],
|
|
1384
|
+
why: "A coverage regression is invisible until the server-side gate fails the pipeline \u2014 after push, sometimes after review.",
|
|
1385
|
+
action: "Add a local coverageThreshold mirroring the server gate (or document the server value beside the config) so a regression warns before push.",
|
|
1386
|
+
effort: "M",
|
|
1387
|
+
confidence: inv.thresholds.sonarConfigured ? "high" : "medium"
|
|
1388
|
+
})
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
if (inv.commitlintDep && !inv.local.commitMsg.includes("commitlint") && !localToolInHook(inv, "commitlint")) {
|
|
1392
|
+
findings.push(
|
|
1393
|
+
finding({
|
|
1394
|
+
id: `${LENS_ID2}/commitlint-unwired`,
|
|
1395
|
+
tier: "polish",
|
|
1396
|
+
claim: "commitlint is a dependency but no commit-msg hook runs it",
|
|
1397
|
+
evidence: ["package.json (@commitlint/cli)", "no commit-msg hook"],
|
|
1398
|
+
why: "The commit convention the tooling promises is not actually enforced.",
|
|
1399
|
+
action: "Wire commitlint into a commit-msg hook, or remove the dependency.",
|
|
1400
|
+
effort: "S",
|
|
1401
|
+
confidence: "high"
|
|
1402
|
+
})
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
return findings;
|
|
1406
|
+
}
|
|
1407
|
+
function localToolInHook(inv, tool) {
|
|
1408
|
+
return inv.local.preCommit.includes(tool) || inv.local.prePush.includes(tool) || inv.local.commitMsg.includes(tool);
|
|
1409
|
+
}
|
|
1410
|
+
var gateIntegrityLens = {
|
|
1411
|
+
id: LENS_ID2,
|
|
1412
|
+
version: "1",
|
|
1413
|
+
title: "Gate integrity (CI \u2194 local)",
|
|
1414
|
+
kind: "improvement",
|
|
1415
|
+
async run(ctx) {
|
|
1416
|
+
const inv = await buildGateInventory(ctx.root, ctx.facts);
|
|
1417
|
+
const disclosures = [];
|
|
1418
|
+
if (inv.ci.inheritedIncludes.length) {
|
|
1419
|
+
disclosures.push(
|
|
1420
|
+
`${inv.ci.inheritedIncludes.length} CI include(s) reference pipeline definitions outside this repo \u2014 the CI inventory is incomplete: ${inv.ci.inheritedIncludes.slice(0, 3).join("; ")}${inv.ci.inheritedIncludes.length > 3 ? " \u2026" : ""}`
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
const advisory = inv.ci.jobs.filter((j) => j.advisory);
|
|
1424
|
+
if (advisory.length) {
|
|
1425
|
+
disclosures.push(
|
|
1426
|
+
`${advisory.length} CI job(s) are allow_failure/continue-on-error \u2014 advisory, not counted as enforced gates: ${advisory.map((j) => j.job).join(", ")}`
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
const inheritedScripts = inv.ci.jobs.filter((j) => !j.scriptVisible);
|
|
1430
|
+
if (inheritedScripts.length) {
|
|
1431
|
+
disclosures.push(
|
|
1432
|
+
`${inheritedScripts.length} CI job(s) define their script in an inherited template \u2014 tools inferred from name/variables only: ${inheritedScripts.map((j) => j.job).join(", ")}`
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
if (inv.ci.parseErrors.length)
|
|
1436
|
+
disclosures.push(...inv.ci.parseErrors.map((e) => `CI parse: ${e}`));
|
|
1437
|
+
if (inv.ci.system === "none")
|
|
1438
|
+
disclosures.push("No CI configuration found \u2014 CI\u2194local comparisons skipped.");
|
|
1439
|
+
const findings = deriveGateFindings(inv, disclosures);
|
|
1440
|
+
return {
|
|
1441
|
+
lens: LENS_ID2,
|
|
1442
|
+
version: "1",
|
|
1443
|
+
title: "Gate integrity (CI \u2194 local)",
|
|
1444
|
+
kind: "improvement",
|
|
1445
|
+
status: "ran",
|
|
1446
|
+
disclosures,
|
|
1447
|
+
findings
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
async function listInstructionFiles(root, facts, scope) {
|
|
1452
|
+
const files = [];
|
|
1453
|
+
const add = async (rel) => {
|
|
1454
|
+
const text = await readText(path.join(root, rel));
|
|
1455
|
+
if (text !== null) files.push({ path: normalizeRelPath(rel), text });
|
|
1456
|
+
};
|
|
1457
|
+
const singleFileArtifacts = [
|
|
1458
|
+
"agents",
|
|
1459
|
+
"claude",
|
|
1460
|
+
"gemini",
|
|
1461
|
+
"copilot",
|
|
1462
|
+
"cursorrules",
|
|
1463
|
+
"cline",
|
|
1464
|
+
"windsurf"
|
|
1465
|
+
];
|
|
1466
|
+
for (const id of singleFileArtifacts) {
|
|
1467
|
+
const artifact = facts.artifacts.find((a) => a.id === id);
|
|
1468
|
+
if (artifact?.exists) await add(artifact.path);
|
|
1469
|
+
}
|
|
1470
|
+
const rulesDir = path.join(root, ".cursor", "rules");
|
|
1471
|
+
if (await isDirectory(rulesDir)) {
|
|
1472
|
+
try {
|
|
1473
|
+
for (const entry of await promises.readdir(rulesDir)) {
|
|
1474
|
+
if (entry.endsWith(".md") || entry.endsWith(".mdc"))
|
|
1475
|
+
await add(path.join(".cursor/rules", entry));
|
|
1476
|
+
}
|
|
1477
|
+
} catch {
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
const skillsDir = path.join(root, ".claude", "skills");
|
|
1481
|
+
if (await isDirectory(skillsDir)) {
|
|
1482
|
+
try {
|
|
1483
|
+
for (const entry of await promises.readdir(skillsDir)) {
|
|
1484
|
+
const skill = path.join(".claude/skills", entry, "SKILL.md");
|
|
1485
|
+
await add(skill);
|
|
1486
|
+
}
|
|
1487
|
+
} catch {
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
const detected = new Set(files.map((f) => f.path));
|
|
1491
|
+
const included = [];
|
|
1492
|
+
for (const rel of await expandFileGlobs(root, scope?.include ?? [])) {
|
|
1493
|
+
if (detected.has(rel)) continue;
|
|
1494
|
+
const before = files.length;
|
|
1495
|
+
await add(rel);
|
|
1496
|
+
if (files.length > before) included.push(rel);
|
|
1497
|
+
}
|
|
1498
|
+
const exclude = scope?.exclude ?? [];
|
|
1499
|
+
if (!exclude.length) return { files, excluded: [], included };
|
|
1500
|
+
const kept = [];
|
|
1501
|
+
const excluded = [];
|
|
1502
|
+
for (const file of files) {
|
|
1503
|
+
if (matchesAnyGlob(file.path, exclude)) excluded.push(file.path);
|
|
1504
|
+
else kept.push(file);
|
|
1505
|
+
}
|
|
1506
|
+
return { files: kept, excluded, included };
|
|
1507
|
+
}
|
|
1508
|
+
function extractCodeTokens(text) {
|
|
1509
|
+
const tokens = [];
|
|
1510
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) tokens.push(m[1].trim());
|
|
1511
|
+
for (const block of text.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) {
|
|
1512
|
+
for (const line of block[1].split("\n")) {
|
|
1513
|
+
const trimmed = line.trim();
|
|
1514
|
+
if (trimmed && !trimmed.startsWith("#")) tokens.push(trimmed);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
return tokens;
|
|
1518
|
+
}
|
|
1519
|
+
var PM_BUILTINS = /* @__PURE__ */ new Set([
|
|
1520
|
+
"install",
|
|
1521
|
+
"i",
|
|
1522
|
+
"add",
|
|
1523
|
+
"remove",
|
|
1524
|
+
"rm",
|
|
1525
|
+
"up",
|
|
1526
|
+
"update",
|
|
1527
|
+
"upgrade",
|
|
1528
|
+
"dlx",
|
|
1529
|
+
"exec",
|
|
1530
|
+
"create",
|
|
1531
|
+
"init",
|
|
1532
|
+
"link",
|
|
1533
|
+
"unlink",
|
|
1534
|
+
"publish",
|
|
1535
|
+
"pack",
|
|
1536
|
+
"audit",
|
|
1537
|
+
"outdated",
|
|
1538
|
+
"why",
|
|
1539
|
+
"list",
|
|
1540
|
+
"ls",
|
|
1541
|
+
"view",
|
|
1542
|
+
"info",
|
|
1543
|
+
"config",
|
|
1544
|
+
"store",
|
|
1545
|
+
"import",
|
|
1546
|
+
"rebuild",
|
|
1547
|
+
"prune",
|
|
1548
|
+
"setup",
|
|
1549
|
+
"env",
|
|
1550
|
+
"bin",
|
|
1551
|
+
"root",
|
|
1552
|
+
"licenses",
|
|
1553
|
+
"patch",
|
|
1554
|
+
"approve-builds",
|
|
1555
|
+
"workspaces",
|
|
1556
|
+
"workspace",
|
|
1557
|
+
"cache",
|
|
1558
|
+
"version",
|
|
1559
|
+
"help"
|
|
1560
|
+
]);
|
|
1561
|
+
function extractCommandClaims(text) {
|
|
1562
|
+
const scripts = /* @__PURE__ */ new Map();
|
|
1563
|
+
let filteredSkipped = 0;
|
|
1564
|
+
for (const token of extractCodeTokens(text)) {
|
|
1565
|
+
for (const m of token.matchAll(
|
|
1566
|
+
/(?:^|&&\s*|\|\|\s*|;\s*|\|\s*|\$\s+|\(\s*)(pnpm|yarn|npm|bun)\s+(?:(run)\s+)?(-{0,2}[A-Za-z0-9:._@/[\]-]+)/g
|
|
1567
|
+
)) {
|
|
1568
|
+
const pm = m[1];
|
|
1569
|
+
const ranExplicit = Boolean(m[2]);
|
|
1570
|
+
const arg = m[3];
|
|
1571
|
+
if (arg.startsWith("-")) {
|
|
1572
|
+
filteredSkipped += 1;
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
if (pm === "npm" && !ranExplicit && arg !== "test" && arg !== "start") continue;
|
|
1576
|
+
if ((pm === "bun" || pm === "yarn" || pm === "pnpm") && !ranExplicit && PM_BUILTINS.has(arg))
|
|
1577
|
+
continue;
|
|
1578
|
+
if (ranExplicit && PM_BUILTINS.has(arg)) continue;
|
|
1579
|
+
scripts.set(arg, token);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
return { scripts, filteredSkipped };
|
|
1583
|
+
}
|
|
1584
|
+
var PATH_TOKEN_RE = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.$-]+)+\/?$/;
|
|
1585
|
+
var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1586
|
+
..."ts tsx cts mts js jsx cjs mjs json jsonc json5 md mdx mdc yml yaml toml ini cfg conf env sh bash zsh fish ps1 bat cmd css scss sass less html htm xml svg sql prisma graphql gql proto py rb rs go java kt kts swift c h cc cpp hpp cs php vue svelte astro txt log lock csv tsv png jpg jpeg gif webp ico avif woff woff2 ttf otf wasm map pem key crt tf tfvars example sample local snap ejs hbs pug".split(" ")
|
|
1587
|
+
]);
|
|
1588
|
+
var CREATION_CONTEXT_RE = /\b(?:creat(?:e|es|ed|ing)|generat(?:e|es|ed|ing)|scaffold(?:s|ed|ing)?|quarantin(?:e|es|ed|ing)|(?:writ(?:e|es|ten|ing)|output(?:s|ted)?|emit(?:s|ted|ting)?|sav(?:e|es|ed|ing)|mov(?:e|es|ed|ing)|copy|copi(?:es|ed))\s+(?:it\s+|them\s+)?(?:to|into)|new\s+(?:file|directory|folder)|will\s+(?:be\s+)?(?:created|generated|written)|add(?:s|ed|ing)?\s+(?:a|the)\s+new)\b/i;
|
|
1589
|
+
var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
|
|
1590
|
+
var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
|
|
1591
|
+
function isPlaceholderClaim(token) {
|
|
1592
|
+
return token.split("/").some((seg) => PLACEHOLDER_PREFIX_RE.test(seg) || PLACEHOLDER_SEGMENTS.has(seg.toLowerCase()));
|
|
1593
|
+
}
|
|
1594
|
+
function claimContext(text, index) {
|
|
1595
|
+
const start = text.lastIndexOf("\n", index) + 1;
|
|
1596
|
+
const endRaw = text.indexOf("\n", index);
|
|
1597
|
+
const end = endRaw === -1 ? text.length : endRaw;
|
|
1598
|
+
const line = text.slice(start, end);
|
|
1599
|
+
if (!/^\s*(?:[-*+]|\d+[.)]|\|)/.test(line)) return line;
|
|
1600
|
+
let cursor = start;
|
|
1601
|
+
while (cursor > 0) {
|
|
1602
|
+
const prevEnd = cursor - 1;
|
|
1603
|
+
const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
1604
|
+
const prev = text.slice(prevStart, prevEnd);
|
|
1605
|
+
cursor = prevStart;
|
|
1606
|
+
if (!prev.trim()) continue;
|
|
1607
|
+
if (/^\s*(?:[-*+]|\d+[.)]|\|)/.test(prev)) continue;
|
|
1608
|
+
return `${prev}
|
|
1609
|
+
${line}`;
|
|
1610
|
+
}
|
|
1611
|
+
return line;
|
|
1612
|
+
}
|
|
1613
|
+
function extractPathClaims(text) {
|
|
1614
|
+
const prospectiveOnly = /* @__PURE__ */ new Map();
|
|
1615
|
+
const placeholder = /* @__PURE__ */ new Set();
|
|
1616
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
1617
|
+
const token = m[1].trim();
|
|
1618
|
+
if (token.includes(" ") || token.length > 120) continue;
|
|
1619
|
+
if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
|
|
1620
|
+
continue;
|
|
1621
|
+
if (token.includes("://") || token.startsWith("www.")) continue;
|
|
1622
|
+
if (/[*?{}<>|]/.test(token)) continue;
|
|
1623
|
+
if (token.includes("@")) continue;
|
|
1624
|
+
if (!PATH_TOKEN_RE.test(token)) continue;
|
|
1625
|
+
if (token.split("/").some((seg) => seg.startsWith("$"))) continue;
|
|
1626
|
+
const isDirClaim = token.endsWith("/");
|
|
1627
|
+
const ext = token.toLowerCase().match(/\.([a-z0-9]{1,8})$/)?.[1];
|
|
1628
|
+
if (!isDirClaim && !(ext && KNOWN_EXTENSIONS.has(ext))) continue;
|
|
1629
|
+
const claim = token.replace(/\/$/, "");
|
|
1630
|
+
if (isPlaceholderClaim(claim)) {
|
|
1631
|
+
placeholder.add(claim);
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
const prospective2 = CREATION_CONTEXT_RE.test(claimContext(text, m.index ?? 0));
|
|
1635
|
+
prospectiveOnly.set(claim, (prospectiveOnly.get(claim) ?? true) && prospective2);
|
|
1636
|
+
}
|
|
1637
|
+
const paths = [];
|
|
1638
|
+
const prospective = [];
|
|
1639
|
+
for (const [claim, only] of prospectiveOnly) {
|
|
1640
|
+
if (only) prospective.push(claim);
|
|
1641
|
+
else paths.push(claim);
|
|
1642
|
+
}
|
|
1643
|
+
return { paths, prospective, placeholder: [...placeholder] };
|
|
1644
|
+
}
|
|
1645
|
+
function packageManagerUsage(text) {
|
|
1646
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1647
|
+
for (const token of extractCodeTokens(text)) {
|
|
1648
|
+
for (const m of token.matchAll(/\b(pnpm|yarn|npm|bun)\s+(?:run\s+)?[A-Za-z-]/g)) {
|
|
1649
|
+
const pm = m[1];
|
|
1650
|
+
counts.set(pm, (counts.get(pm) ?? 0) + 1);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
return counts;
|
|
1654
|
+
}
|
|
1655
|
+
var KNOWN_DOC_REFS = ["AGENTS.md", "CLAUDE.md", "PROJECT_CONTEXT.md", "GEMINI.md"];
|
|
1656
|
+
function extractDocRefs(text) {
|
|
1657
|
+
return KNOWN_DOC_REFS.filter((name) => text.includes(name));
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// src/lenses/instruction-truth/lens.ts
|
|
1661
|
+
var LENS_ID3 = "instruction-truth";
|
|
1662
|
+
var MAX_PATH_FINDINGS_PER_FILE = 15;
|
|
1663
|
+
function finding2(partial) {
|
|
1664
|
+
return { lens: LENS_ID3, ...partial };
|
|
1665
|
+
}
|
|
1666
|
+
function compareCommands(baseline, fresh) {
|
|
1667
|
+
const out = [];
|
|
1668
|
+
for (const role of ["test", "lint", "typecheck", "format", "formatCheck", "build"]) {
|
|
1669
|
+
const before = baseline.commands[role];
|
|
1670
|
+
if (before && !(before in fresh.commands.raw)) {
|
|
1671
|
+
out.push(
|
|
1672
|
+
finding2({
|
|
1673
|
+
id: `${LENS_ID3}/command-gone-${role}`,
|
|
1674
|
+
tier: "risk",
|
|
1675
|
+
claim: `Documented ${role} command \`${before}\` no longer exists in package.json`,
|
|
1676
|
+
evidence: ["package.json"],
|
|
1677
|
+
why: "Every doc, hook, and agent instruction naming it now fails or silently skips a check.",
|
|
1678
|
+
action: "Re-point the contract/hooks at the renamed script, then refresh the baseline.",
|
|
1679
|
+
effort: "S",
|
|
1680
|
+
confidence: "high"
|
|
1681
|
+
})
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
return out;
|
|
1686
|
+
}
|
|
1687
|
+
function compareArtifacts(baseline, fresh) {
|
|
1688
|
+
const out = [];
|
|
1689
|
+
const freshById = new Map(fresh.artifacts.map((a) => [a.id, a]));
|
|
1690
|
+
for (const a of baseline.artifacts) {
|
|
1691
|
+
const now = freshById.get(a.id);
|
|
1692
|
+
if (a.exists && now && !now.exists) {
|
|
1693
|
+
out.push(
|
|
1694
|
+
finding2({
|
|
1695
|
+
id: `${LENS_ID3}/artifact-gone-${a.id}`,
|
|
1696
|
+
tier: "gap",
|
|
1697
|
+
claim: `${a.label} was present at baseline but is now missing`,
|
|
1698
|
+
evidence: [a.path],
|
|
1699
|
+
why: "Agents and docs still assume it exists; instructions referring to it now mislead.",
|
|
1700
|
+
action: "Restore it or update the contract and refresh the baseline.",
|
|
1701
|
+
effort: "S",
|
|
1702
|
+
confidence: "high"
|
|
1703
|
+
})
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
return out;
|
|
1708
|
+
}
|
|
1709
|
+
function compareLayout(baseline, fresh) {
|
|
1710
|
+
const now = new Set(fresh.tree.dirs.map((d) => d.name));
|
|
1711
|
+
return baseline.tree.dirs.filter((d) => !now.has(d.name)).map(
|
|
1712
|
+
(d) => finding2({
|
|
1713
|
+
id: `${LENS_ID3}/dir-gone-${d.name}`,
|
|
1714
|
+
tier: "gap",
|
|
1715
|
+
claim: `Top-level \`${d.name}/\` from the baseline no longer exists \u2014 the repo map may be stale`,
|
|
1716
|
+
evidence: [`${d.name}/`],
|
|
1717
|
+
why: "A stale map sends agents (and people) to paths that are gone.",
|
|
1718
|
+
action: "Update the repo map and refresh the baseline.",
|
|
1719
|
+
effort: "S",
|
|
1720
|
+
confidence: "medium"
|
|
1721
|
+
})
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
var instructionTruthLens = {
|
|
1725
|
+
id: LENS_ID3,
|
|
1726
|
+
version: "1",
|
|
1727
|
+
title: "Instruction truth",
|
|
1728
|
+
kind: "truth",
|
|
1729
|
+
async run(ctx) {
|
|
1730
|
+
const findings = [];
|
|
1731
|
+
const disclosures = [];
|
|
1732
|
+
const { facts, root } = ctx;
|
|
1733
|
+
const config = ctx.config?.config ?? DEFAULT_CONFIG;
|
|
1734
|
+
disclosures.push(...ctx.config?.problems ?? []);
|
|
1735
|
+
const {
|
|
1736
|
+
files,
|
|
1737
|
+
excluded,
|
|
1738
|
+
included: includedExtra
|
|
1739
|
+
} = await listInstructionFiles(root, facts, config.instructions);
|
|
1740
|
+
if (!files.length) {
|
|
1741
|
+
findings.push(
|
|
1742
|
+
finding2({
|
|
1743
|
+
id: `${LENS_ID3}/no-contract`,
|
|
1744
|
+
tier: "gap",
|
|
1745
|
+
claim: "No agent instruction files exist (AGENTS.md or equivalents)",
|
|
1746
|
+
evidence: ["AGENTS.md (missing)"],
|
|
1747
|
+
why: "Every agent in this repo works from generic priors instead of the project's rules.",
|
|
1748
|
+
action: "Run `etymd init` to scaffold one.",
|
|
1749
|
+
effort: "M",
|
|
1750
|
+
confidence: "high"
|
|
1751
|
+
})
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
const knownScripts = new Set(Object.keys(facts.commands.raw));
|
|
1755
|
+
for (const pkg of facts.packages) {
|
|
1756
|
+
const pkgJson = await readJson(
|
|
1757
|
+
path.join(root, pkg.dir, "package.json")
|
|
1758
|
+
);
|
|
1759
|
+
for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
|
|
1760
|
+
}
|
|
1761
|
+
const pathResolves = async (claim) => {
|
|
1762
|
+
const bases = [root, ...facts.packages.map((p) => path.join(root, p.dir))];
|
|
1763
|
+
for (const base of bases) {
|
|
1764
|
+
if (await pathExists(path.join(base, claim))) return true;
|
|
1765
|
+
if (await pathExists(path.join(base, "src", claim))) return true;
|
|
1766
|
+
if (await pathExists(path.join(base, "scripts", claim))) return true;
|
|
1767
|
+
}
|
|
1768
|
+
return false;
|
|
1769
|
+
};
|
|
1770
|
+
const binResolves = async (name) => {
|
|
1771
|
+
const bases = [root, ...facts.packages.map((p) => path.join(root, p.dir))];
|
|
1772
|
+
for (const base of bases) {
|
|
1773
|
+
if (await pathExists(path.join(base, "node_modules", ".bin", name))) return true;
|
|
1774
|
+
}
|
|
1775
|
+
return false;
|
|
1776
|
+
};
|
|
1777
|
+
const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
|
|
1778
|
+
let totalFilteredSkipped = 0;
|
|
1779
|
+
let binaryResolved = 0;
|
|
1780
|
+
let unverifiableCommands = 0;
|
|
1781
|
+
let gitignoredSkipped = 0;
|
|
1782
|
+
let prospectiveSkipped = 0;
|
|
1783
|
+
let placeholderSkipped = 0;
|
|
1784
|
+
for (const file of files) {
|
|
1785
|
+
const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
|
|
1786
|
+
totalFilteredSkipped += filteredSkipped;
|
|
1787
|
+
for (const [script, raw] of claimed) {
|
|
1788
|
+
if (knownScripts.has(script)) continue;
|
|
1789
|
+
if (await binResolves(script)) {
|
|
1790
|
+
binaryResolved += 1;
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
if (!nodeModulesInstalled) {
|
|
1794
|
+
unverifiableCommands += 1;
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
findings.push(
|
|
1798
|
+
finding2({
|
|
1799
|
+
id: `${LENS_ID3}/stale-command:${file.path}:${script}`,
|
|
1800
|
+
tier: "risk",
|
|
1801
|
+
claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
|
|
1802
|
+
evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
|
|
1803
|
+
why: "An agent following this instruction runs a command that fails \u2014 or silently skips the check it was meant to run.",
|
|
1804
|
+
action: "Update the instruction to the current script name (or restore the script).",
|
|
1805
|
+
effort: "S",
|
|
1806
|
+
confidence: "high"
|
|
1807
|
+
})
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
const { paths, prospective, placeholder } = extractPathClaims(file.text);
|
|
1811
|
+
prospectiveSkipped += prospective.length;
|
|
1812
|
+
placeholderSkipped += placeholder.length;
|
|
1813
|
+
const missing = [];
|
|
1814
|
+
for (const claim of paths) {
|
|
1815
|
+
if (!await pathResolves(claim)) missing.push(claim);
|
|
1816
|
+
}
|
|
1817
|
+
const ignoredOut = missing.length ? await git(root, ["check-ignore", ...missing]) : null;
|
|
1818
|
+
const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
|
|
1819
|
+
let pathFindings = 0;
|
|
1820
|
+
for (const claim of missing) {
|
|
1821
|
+
if (gitignored.has(claim)) {
|
|
1822
|
+
gitignoredSkipped += 1;
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
if (pathFindings >= MAX_PATH_FINDINGS_PER_FILE) {
|
|
1826
|
+
disclosures.push(
|
|
1827
|
+
`${file.path}: more than ${MAX_PATH_FINDINGS_PER_FILE} missing-path claims \u2014 truncated.`
|
|
1828
|
+
);
|
|
1829
|
+
break;
|
|
1830
|
+
}
|
|
1831
|
+
pathFindings += 1;
|
|
1832
|
+
findings.push(
|
|
1833
|
+
finding2({
|
|
1834
|
+
id: `${LENS_ID3}/stale-path:${file.path}:${claim}`,
|
|
1835
|
+
tier: "gap",
|
|
1836
|
+
claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
|
|
1837
|
+
evidence: [file.path, `missing: ${claim}`],
|
|
1838
|
+
why: "Agents navigate by these references; a dead path wastes a lookup and erodes trust in the rest of the file.",
|
|
1839
|
+
action: "Fix or remove the reference.",
|
|
1840
|
+
effort: "S",
|
|
1841
|
+
confidence: "medium"
|
|
1842
|
+
})
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
if (facts.packageManager !== "unknown") {
|
|
1846
|
+
const usage = packageManagerUsage(file.text);
|
|
1847
|
+
const own = usage.get(facts.packageManager) ?? 0;
|
|
1848
|
+
for (const [pm, count] of usage) {
|
|
1849
|
+
if (pm === facts.packageManager || count < 2 || count <= own) continue;
|
|
1850
|
+
findings.push(
|
|
1851
|
+
finding2({
|
|
1852
|
+
id: `${LENS_ID3}/pm-conflict:${file.path}`,
|
|
1853
|
+
tier: "gap",
|
|
1854
|
+
claim: `${file.path} instructs \`${pm}\` (${count}\xD7) but the repo uses ${facts.packageManager}`,
|
|
1855
|
+
evidence: [file.path, `lockfile \u2192 ${facts.packageManager}`],
|
|
1856
|
+
why: "Mixed package-manager instructions produce divergent lockfiles and broken installs.",
|
|
1857
|
+
action: `Rewrite the commands for ${facts.packageManager}.`,
|
|
1858
|
+
effort: "S",
|
|
1859
|
+
confidence: "medium"
|
|
1860
|
+
})
|
|
1861
|
+
);
|
|
1862
|
+
break;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
for (const ref of extractDocRefs(file.text)) {
|
|
1866
|
+
if (await pathExists(path.join(root, ref))) continue;
|
|
1867
|
+
findings.push(
|
|
1868
|
+
finding2({
|
|
1869
|
+
id: `${LENS_ID3}/dangling-ref:${file.path}:${ref}`,
|
|
1870
|
+
tier: "gap",
|
|
1871
|
+
claim: `${file.path} references ${ref} \u2014 no such file exists`,
|
|
1872
|
+
evidence: [file.path, `missing: ${ref}`],
|
|
1873
|
+
why: "The pointer chain agents follow breaks at a file they can never read.",
|
|
1874
|
+
action: `Create ${ref} or remove the reference.`,
|
|
1875
|
+
effort: "S",
|
|
1876
|
+
confidence: "high"
|
|
1877
|
+
})
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
if (ctx.baseline) {
|
|
1882
|
+
findings.push(
|
|
1883
|
+
...compareCommands(ctx.baseline.facts, facts),
|
|
1884
|
+
...compareArtifacts(ctx.baseline.facts, facts),
|
|
1885
|
+
...compareLayout(ctx.baseline.facts, facts)
|
|
1886
|
+
);
|
|
1887
|
+
if (baselineCarriesMachinePath(ctx.baseline)) {
|
|
1888
|
+
disclosures.push(
|
|
1889
|
+
`${BASELINE_FILE} records an absolute machine path as its scan root (written by an older etymd). It is committed, so that path is published with your repo \u2014 run \`etymd approve\` to rewrite it.`
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
if (ctx.baseline.packVersion !== PACK_VERSION) {
|
|
1893
|
+
disclosures.push(
|
|
1894
|
+
`Baseline was approved under pack v${ctx.baseline.packVersion}; current pack is v${PACK_VERSION}.`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
} else {
|
|
1898
|
+
disclosures.push(
|
|
1899
|
+
"No committed baseline (.etymd/baseline.json) \u2014 drift over time is not measurable; run `etymd init` to approve one."
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
if (binaryResolved) {
|
|
1903
|
+
disclosures.push(
|
|
1904
|
+
`${binaryResolved} command claim(s) are installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
if (unverifiableCommands) {
|
|
1908
|
+
disclosures.push(
|
|
1909
|
+
`node_modules is not installed \u2014 ${unverifiableCommands} command claim(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
if (gitignoredSkipped) {
|
|
1913
|
+
disclosures.push(
|
|
1914
|
+
`${gitignoredSkipped} missing path claim(s) are gitignored (machine-local, e.g. .env) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1917
|
+
if (prospectiveSkipped) {
|
|
1918
|
+
disclosures.push(
|
|
1919
|
+
`${prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
if (placeholderSkipped) {
|
|
1923
|
+
disclosures.push(
|
|
1924
|
+
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
if (excluded.length) {
|
|
1928
|
+
const shown = excluded.slice(0, 5).join(", ");
|
|
1929
|
+
disclosures.push(
|
|
1930
|
+
`${excluded.length} instruction file(s) excluded by ${CONFIG_FILE} and NOT audited: ${shown}${excluded.length > 5 ? `, +${excluded.length - 5} more` : ""}.`
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
if (includedExtra.length) {
|
|
1934
|
+
disclosures.push(
|
|
1935
|
+
`${includedExtra.length} extra instruction file(s) audited via ${CONFIG_FILE} \`instructions.include\`: ${includedExtra.slice(0, 5).join(", ")}${includedExtra.length > 5 ? `, +${includedExtra.length - 5} more` : ""}.`
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
disclosures.push(
|
|
1939
|
+
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; framework-pattern staleness not checked.`
|
|
1940
|
+
);
|
|
1941
|
+
return {
|
|
1942
|
+
lens: LENS_ID3,
|
|
1943
|
+
version: "1",
|
|
1944
|
+
title: "Instruction truth",
|
|
1945
|
+
kind: "truth",
|
|
1946
|
+
status: "ran",
|
|
1947
|
+
disclosures,
|
|
1948
|
+
findings,
|
|
1949
|
+
outOfScope: excluded
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
};
|
|
1953
|
+
|
|
1954
|
+
// src/engine/finding.ts
|
|
1955
|
+
var TIER_ORDER = { risk: 0, gap: 1, polish: 2 };
|
|
1956
|
+
var EFFORT_ORDER = { S: 0, M: 1, L: 2 };
|
|
1957
|
+
function rankFindings(findings) {
|
|
1958
|
+
return [...findings].sort(
|
|
1959
|
+
(a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier] || EFFORT_ORDER[a.effort] - EFFORT_ORDER[b.effort]
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
function ledgerPath(root) {
|
|
1963
|
+
return path.join(root, ".etymd", "ledger.json");
|
|
1964
|
+
}
|
|
1965
|
+
async function readLedger(root) {
|
|
1966
|
+
return await readJson(ledgerPath(root)) ?? { version: 1, entries: [] };
|
|
1967
|
+
}
|
|
1968
|
+
async function writeLedger(root, ledger) {
|
|
1969
|
+
const target = ledgerPath(root);
|
|
1970
|
+
await promises.mkdir(path.dirname(target), { recursive: true });
|
|
1971
|
+
await promises.writeFile(target, JSON.stringify(ledger, null, 2) + "\n", "utf8");
|
|
1972
|
+
}
|
|
1973
|
+
function belongsToAny(id, paths) {
|
|
1974
|
+
return paths.some((p) => id.includes(`:${p}`));
|
|
1975
|
+
}
|
|
1976
|
+
function reconcileLedger(ledger, findings, now = (/* @__PURE__ */ new Date()).toISOString(), outOfScope = []) {
|
|
1977
|
+
const byId = new Map(ledger.entries.map((e) => [e.id, e]));
|
|
1978
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1979
|
+
const diff = {
|
|
1980
|
+
new: [],
|
|
1981
|
+
stillOpen: [],
|
|
1982
|
+
regressed: [],
|
|
1983
|
+
resolved: [],
|
|
1984
|
+
dismissed: [],
|
|
1985
|
+
accepted: [],
|
|
1986
|
+
outOfScope: []
|
|
1987
|
+
};
|
|
1988
|
+
const entries = [];
|
|
1989
|
+
for (const f of findings) {
|
|
1990
|
+
seen.add(f.id);
|
|
1991
|
+
const prev = byId.get(f.id);
|
|
1992
|
+
if (!prev) {
|
|
1993
|
+
diff.new.push(f);
|
|
1994
|
+
entries.push({
|
|
1995
|
+
id: f.id,
|
|
1996
|
+
status: "open",
|
|
1997
|
+
tier: f.tier,
|
|
1998
|
+
claim: f.claim,
|
|
1999
|
+
firstSeen: now,
|
|
2000
|
+
lastSeen: now
|
|
2001
|
+
});
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
if (prev.status === "dismissed") {
|
|
2005
|
+
diff.dismissed.push(f);
|
|
2006
|
+
entries.push({ ...prev, lastSeen: now });
|
|
2007
|
+
continue;
|
|
2008
|
+
}
|
|
2009
|
+
if (prev.status === "accepted") {
|
|
2010
|
+
diff.accepted.push(f);
|
|
2011
|
+
entries.push({ ...prev, tier: f.tier, claim: f.claim, lastSeen: now });
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
if (prev.status === "done") {
|
|
2015
|
+
diff.regressed.push(f);
|
|
2016
|
+
entries.push({ ...prev, status: "regressed", tier: f.tier, claim: f.claim, lastSeen: now });
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
2019
|
+
diff.stillOpen.push(f);
|
|
2020
|
+
entries.push({ ...prev, tier: f.tier, claim: f.claim, lastSeen: now });
|
|
2021
|
+
}
|
|
2022
|
+
for (const prev of ledger.entries) {
|
|
2023
|
+
if (seen.has(prev.id)) continue;
|
|
2024
|
+
if (outOfScope.length && belongsToAny(prev.id, outOfScope)) {
|
|
2025
|
+
diff.outOfScope.push(prev);
|
|
2026
|
+
entries.push(prev);
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
if (prev.status === "open" || prev.status === "accepted" || prev.status === "regressed") {
|
|
2030
|
+
diff.resolved.push(prev);
|
|
2031
|
+
entries.push({ ...prev, status: "done", lastSeen: now });
|
|
2032
|
+
} else {
|
|
2033
|
+
entries.push(prev);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
return { ledger: { version: 1, entries }, diff };
|
|
2037
|
+
}
|
|
2038
|
+
function visibleFindings(findings, ledger) {
|
|
2039
|
+
const quieted = new Set(
|
|
2040
|
+
ledger.entries.filter((e) => e.status === "dismissed" || e.status === "accepted").map((e) => e.id)
|
|
2041
|
+
);
|
|
2042
|
+
return findings.filter((f) => !quieted.has(f.id));
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// src/engine/run.ts
|
|
2046
|
+
var LENSES = [instructionTruthLens, gateIntegrityLens, contextEconomyLens];
|
|
2047
|
+
async function runAudit(root, opts = {}) {
|
|
2048
|
+
const facts = await scanProject(root);
|
|
2049
|
+
if ((opts.persistLedger ?? true) || await pathExists(path.join(root, ETYMD_DIR))) {
|
|
2050
|
+
await writeCachedFacts(root, facts);
|
|
2051
|
+
}
|
|
2052
|
+
const baseline = await readBaseline(root);
|
|
2053
|
+
const config = await readConfig(root);
|
|
2054
|
+
const profile = baseline?.profile ?? deriveProfile(facts);
|
|
2055
|
+
const selected = LENSES.filter(
|
|
2056
|
+
(lens) => (!opts.kind || lens.kind === opts.kind) && (!opts.lensIds || opts.lensIds.includes(lens.id))
|
|
2057
|
+
);
|
|
2058
|
+
const reports = [];
|
|
2059
|
+
for (const lens of selected) {
|
|
2060
|
+
try {
|
|
2061
|
+
reports.push(await lens.run({ root, facts, profile, baseline, config }));
|
|
2062
|
+
} catch (err) {
|
|
2063
|
+
reports.push({
|
|
2064
|
+
lens: lens.id,
|
|
2065
|
+
version: lens.version,
|
|
2066
|
+
title: lens.title,
|
|
2067
|
+
kind: lens.kind,
|
|
2068
|
+
status: "skipped",
|
|
2069
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
2070
|
+
disclosures: [],
|
|
2071
|
+
findings: []
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
const all = reports.flatMap((r) => r.findings);
|
|
2076
|
+
const previous = await readLedger(root);
|
|
2077
|
+
const outOfScope = reports.flatMap((r) => r.outOfScope ?? []);
|
|
2078
|
+
const { ledger, diff } = reconcileLedger(previous, all, void 0, outOfScope);
|
|
2079
|
+
const fullRun = selected.length === LENSES.length;
|
|
2080
|
+
if ((opts.persistLedger ?? true) && fullRun) {
|
|
2081
|
+
await writeLedger(root, ledger);
|
|
2082
|
+
}
|
|
2083
|
+
return {
|
|
2084
|
+
facts,
|
|
2085
|
+
profile,
|
|
2086
|
+
baseline,
|
|
2087
|
+
config,
|
|
2088
|
+
reports,
|
|
2089
|
+
findings: rankFindings(visibleFindings(all, previous)),
|
|
2090
|
+
ledger,
|
|
2091
|
+
diff
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
export { CONFIG_FILE, DEFAULT_CONFIG, EXTRACTION_THRESHOLD, LENSES, NAME, PACK_VERSION, TOTAL_BUDGET_WORDS, VERSION, applyFiles, baselineCarriesMachinePath, baselinePath, buildGateInventory, cacheFactsPath, configPath, contextEconomyLens, deriveProfile, extractCommandClaims, extractPathClaims, gateIntegrityLens, instructionTruthLens, isAlwaysAppliedCursorRule, listInstructionFiles, measureContext, planWorkflow, rankFindings, readBaseline, readCachedFacts, readConfig, readLedger, reconcileLedger, runAudit, scanProject, withoutMachinePath, writeBaseline, writeCachedFacts, writeLedger };
|