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
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-N64ZTBCW.js';
|
|
3
|
+
import { DEFAULT_CONFIG, readConfig, CONFIG_FILE, measureContext } from './chunk-I566LDX7.js';
|
|
4
|
+
import { scanProject, PACK_VERSION, expandFileGlobs } from './chunk-TCWKIMCU.js';
|
|
5
|
+
import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-SMM2TW35.js';
|
|
6
|
+
import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff, pathExists, readJson, git, isDirectory, matchesAnyGlob, readText, isCiEnvironment, normalizeRelPath } from './chunk-WVYKYPKS.js';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import YAML from 'yaml';
|
|
9
|
+
import { promises } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
// src/lenses/context-economy.ts
|
|
12
|
+
var LENS_ID = "context-economy";
|
|
13
|
+
DEFAULT_CONFIG.context.totalWords;
|
|
14
|
+
var contextEconomyLens = {
|
|
15
|
+
id: LENS_ID,
|
|
16
|
+
version: "1",
|
|
17
|
+
title: "Context economy",
|
|
18
|
+
kind: "improvement",
|
|
19
|
+
async run(ctx) {
|
|
20
|
+
const budgets = ctx.config?.config.context ?? DEFAULT_CONFIG.context;
|
|
21
|
+
const budget = await measureContext(ctx.root, budgets.perFileWords);
|
|
22
|
+
const findings = [];
|
|
23
|
+
for (const f of budget.extractionCandidates) {
|
|
24
|
+
findings.push({
|
|
25
|
+
id: `${LENS_ID}/heavy-file:${f.path}`,
|
|
26
|
+
lens: LENS_ID,
|
|
27
|
+
tier: "gap",
|
|
28
|
+
claim: `${f.path} loads ${f.words} words (~${f.approxTokens} tokens) into every session`,
|
|
29
|
+
evidence: [`${f.path}: ${f.words} words`],
|
|
30
|
+
why: "Reference material that loads every session taxes attention and cost on tasks that never need it.",
|
|
31
|
+
action: "Extract the reference bulk into an on-demand skill/doc and keep a pointer.",
|
|
32
|
+
effort: "M",
|
|
33
|
+
confidence: "high"
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (budget.totalWords >= budgets.totalWords) {
|
|
37
|
+
findings.push({
|
|
38
|
+
id: `${LENS_ID}/total-over-budget`,
|
|
39
|
+
lens: LENS_ID,
|
|
40
|
+
tier: "gap",
|
|
41
|
+
claim: `The always-loaded footprint is ${budget.totalWords} words (~${budget.totalApproxTokens} tokens) \u2014 over the ${budgets.totalWords}-word budget`,
|
|
42
|
+
evidence: budget.files.slice(0, 5).map((f) => `${f.path}: ${f.words}w`),
|
|
43
|
+
why: "Every session pays this before the task begins; instruction-following degrades as the resident context grows.",
|
|
44
|
+
action: "Run `etymd context` for the per-file breakdown and extract the heaviest block.",
|
|
45
|
+
effort: "M",
|
|
46
|
+
confidence: "high"
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
lens: LENS_ID,
|
|
51
|
+
version: "1",
|
|
52
|
+
title: "Context economy",
|
|
53
|
+
kind: "improvement",
|
|
54
|
+
status: "ran",
|
|
55
|
+
disclosures: [
|
|
56
|
+
...ctx.config?.problems ?? [],
|
|
57
|
+
`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.`
|
|
58
|
+
],
|
|
59
|
+
findings
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var TOOL_MATCHERS = [
|
|
64
|
+
["sonar", /sonar-scanner|sonarqube|\bsonar\b/i],
|
|
65
|
+
["codecov", /codecov/i],
|
|
66
|
+
["commitlint", /commitlint/],
|
|
67
|
+
["chromatic", /chromatic/],
|
|
68
|
+
["size", /size-limit|bundlesize/],
|
|
69
|
+
["e2e", /\bcypress\b|\bplaywright\b/],
|
|
70
|
+
["coverage", /--coverage|\bnyc\b|\bc8\b/],
|
|
71
|
+
["format-check", /prettier[^&|;]*(\s-l\b|\s--check)|format:check/],
|
|
72
|
+
["format-write", /prettier[^&|;]*--write|--fix\b/],
|
|
73
|
+
["typecheck", /\btsc\b(?![^&|;]*--watch)/],
|
|
74
|
+
["lint", /\beslint\b/],
|
|
75
|
+
["test", /\bjest\b|\bvitest\b|react-scripts test|\bnx\b[^&|;]*\btest\b/]
|
|
76
|
+
];
|
|
77
|
+
function expandScriptRefs(command, scripts, depth = 2) {
|
|
78
|
+
if (depth <= 0) return command;
|
|
79
|
+
let expanded = command;
|
|
80
|
+
for (const m of command.matchAll(
|
|
81
|
+
/(?:yarn|pnpm|npm run|bun run|npx run-s|npx run-p)\s+([A-Za-z0-9:._-]+)/g
|
|
82
|
+
)) {
|
|
83
|
+
const name = m[1];
|
|
84
|
+
const body = scripts[name];
|
|
85
|
+
if (body) expanded += `
|
|
86
|
+
${expandScriptRefs(body, scripts, depth - 1)}`;
|
|
87
|
+
}
|
|
88
|
+
if (/npm-run-all|run-s|run-p/.test(command)) {
|
|
89
|
+
for (const token of command.split(/\s+/)) {
|
|
90
|
+
const clean = token.replace(/["']/g, "");
|
|
91
|
+
const body = scripts[clean];
|
|
92
|
+
if (body) expanded += `
|
|
93
|
+
${expandScriptRefs(body, scripts, depth - 1)}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return expanded;
|
|
97
|
+
}
|
|
98
|
+
function matchTools(command, scripts) {
|
|
99
|
+
const expanded = expandScriptRefs(command, scripts);
|
|
100
|
+
const tools = /* @__PURE__ */ new Set();
|
|
101
|
+
for (const [tool, re] of TOOL_MATCHERS) {
|
|
102
|
+
if (re.test(expanded)) tools.add(tool);
|
|
103
|
+
}
|
|
104
|
+
return [...tools];
|
|
105
|
+
}
|
|
106
|
+
var GITLAB_TAGS = [
|
|
107
|
+
{ tag: "!reference", collection: "seq", resolve: () => [] }
|
|
108
|
+
];
|
|
109
|
+
var GITLAB_RESERVED = /* @__PURE__ */ new Set([
|
|
110
|
+
"stages",
|
|
111
|
+
"variables",
|
|
112
|
+
"include",
|
|
113
|
+
"default",
|
|
114
|
+
"workflow",
|
|
115
|
+
"image",
|
|
116
|
+
"services",
|
|
117
|
+
"before_script",
|
|
118
|
+
"after_script",
|
|
119
|
+
"cache",
|
|
120
|
+
"types"
|
|
121
|
+
]);
|
|
122
|
+
function looksLikeJob(job) {
|
|
123
|
+
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;
|
|
124
|
+
}
|
|
125
|
+
function inferToolsFromShape(name, job) {
|
|
126
|
+
const tools = /* @__PURE__ */ new Set();
|
|
127
|
+
if (/sonar/i.test(name)) tools.add("sonar");
|
|
128
|
+
if (/chromatic/i.test(name)) tools.add("chromatic");
|
|
129
|
+
if (job.variables && typeof job.variables === "object") {
|
|
130
|
+
for (const key of Object.keys(job.variables)) {
|
|
131
|
+
if (key.startsWith("SONAR_")) tools.add("sonar");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...tools];
|
|
135
|
+
}
|
|
136
|
+
function asLines(value) {
|
|
137
|
+
if (typeof value === "string") return [value];
|
|
138
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
function parseGitlabCi(text, file, scripts) {
|
|
142
|
+
const parseErrors = [];
|
|
143
|
+
let doc;
|
|
144
|
+
try {
|
|
145
|
+
doc = YAML.parse(text, { customTags: GITLAB_TAGS, logLevel: "silent" });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
parseErrors.push(
|
|
148
|
+
`${file}: ${err instanceof Error ? err.message.split("\n")[0] : "unparseable YAML"}`
|
|
149
|
+
);
|
|
150
|
+
return { jobs: [], inheritedIncludes: [], parseErrors };
|
|
151
|
+
}
|
|
152
|
+
if (!doc || typeof doc !== "object") return { jobs: [], inheritedIncludes: [], parseErrors };
|
|
153
|
+
const inheritedIncludes = [];
|
|
154
|
+
const include = doc.include;
|
|
155
|
+
const includeEntries = Array.isArray(include) ? include : include ? [include] : [];
|
|
156
|
+
for (const entry of includeEntries) {
|
|
157
|
+
if (typeof entry === "string") inheritedIncludes.push(entry);
|
|
158
|
+
else if (entry && typeof entry === "object") {
|
|
159
|
+
const e = entry;
|
|
160
|
+
if (typeof e.local === "string") inheritedIncludes.push(`local: ${e.local}`);
|
|
161
|
+
else if (typeof e.project === "string") inheritedIncludes.push(`project: ${e.project}`);
|
|
162
|
+
else if (typeof e.template === "string") inheritedIncludes.push(`template: ${e.template}`);
|
|
163
|
+
else if (typeof e.remote === "string") inheritedIncludes.push(`remote: ${e.remote}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const jobs = [];
|
|
167
|
+
const resolveScript = (job, seen = /* @__PURE__ */ new Set()) => {
|
|
168
|
+
const lines = [...asLines(job.before_script), ...asLines(job.script)];
|
|
169
|
+
const bases = asLines(job.extends).length ? asLines(job.extends) : typeof job.extends === "string" ? [job.extends] : [];
|
|
170
|
+
for (const base of bases) {
|
|
171
|
+
if (seen.has(base)) continue;
|
|
172
|
+
seen.add(base);
|
|
173
|
+
const tpl = doc[base];
|
|
174
|
+
if (tpl && typeof tpl === "object") lines.push(...resolveScript(tpl, seen));
|
|
175
|
+
}
|
|
176
|
+
return lines;
|
|
177
|
+
};
|
|
178
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
179
|
+
if (GITLAB_RESERVED.has(key) || key.startsWith(".")) continue;
|
|
180
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
181
|
+
const job = value;
|
|
182
|
+
if (!looksLikeJob(job)) continue;
|
|
183
|
+
const lines = resolveScript(job);
|
|
184
|
+
const tools = new Set(inferToolsFromShape(key, job));
|
|
185
|
+
for (const line of lines) for (const t of matchTools(line, scripts)) tools.add(t);
|
|
186
|
+
if (!lines.length && !tools.size) continue;
|
|
187
|
+
jobs.push({
|
|
188
|
+
job: key,
|
|
189
|
+
file,
|
|
190
|
+
tools: [...tools],
|
|
191
|
+
advisory: job.allow_failure === true || typeof job.allow_failure === "object" && job.allow_failure !== null,
|
|
192
|
+
branchScoped: Boolean(job.rules ?? job.only ?? job.except),
|
|
193
|
+
scriptVisible: lines.length > 0
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return { jobs, inheritedIncludes, parseErrors };
|
|
197
|
+
}
|
|
198
|
+
function parseGithubWorkflow(text, file, scripts) {
|
|
199
|
+
const parseErrors = [];
|
|
200
|
+
let doc;
|
|
201
|
+
try {
|
|
202
|
+
doc = YAML.parse(text, { logLevel: "silent" });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
parseErrors.push(
|
|
205
|
+
`${file}: ${err instanceof Error ? err.message.split("\n")[0] : "unparseable YAML"}`
|
|
206
|
+
);
|
|
207
|
+
return { jobs: [], parseErrors };
|
|
208
|
+
}
|
|
209
|
+
const jobs = [];
|
|
210
|
+
const jobsObj = doc?.jobs ?? {};
|
|
211
|
+
for (const [key, value] of Object.entries(jobsObj)) {
|
|
212
|
+
if (!value || typeof value !== "object") continue;
|
|
213
|
+
const job = value;
|
|
214
|
+
const steps = Array.isArray(job.steps) ? job.steps : [];
|
|
215
|
+
const tools = /* @__PURE__ */ new Set();
|
|
216
|
+
for (const step of steps) {
|
|
217
|
+
const run2 = step?.run;
|
|
218
|
+
if (typeof run2 === "string") for (const t of matchTools(run2, scripts)) tools.add(t);
|
|
219
|
+
}
|
|
220
|
+
if (!tools.size) continue;
|
|
221
|
+
jobs.push({
|
|
222
|
+
job: key,
|
|
223
|
+
file,
|
|
224
|
+
tools: [...tools],
|
|
225
|
+
advisory: job["continue-on-error"] === true,
|
|
226
|
+
branchScoped: false,
|
|
227
|
+
scriptVisible: true
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return { jobs, parseErrors };
|
|
231
|
+
}
|
|
232
|
+
async function localHookTools(root, facts, scripts) {
|
|
233
|
+
const hooks = facts.hooks;
|
|
234
|
+
const empty = [];
|
|
235
|
+
const readHook = async (name) => {
|
|
236
|
+
if (!hooks.dir) return empty;
|
|
237
|
+
const text = await readText(path.join(root, hooks.dir, name));
|
|
238
|
+
return text ? matchTools(text, scripts) : empty;
|
|
239
|
+
};
|
|
240
|
+
let preCommit = [];
|
|
241
|
+
let prePush = [];
|
|
242
|
+
let commitMsg = [];
|
|
243
|
+
if (hooks.dir) {
|
|
244
|
+
[preCommit, prePush, commitMsg] = await Promise.all([
|
|
245
|
+
readHook("pre-commit"),
|
|
246
|
+
readHook("pre-push"),
|
|
247
|
+
readHook("commit-msg")
|
|
248
|
+
]);
|
|
249
|
+
} else if (hooks.source === "husky-legacy") {
|
|
250
|
+
const pkg = await readJson(
|
|
251
|
+
path.join(root, "package.json")
|
|
252
|
+
);
|
|
253
|
+
const legacy = pkg?.husky?.hooks ?? {};
|
|
254
|
+
const configText = await readText(path.join(root, "husky.config.js"));
|
|
255
|
+
const forHook = (name) => {
|
|
256
|
+
const cmd = legacy[name] ?? (configText?.includes(name) ? configText : void 0);
|
|
257
|
+
return cmd ? matchTools(cmd, scripts) : [];
|
|
258
|
+
};
|
|
259
|
+
preCommit = forHook("pre-commit");
|
|
260
|
+
prePush = forHook("pre-push");
|
|
261
|
+
commitMsg = forHook("commit-msg");
|
|
262
|
+
}
|
|
263
|
+
let lintStaged = [];
|
|
264
|
+
if (hooks.lintStaged) {
|
|
265
|
+
const pkg = await readJson(path.join(root, "package.json"));
|
|
266
|
+
const config = pkg?.["lint-staged"];
|
|
267
|
+
const commands = [];
|
|
268
|
+
const collect = (v) => {
|
|
269
|
+
if (typeof v === "string") commands.push(v);
|
|
270
|
+
else if (Array.isArray(v)) v.forEach(collect);
|
|
271
|
+
else if (v && typeof v === "object") Object.values(v).forEach(collect);
|
|
272
|
+
};
|
|
273
|
+
collect(config);
|
|
274
|
+
const tools = /* @__PURE__ */ new Set();
|
|
275
|
+
for (const cmd of commands) for (const t of matchTools(cmd, scripts)) tools.add(t);
|
|
276
|
+
lintStaged = [...tools];
|
|
277
|
+
if ((hooks.preCommit || hooks.source === "husky" || hooks.source === "husky-legacy") && lintStaged.length) {
|
|
278
|
+
preCommit = [.../* @__PURE__ */ new Set([...preCommit, ...lintStaged])];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const wired = hooks.source === "custom" || hooks.source === "husky" || hooks.source === "husky-legacy" ? true : hooks.source === "githooks" ? facts.git.hooksPath === hooks.dir : false;
|
|
282
|
+
return { source: hooks.source, wired, preCommit, prePush, commitMsg, lintStaged };
|
|
283
|
+
}
|
|
284
|
+
async function detectThresholds(root, scripts, ciJobs) {
|
|
285
|
+
const sonarConfigured = await pathExists(path.join(root, "sonar-project.properties")) || ciJobs.some((j) => j.tools.includes("sonar"));
|
|
286
|
+
let coverageThresholdLocal = false;
|
|
287
|
+
for (const f of [
|
|
288
|
+
"jest.config.js",
|
|
289
|
+
"jest.config.ts",
|
|
290
|
+
"jest.config.cjs",
|
|
291
|
+
"jest.config.mjs",
|
|
292
|
+
"jest.config.json",
|
|
293
|
+
"vitest.config.ts",
|
|
294
|
+
"vitest.config.js",
|
|
295
|
+
"vitest.config.mts"
|
|
296
|
+
]) {
|
|
297
|
+
const text = await readText(path.join(root, f));
|
|
298
|
+
if (text && /coverageThreshold|thresholds\s*:/.test(text)) {
|
|
299
|
+
coverageThresholdLocal = true;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!coverageThresholdLocal) {
|
|
304
|
+
const pkg = await readJson(
|
|
305
|
+
path.join(root, "package.json")
|
|
306
|
+
);
|
|
307
|
+
coverageThresholdLocal = Boolean(pkg?.jest?.coverageThreshold);
|
|
308
|
+
}
|
|
309
|
+
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"));
|
|
310
|
+
return { sonarConfigured, coverageThresholdLocal, coverageCollected };
|
|
311
|
+
}
|
|
312
|
+
async function buildGateInventory(root, facts) {
|
|
313
|
+
const scripts = facts.commands.raw;
|
|
314
|
+
const jobs = [];
|
|
315
|
+
const inheritedIncludes = [];
|
|
316
|
+
const parseErrors = [];
|
|
317
|
+
if (facts.ci.system === "gitlab") {
|
|
318
|
+
const text = await readText(path.join(root, ".gitlab-ci.yml"));
|
|
319
|
+
if (text) {
|
|
320
|
+
const parsed = parseGitlabCi(text, ".gitlab-ci.yml", scripts);
|
|
321
|
+
jobs.push(...parsed.jobs);
|
|
322
|
+
parseErrors.push(...parsed.parseErrors);
|
|
323
|
+
for (const inc of parsed.inheritedIncludes) {
|
|
324
|
+
if (inc.startsWith("local: ")) {
|
|
325
|
+
const rel = inc.slice("local: ".length).replace(/^\//, "");
|
|
326
|
+
const subText = await readText(path.join(root, rel));
|
|
327
|
+
if (subText) {
|
|
328
|
+
const sub = parseGitlabCi(subText, rel, scripts);
|
|
329
|
+
jobs.push(...sub.jobs);
|
|
330
|
+
parseErrors.push(...sub.parseErrors);
|
|
331
|
+
inheritedIncludes.push(...sub.inheritedIncludes);
|
|
332
|
+
} else {
|
|
333
|
+
inheritedIncludes.push(inc);
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
inheritedIncludes.push(inc);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} else if (facts.ci.system === "github") {
|
|
341
|
+
for (const file of facts.ci.files) {
|
|
342
|
+
const text = await readText(path.join(root, file));
|
|
343
|
+
if (!text) continue;
|
|
344
|
+
const parsed = parseGithubWorkflow(text, file, scripts);
|
|
345
|
+
jobs.push(...parsed.jobs);
|
|
346
|
+
parseErrors.push(...parsed.parseErrors);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const [local, thresholds] = await Promise.all([
|
|
350
|
+
localHookTools(root, facts, scripts),
|
|
351
|
+
detectThresholds(root, scripts, jobs)
|
|
352
|
+
]);
|
|
353
|
+
const pkg = await readJson(path.join(root, "package.json"));
|
|
354
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
355
|
+
const commitlintDep = Boolean(deps["@commitlint/cli"] ?? deps["commitlint"]);
|
|
356
|
+
return {
|
|
357
|
+
local,
|
|
358
|
+
ci: { system: facts.ci.system, jobs, inheritedIncludes, parseErrors },
|
|
359
|
+
thresholds,
|
|
360
|
+
commitlintDep
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/lenses/gate-integrity/lens.ts
|
|
365
|
+
var LENS_ID2 = "gate-integrity";
|
|
366
|
+
var CORRECTNESS_TOOLS = ["typecheck", "lint", "format-check", "test"];
|
|
367
|
+
var TOOL_LABEL = {
|
|
368
|
+
typecheck: "type checking",
|
|
369
|
+
lint: "linting",
|
|
370
|
+
"format-check": "format checking",
|
|
371
|
+
"format-write": "format writing",
|
|
372
|
+
test: "unit tests",
|
|
373
|
+
coverage: "coverage",
|
|
374
|
+
e2e: "e2e tests",
|
|
375
|
+
sonar: "SonarQube analysis",
|
|
376
|
+
codecov: "Codecov upload",
|
|
377
|
+
commitlint: "commit-message linting",
|
|
378
|
+
size: "bundle-size check",
|
|
379
|
+
chromatic: "visual review"
|
|
380
|
+
};
|
|
381
|
+
function finding(partial) {
|
|
382
|
+
return { lens: LENS_ID2, ...partial };
|
|
383
|
+
}
|
|
384
|
+
function deriveGateFindings(inv, disclosures = []) {
|
|
385
|
+
const findings = [];
|
|
386
|
+
const localTools = /* @__PURE__ */ new Set([
|
|
387
|
+
...inv.local.preCommit,
|
|
388
|
+
...inv.local.prePush,
|
|
389
|
+
...inv.local.commitMsg
|
|
390
|
+
]);
|
|
391
|
+
const enforcedCiJobs = inv.ci.jobs.filter((j) => !j.advisory);
|
|
392
|
+
const enforcedCiTools = new Set(enforcedCiJobs.flatMap((j) => j.tools));
|
|
393
|
+
const anyCiTools = new Set(inv.ci.jobs.flatMap((j) => j.tools));
|
|
394
|
+
if (inv.local.source === "githooks" && !inv.local.wired) {
|
|
395
|
+
if (isCiEnvironment()) {
|
|
396
|
+
disclosures.push(
|
|
397
|
+
"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."
|
|
398
|
+
);
|
|
399
|
+
} else {
|
|
400
|
+
findings.push(
|
|
401
|
+
finding({
|
|
402
|
+
id: `${LENS_ID2}/hooks-not-wired`,
|
|
403
|
+
tier: "risk",
|
|
404
|
+
claim: "Tracked .githooks/ exist but git core.hooksPath is unset \u2014 the hooks never run",
|
|
405
|
+
evidence: [".githooks/"],
|
|
406
|
+
why: "Every check in those hooks is silently skipped on this clone.",
|
|
407
|
+
action: "Run `git config core.hooksPath .githooks` (etymd gates does this).",
|
|
408
|
+
effort: "S",
|
|
409
|
+
confidence: "high"
|
|
410
|
+
})
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const tool of CORRECTNESS_TOOLS) {
|
|
415
|
+
if (!enforcedCiTools.has(tool) || localTools.has(tool)) continue;
|
|
416
|
+
const jobs = enforcedCiJobs.filter((j) => j.tools.includes(tool));
|
|
417
|
+
const branchNote = jobs.every((j) => j.branchScoped) ? " (rule-scoped \u2014 blocking-ness may vary per branch)" : "";
|
|
418
|
+
findings.push(
|
|
419
|
+
finding({
|
|
420
|
+
id: `${LENS_ID2}/ci-only-${tool}`,
|
|
421
|
+
tier: "gap",
|
|
422
|
+
claim: `${TOOL_LABEL[tool]} is enforced only in CI \u2014 no local hook runs it`,
|
|
423
|
+
evidence: jobs.map((j) => `${j.file} job \`${j.job}\`${branchNote}`),
|
|
424
|
+
why: "A failure is invisible until the pipeline runs \u2014 the slowest possible feedback loop.",
|
|
425
|
+
action: "Wire it into a pre-push hook \u2014 `etymd gates` generates one from your own scripts.",
|
|
426
|
+
effort: "S",
|
|
427
|
+
confidence: "high"
|
|
428
|
+
})
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (inv.ci.system !== "none" && inv.ci.jobs.length) {
|
|
432
|
+
for (const tool of CORRECTNESS_TOOLS) {
|
|
433
|
+
if (!localTools.has(tool) || anyCiTools.has(tool)) continue;
|
|
434
|
+
if (inv.ci.inheritedIncludes.length) continue;
|
|
435
|
+
findings.push(
|
|
436
|
+
finding({
|
|
437
|
+
id: `${LENS_ID2}/local-only-${tool}`,
|
|
438
|
+
tier: "gap",
|
|
439
|
+
claim: `${TOOL_LABEL[tool]} runs only in local hooks \u2014 CI never re-checks it`,
|
|
440
|
+
evidence: [`local hooks (${inv.local.source})`],
|
|
441
|
+
why: "Hooks are skippable (`--no-verify`, fresh clones without hook setup) \u2014 CI is the backstop.",
|
|
442
|
+
action: "Add the same check to the CI pipeline.",
|
|
443
|
+
effort: "M",
|
|
444
|
+
confidence: "medium"
|
|
445
|
+
})
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (inv.thresholds.coverageCollected && !inv.thresholds.coverageThresholdLocal) {
|
|
450
|
+
const sonarJobs = inv.ci.jobs.filter((j) => j.tools.includes("sonar"));
|
|
451
|
+
const advisoryNote = sonarJobs.length && sonarJobs.every((j) => j.advisory) ? " (the job is allow_failure \u2014 advisory on this pipeline)" : "";
|
|
452
|
+
findings.push(
|
|
453
|
+
finding({
|
|
454
|
+
id: `${LENS_ID2}/coverage-no-local-threshold`,
|
|
455
|
+
tier: inv.thresholds.sonarConfigured ? "gap" : "polish",
|
|
456
|
+
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",
|
|
457
|
+
evidence: [
|
|
458
|
+
...sonarJobs.map((j) => `${j.file} job \`${j.job}\``),
|
|
459
|
+
"no coverageThreshold in jest/vitest config"
|
|
460
|
+
],
|
|
461
|
+
why: "A coverage regression is invisible until the server-side gate fails the pipeline \u2014 after push, sometimes after review.",
|
|
462
|
+
action: "Add a local coverageThreshold mirroring the server gate (or document the server value beside the config) so a regression warns before push.",
|
|
463
|
+
effort: "M",
|
|
464
|
+
confidence: inv.thresholds.sonarConfigured ? "high" : "medium"
|
|
465
|
+
})
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (inv.commitlintDep && !inv.local.commitMsg.includes("commitlint") && !localToolInHook(inv, "commitlint")) {
|
|
469
|
+
findings.push(
|
|
470
|
+
finding({
|
|
471
|
+
id: `${LENS_ID2}/commitlint-unwired`,
|
|
472
|
+
tier: "polish",
|
|
473
|
+
claim: "commitlint is a dependency but no commit-msg hook runs it",
|
|
474
|
+
evidence: ["package.json (@commitlint/cli)", "no commit-msg hook"],
|
|
475
|
+
why: "The commit convention the tooling promises is not actually enforced.",
|
|
476
|
+
action: "Wire commitlint into a commit-msg hook, or remove the dependency.",
|
|
477
|
+
effort: "S",
|
|
478
|
+
confidence: "high"
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
return findings;
|
|
483
|
+
}
|
|
484
|
+
function localToolInHook(inv, tool) {
|
|
485
|
+
return inv.local.preCommit.includes(tool) || inv.local.prePush.includes(tool) || inv.local.commitMsg.includes(tool);
|
|
486
|
+
}
|
|
487
|
+
var gateIntegrityLens = {
|
|
488
|
+
id: LENS_ID2,
|
|
489
|
+
version: "1",
|
|
490
|
+
title: "Gate integrity (CI \u2194 local)",
|
|
491
|
+
kind: "improvement",
|
|
492
|
+
async run(ctx) {
|
|
493
|
+
const inv = await buildGateInventory(ctx.root, ctx.facts);
|
|
494
|
+
const disclosures = [];
|
|
495
|
+
if (inv.ci.inheritedIncludes.length) {
|
|
496
|
+
disclosures.push(
|
|
497
|
+
`${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" : ""}`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const advisory = inv.ci.jobs.filter((j) => j.advisory);
|
|
501
|
+
if (advisory.length) {
|
|
502
|
+
disclosures.push(
|
|
503
|
+
`${advisory.length} CI job(s) are allow_failure/continue-on-error \u2014 advisory, not counted as enforced gates: ${advisory.map((j) => j.job).join(", ")}`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const inheritedScripts = inv.ci.jobs.filter((j) => !j.scriptVisible);
|
|
507
|
+
if (inheritedScripts.length) {
|
|
508
|
+
disclosures.push(
|
|
509
|
+
`${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(", ")}`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (inv.ci.parseErrors.length)
|
|
513
|
+
disclosures.push(...inv.ci.parseErrors.map((e) => `CI parse: ${e}`));
|
|
514
|
+
if (inv.ci.system === "none")
|
|
515
|
+
disclosures.push("No CI configuration found \u2014 CI\u2194local comparisons skipped.");
|
|
516
|
+
const findings = deriveGateFindings(inv, disclosures);
|
|
517
|
+
return {
|
|
518
|
+
lens: LENS_ID2,
|
|
519
|
+
version: "1",
|
|
520
|
+
title: "Gate integrity (CI \u2194 local)",
|
|
521
|
+
kind: "improvement",
|
|
522
|
+
status: "ran",
|
|
523
|
+
disclosures,
|
|
524
|
+
findings
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
async function listInstructionFiles(root, facts, scope) {
|
|
529
|
+
const files = [];
|
|
530
|
+
const add = async (rel) => {
|
|
531
|
+
const text = await readText(path.join(root, rel));
|
|
532
|
+
if (text !== null) files.push({ path: normalizeRelPath(rel), text });
|
|
533
|
+
};
|
|
534
|
+
const singleFileArtifacts = [
|
|
535
|
+
"agents",
|
|
536
|
+
"claude",
|
|
537
|
+
"gemini",
|
|
538
|
+
"copilot",
|
|
539
|
+
"cursorrules",
|
|
540
|
+
"cline",
|
|
541
|
+
"windsurf"
|
|
542
|
+
];
|
|
543
|
+
for (const id of singleFileArtifacts) {
|
|
544
|
+
const artifact = facts.artifacts.find((a) => a.id === id);
|
|
545
|
+
if (artifact?.exists) await add(artifact.path);
|
|
546
|
+
}
|
|
547
|
+
const rulesDir = path.join(root, ".cursor", "rules");
|
|
548
|
+
if (await isDirectory(rulesDir)) {
|
|
549
|
+
try {
|
|
550
|
+
for (const entry of await promises.readdir(rulesDir)) {
|
|
551
|
+
if (entry.endsWith(".md") || entry.endsWith(".mdc"))
|
|
552
|
+
await add(path.join(".cursor/rules", entry));
|
|
553
|
+
}
|
|
554
|
+
} catch {
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const skillsDir = path.join(root, ".claude", "skills");
|
|
558
|
+
if (await isDirectory(skillsDir)) {
|
|
559
|
+
try {
|
|
560
|
+
for (const entry of await promises.readdir(skillsDir)) {
|
|
561
|
+
const skill = path.join(".claude/skills", entry, "SKILL.md");
|
|
562
|
+
await add(skill);
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const detected = new Set(files.map((f) => f.path));
|
|
568
|
+
const included = [];
|
|
569
|
+
for (const rel of await expandFileGlobs(root, scope?.include ?? [])) {
|
|
570
|
+
if (detected.has(rel)) continue;
|
|
571
|
+
const before = files.length;
|
|
572
|
+
await add(rel);
|
|
573
|
+
if (files.length > before) included.push(rel);
|
|
574
|
+
}
|
|
575
|
+
const exclude = scope?.exclude ?? [];
|
|
576
|
+
if (!exclude.length) return { files, excluded: [], included };
|
|
577
|
+
const kept = [];
|
|
578
|
+
const excluded = [];
|
|
579
|
+
for (const file of files) {
|
|
580
|
+
if (matchesAnyGlob(file.path, exclude)) excluded.push(file.path);
|
|
581
|
+
else kept.push(file);
|
|
582
|
+
}
|
|
583
|
+
return { files: kept, excluded, included };
|
|
584
|
+
}
|
|
585
|
+
function extractCodeTokens(text) {
|
|
586
|
+
const tokens = [];
|
|
587
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) tokens.push(m[1].trim());
|
|
588
|
+
for (const block of text.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) {
|
|
589
|
+
for (const line of block[1].split("\n")) {
|
|
590
|
+
const trimmed = line.trim();
|
|
591
|
+
if (trimmed && !trimmed.startsWith("#")) tokens.push(trimmed);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return tokens;
|
|
595
|
+
}
|
|
596
|
+
var PM_BUILTINS = /* @__PURE__ */ new Set([
|
|
597
|
+
"install",
|
|
598
|
+
"i",
|
|
599
|
+
"add",
|
|
600
|
+
"remove",
|
|
601
|
+
"rm",
|
|
602
|
+
"up",
|
|
603
|
+
"update",
|
|
604
|
+
"upgrade",
|
|
605
|
+
"dlx",
|
|
606
|
+
"exec",
|
|
607
|
+
"create",
|
|
608
|
+
"init",
|
|
609
|
+
"link",
|
|
610
|
+
"unlink",
|
|
611
|
+
"publish",
|
|
612
|
+
"pack",
|
|
613
|
+
"audit",
|
|
614
|
+
"outdated",
|
|
615
|
+
"why",
|
|
616
|
+
"list",
|
|
617
|
+
"ls",
|
|
618
|
+
"view",
|
|
619
|
+
"info",
|
|
620
|
+
"config",
|
|
621
|
+
"store",
|
|
622
|
+
"import",
|
|
623
|
+
"rebuild",
|
|
624
|
+
"prune",
|
|
625
|
+
"setup",
|
|
626
|
+
"env",
|
|
627
|
+
"bin",
|
|
628
|
+
"root",
|
|
629
|
+
"licenses",
|
|
630
|
+
"patch",
|
|
631
|
+
"approve-builds",
|
|
632
|
+
"workspaces",
|
|
633
|
+
"workspace",
|
|
634
|
+
"cache",
|
|
635
|
+
"version",
|
|
636
|
+
"help"
|
|
637
|
+
]);
|
|
638
|
+
function extractCommandClaims(text) {
|
|
639
|
+
const scripts = /* @__PURE__ */ new Map();
|
|
640
|
+
let filteredSkipped = 0;
|
|
641
|
+
for (const token of extractCodeTokens(text)) {
|
|
642
|
+
for (const m of token.matchAll(
|
|
643
|
+
/(?:^|&&\s*|\|\|\s*|;\s*|\|\s*|\$\s+|\(\s*)(pnpm|yarn|npm|bun)\s+(?:(run)\s+)?(-{0,2}[A-Za-z0-9:._@/[\]-]+)/g
|
|
644
|
+
)) {
|
|
645
|
+
const pm = m[1];
|
|
646
|
+
const ranExplicit = Boolean(m[2]);
|
|
647
|
+
const arg = m[3];
|
|
648
|
+
if (arg.startsWith("-")) {
|
|
649
|
+
filteredSkipped += 1;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (pm === "npm" && !ranExplicit && arg !== "test" && arg !== "start") continue;
|
|
653
|
+
if ((pm === "bun" || pm === "yarn" || pm === "pnpm") && !ranExplicit && PM_BUILTINS.has(arg))
|
|
654
|
+
continue;
|
|
655
|
+
if (ranExplicit && PM_BUILTINS.has(arg)) continue;
|
|
656
|
+
scripts.set(arg, token);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return { scripts, filteredSkipped };
|
|
660
|
+
}
|
|
661
|
+
var PATH_TOKEN_RE = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.$-]+)+\/?$/;
|
|
662
|
+
var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
663
|
+
..."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(" ")
|
|
664
|
+
]);
|
|
665
|
+
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;
|
|
666
|
+
var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
|
|
667
|
+
var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
|
|
668
|
+
function isPlaceholderClaim(token) {
|
|
669
|
+
return token.split("/").some((seg) => PLACEHOLDER_PREFIX_RE.test(seg) || PLACEHOLDER_SEGMENTS.has(seg.toLowerCase()));
|
|
670
|
+
}
|
|
671
|
+
function claimContext(text, index) {
|
|
672
|
+
const start = text.lastIndexOf("\n", index) + 1;
|
|
673
|
+
const endRaw = text.indexOf("\n", index);
|
|
674
|
+
const end = endRaw === -1 ? text.length : endRaw;
|
|
675
|
+
const line = text.slice(start, end);
|
|
676
|
+
if (!/^\s*(?:[-*+]|\d+[.)]|\|)/.test(line)) return line;
|
|
677
|
+
let cursor = start;
|
|
678
|
+
while (cursor > 0) {
|
|
679
|
+
const prevEnd = cursor - 1;
|
|
680
|
+
const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
681
|
+
const prev = text.slice(prevStart, prevEnd);
|
|
682
|
+
cursor = prevStart;
|
|
683
|
+
if (!prev.trim()) continue;
|
|
684
|
+
if (/^\s*(?:[-*+]|\d+[.)]|\|)/.test(prev)) continue;
|
|
685
|
+
return `${prev}
|
|
686
|
+
${line}`;
|
|
687
|
+
}
|
|
688
|
+
return line;
|
|
689
|
+
}
|
|
690
|
+
function extractPathClaims(text) {
|
|
691
|
+
const prospectiveOnly = /* @__PURE__ */ new Map();
|
|
692
|
+
const placeholder = /* @__PURE__ */ new Set();
|
|
693
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
694
|
+
const token = m[1].trim();
|
|
695
|
+
if (token.includes(" ") || token.length > 120) continue;
|
|
696
|
+
if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
|
|
697
|
+
continue;
|
|
698
|
+
if (token.includes("://") || token.startsWith("www.")) continue;
|
|
699
|
+
if (/[*?{}<>|]/.test(token)) continue;
|
|
700
|
+
if (token.includes("@")) continue;
|
|
701
|
+
if (!PATH_TOKEN_RE.test(token)) continue;
|
|
702
|
+
if (token.split("/").some((seg) => seg.startsWith("$"))) continue;
|
|
703
|
+
const isDirClaim = token.endsWith("/");
|
|
704
|
+
const ext = token.toLowerCase().match(/\.([a-z0-9]{1,8})$/)?.[1];
|
|
705
|
+
if (!isDirClaim && !(ext && KNOWN_EXTENSIONS.has(ext))) continue;
|
|
706
|
+
const claim = token.replace(/\/$/, "");
|
|
707
|
+
if (isPlaceholderClaim(claim)) {
|
|
708
|
+
placeholder.add(claim);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const prospective2 = CREATION_CONTEXT_RE.test(claimContext(text, m.index ?? 0));
|
|
712
|
+
prospectiveOnly.set(claim, (prospectiveOnly.get(claim) ?? true) && prospective2);
|
|
713
|
+
}
|
|
714
|
+
const paths = [];
|
|
715
|
+
const prospective = [];
|
|
716
|
+
for (const [claim, only] of prospectiveOnly) {
|
|
717
|
+
if (only) prospective.push(claim);
|
|
718
|
+
else paths.push(claim);
|
|
719
|
+
}
|
|
720
|
+
return { paths, prospective, placeholder: [...placeholder] };
|
|
721
|
+
}
|
|
722
|
+
function packageManagerUsage(text) {
|
|
723
|
+
const counts = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const token of extractCodeTokens(text)) {
|
|
725
|
+
for (const m of token.matchAll(/\b(pnpm|yarn|npm|bun)\s+(?:run\s+)?[A-Za-z-]/g)) {
|
|
726
|
+
const pm = m[1];
|
|
727
|
+
counts.set(pm, (counts.get(pm) ?? 0) + 1);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return counts;
|
|
731
|
+
}
|
|
732
|
+
var KNOWN_DOC_REFS = ["AGENTS.md", "CLAUDE.md", "PROJECT_CONTEXT.md", "GEMINI.md"];
|
|
733
|
+
function extractDocRefs(text) {
|
|
734
|
+
return KNOWN_DOC_REFS.filter((name) => text.includes(name));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/lenses/instruction-truth/lens.ts
|
|
738
|
+
var LENS_ID3 = "instruction-truth";
|
|
739
|
+
var MAX_PATH_FINDINGS_PER_FILE = 15;
|
|
740
|
+
function finding2(partial) {
|
|
741
|
+
return { lens: LENS_ID3, ...partial };
|
|
742
|
+
}
|
|
743
|
+
function compareCommands(baseline, fresh) {
|
|
744
|
+
const out = [];
|
|
745
|
+
for (const role of ["test", "lint", "typecheck", "format", "formatCheck", "build"]) {
|
|
746
|
+
const before = baseline.commands[role];
|
|
747
|
+
if (before && !(before in fresh.commands.raw)) {
|
|
748
|
+
out.push(
|
|
749
|
+
finding2({
|
|
750
|
+
id: `${LENS_ID3}/command-gone-${role}`,
|
|
751
|
+
tier: "risk",
|
|
752
|
+
claim: `Documented ${role} command \`${before}\` no longer exists in package.json`,
|
|
753
|
+
evidence: ["package.json"],
|
|
754
|
+
why: "Every doc, hook, and agent instruction naming it now fails or silently skips a check.",
|
|
755
|
+
action: "Re-point the contract/hooks at the renamed script, then refresh the baseline.",
|
|
756
|
+
effort: "S",
|
|
757
|
+
confidence: "high"
|
|
758
|
+
})
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return out;
|
|
763
|
+
}
|
|
764
|
+
function compareArtifacts(baseline, fresh) {
|
|
765
|
+
const out = [];
|
|
766
|
+
const freshById = new Map(fresh.artifacts.map((a) => [a.id, a]));
|
|
767
|
+
for (const a of baseline.artifacts) {
|
|
768
|
+
const now = freshById.get(a.id);
|
|
769
|
+
if (a.exists && now && !now.exists) {
|
|
770
|
+
out.push(
|
|
771
|
+
finding2({
|
|
772
|
+
id: `${LENS_ID3}/artifact-gone-${a.id}`,
|
|
773
|
+
tier: "gap",
|
|
774
|
+
claim: `${a.label} was present at baseline but is now missing`,
|
|
775
|
+
evidence: [a.path],
|
|
776
|
+
why: "Agents and docs still assume it exists; instructions referring to it now mislead.",
|
|
777
|
+
action: "Restore it or update the contract and refresh the baseline.",
|
|
778
|
+
effort: "S",
|
|
779
|
+
confidence: "high"
|
|
780
|
+
})
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
function compareLayout(baseline, fresh) {
|
|
787
|
+
const now = new Set(fresh.tree.dirs.map((d) => d.name));
|
|
788
|
+
return baseline.tree.dirs.filter((d) => !now.has(d.name)).map(
|
|
789
|
+
(d) => finding2({
|
|
790
|
+
id: `${LENS_ID3}/dir-gone-${d.name}`,
|
|
791
|
+
tier: "gap",
|
|
792
|
+
claim: `Top-level \`${d.name}/\` from the baseline no longer exists \u2014 the repo map may be stale`,
|
|
793
|
+
evidence: [`${d.name}/`],
|
|
794
|
+
why: "A stale map sends agents (and people) to paths that are gone.",
|
|
795
|
+
action: "Update the repo map and refresh the baseline.",
|
|
796
|
+
effort: "S",
|
|
797
|
+
confidence: "medium"
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
var instructionTruthLens = {
|
|
802
|
+
id: LENS_ID3,
|
|
803
|
+
version: "1",
|
|
804
|
+
title: "Instruction truth",
|
|
805
|
+
kind: "truth",
|
|
806
|
+
async run(ctx) {
|
|
807
|
+
const findings = [];
|
|
808
|
+
const disclosures = [];
|
|
809
|
+
const { facts, root } = ctx;
|
|
810
|
+
const config = ctx.config?.config ?? DEFAULT_CONFIG;
|
|
811
|
+
disclosures.push(...ctx.config?.problems ?? []);
|
|
812
|
+
const {
|
|
813
|
+
files,
|
|
814
|
+
excluded,
|
|
815
|
+
included: includedExtra
|
|
816
|
+
} = await listInstructionFiles(root, facts, config.instructions);
|
|
817
|
+
if (!files.length) {
|
|
818
|
+
findings.push(
|
|
819
|
+
finding2({
|
|
820
|
+
id: `${LENS_ID3}/no-contract`,
|
|
821
|
+
tier: "gap",
|
|
822
|
+
claim: "No agent instruction files exist (AGENTS.md or equivalents)",
|
|
823
|
+
evidence: ["AGENTS.md (missing)"],
|
|
824
|
+
why: "Every agent in this repo works from generic priors instead of the project's rules.",
|
|
825
|
+
action: "Run `etymd init` to scaffold one.",
|
|
826
|
+
effort: "M",
|
|
827
|
+
confidence: "high"
|
|
828
|
+
})
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
const knownScripts = new Set(Object.keys(facts.commands.raw));
|
|
832
|
+
for (const pkg of facts.packages) {
|
|
833
|
+
const pkgJson = await readJson(
|
|
834
|
+
path.join(root, pkg.dir, "package.json")
|
|
835
|
+
);
|
|
836
|
+
for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
|
|
837
|
+
}
|
|
838
|
+
const pathResolves = async (claim) => {
|
|
839
|
+
const bases = [root, ...facts.packages.map((p) => path.join(root, p.dir))];
|
|
840
|
+
for (const base of bases) {
|
|
841
|
+
if (await pathExists(path.join(base, claim))) return true;
|
|
842
|
+
if (await pathExists(path.join(base, "src", claim))) return true;
|
|
843
|
+
if (await pathExists(path.join(base, "scripts", claim))) return true;
|
|
844
|
+
}
|
|
845
|
+
return false;
|
|
846
|
+
};
|
|
847
|
+
const binResolves = async (name) => {
|
|
848
|
+
const bases = [root, ...facts.packages.map((p) => path.join(root, p.dir))];
|
|
849
|
+
for (const base of bases) {
|
|
850
|
+
if (await pathExists(path.join(base, "node_modules", ".bin", name))) return true;
|
|
851
|
+
}
|
|
852
|
+
return false;
|
|
853
|
+
};
|
|
854
|
+
const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
|
|
855
|
+
let totalFilteredSkipped = 0;
|
|
856
|
+
let binaryResolved = 0;
|
|
857
|
+
let unverifiableCommands = 0;
|
|
858
|
+
let gitignoredSkipped = 0;
|
|
859
|
+
let prospectiveSkipped = 0;
|
|
860
|
+
let placeholderSkipped = 0;
|
|
861
|
+
for (const file of files) {
|
|
862
|
+
const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
|
|
863
|
+
totalFilteredSkipped += filteredSkipped;
|
|
864
|
+
for (const [script, raw] of claimed) {
|
|
865
|
+
if (knownScripts.has(script)) continue;
|
|
866
|
+
if (await binResolves(script)) {
|
|
867
|
+
binaryResolved += 1;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (!nodeModulesInstalled) {
|
|
871
|
+
unverifiableCommands += 1;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
findings.push(
|
|
875
|
+
finding2({
|
|
876
|
+
id: `${LENS_ID3}/stale-command:${file.path}:${script}`,
|
|
877
|
+
tier: "risk",
|
|
878
|
+
claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
|
|
879
|
+
evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
|
|
880
|
+
why: "An agent following this instruction runs a command that fails \u2014 or silently skips the check it was meant to run.",
|
|
881
|
+
action: "Update the instruction to the current script name (or restore the script).",
|
|
882
|
+
effort: "S",
|
|
883
|
+
confidence: "high"
|
|
884
|
+
})
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const { paths, prospective, placeholder } = extractPathClaims(file.text);
|
|
888
|
+
prospectiveSkipped += prospective.length;
|
|
889
|
+
placeholderSkipped += placeholder.length;
|
|
890
|
+
const missing = [];
|
|
891
|
+
for (const claim of paths) {
|
|
892
|
+
if (!await pathResolves(claim)) missing.push(claim);
|
|
893
|
+
}
|
|
894
|
+
const ignoredOut = missing.length ? await git(root, ["check-ignore", ...missing]) : null;
|
|
895
|
+
const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
|
|
896
|
+
let pathFindings = 0;
|
|
897
|
+
for (const claim of missing) {
|
|
898
|
+
if (gitignored.has(claim)) {
|
|
899
|
+
gitignoredSkipped += 1;
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (pathFindings >= MAX_PATH_FINDINGS_PER_FILE) {
|
|
903
|
+
disclosures.push(
|
|
904
|
+
`${file.path}: more than ${MAX_PATH_FINDINGS_PER_FILE} missing-path claims \u2014 truncated.`
|
|
905
|
+
);
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
pathFindings += 1;
|
|
909
|
+
findings.push(
|
|
910
|
+
finding2({
|
|
911
|
+
id: `${LENS_ID3}/stale-path:${file.path}:${claim}`,
|
|
912
|
+
tier: "gap",
|
|
913
|
+
claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
|
|
914
|
+
evidence: [file.path, `missing: ${claim}`],
|
|
915
|
+
why: "Agents navigate by these references; a dead path wastes a lookup and erodes trust in the rest of the file.",
|
|
916
|
+
action: "Fix or remove the reference.",
|
|
917
|
+
effort: "S",
|
|
918
|
+
confidence: "medium"
|
|
919
|
+
})
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
if (facts.packageManager !== "unknown") {
|
|
923
|
+
const usage = packageManagerUsage(file.text);
|
|
924
|
+
const own = usage.get(facts.packageManager) ?? 0;
|
|
925
|
+
for (const [pm, count] of usage) {
|
|
926
|
+
if (pm === facts.packageManager || count < 2 || count <= own) continue;
|
|
927
|
+
findings.push(
|
|
928
|
+
finding2({
|
|
929
|
+
id: `${LENS_ID3}/pm-conflict:${file.path}`,
|
|
930
|
+
tier: "gap",
|
|
931
|
+
claim: `${file.path} instructs \`${pm}\` (${count}\xD7) but the repo uses ${facts.packageManager}`,
|
|
932
|
+
evidence: [file.path, `lockfile \u2192 ${facts.packageManager}`],
|
|
933
|
+
why: "Mixed package-manager instructions produce divergent lockfiles and broken installs.",
|
|
934
|
+
action: `Rewrite the commands for ${facts.packageManager}.`,
|
|
935
|
+
effort: "S",
|
|
936
|
+
confidence: "medium"
|
|
937
|
+
})
|
|
938
|
+
);
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
for (const ref of extractDocRefs(file.text)) {
|
|
943
|
+
if (await pathExists(path.join(root, ref))) continue;
|
|
944
|
+
findings.push(
|
|
945
|
+
finding2({
|
|
946
|
+
id: `${LENS_ID3}/dangling-ref:${file.path}:${ref}`,
|
|
947
|
+
tier: "gap",
|
|
948
|
+
claim: `${file.path} references ${ref} \u2014 no such file exists`,
|
|
949
|
+
evidence: [file.path, `missing: ${ref}`],
|
|
950
|
+
why: "The pointer chain agents follow breaks at a file they can never read.",
|
|
951
|
+
action: `Create ${ref} or remove the reference.`,
|
|
952
|
+
effort: "S",
|
|
953
|
+
confidence: "high"
|
|
954
|
+
})
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (ctx.baseline) {
|
|
959
|
+
findings.push(
|
|
960
|
+
...compareCommands(ctx.baseline.facts, facts),
|
|
961
|
+
...compareArtifacts(ctx.baseline.facts, facts),
|
|
962
|
+
...compareLayout(ctx.baseline.facts, facts)
|
|
963
|
+
);
|
|
964
|
+
if (baselineCarriesMachinePath(ctx.baseline)) {
|
|
965
|
+
disclosures.push(
|
|
966
|
+
`${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.`
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
if (ctx.baseline.packVersion !== PACK_VERSION) {
|
|
970
|
+
disclosures.push(
|
|
971
|
+
`Baseline was approved under pack v${ctx.baseline.packVersion}; current pack is v${PACK_VERSION}.`
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
} else {
|
|
975
|
+
disclosures.push(
|
|
976
|
+
"No committed baseline (.etymd/baseline.json) \u2014 drift over time is not measurable; run `etymd init` to approve one."
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
if (binaryResolved) {
|
|
980
|
+
disclosures.push(
|
|
981
|
+
`${binaryResolved} command claim(s) are installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
if (unverifiableCommands) {
|
|
985
|
+
disclosures.push(
|
|
986
|
+
`node_modules is not installed \u2014 ${unverifiableCommands} command claim(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
if (gitignoredSkipped) {
|
|
990
|
+
disclosures.push(
|
|
991
|
+
`${gitignoredSkipped} missing path claim(s) are gitignored (machine-local, e.g. .env) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
if (prospectiveSkipped) {
|
|
995
|
+
disclosures.push(
|
|
996
|
+
`${prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
if (placeholderSkipped) {
|
|
1000
|
+
disclosures.push(
|
|
1001
|
+
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
if (excluded.length) {
|
|
1005
|
+
const shown = excluded.slice(0, 5).join(", ");
|
|
1006
|
+
disclosures.push(
|
|
1007
|
+
`${excluded.length} instruction file(s) excluded by ${CONFIG_FILE} and NOT audited: ${shown}${excluded.length > 5 ? `, +${excluded.length - 5} more` : ""}.`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
if (includedExtra.length) {
|
|
1011
|
+
disclosures.push(
|
|
1012
|
+
`${includedExtra.length} extra instruction file(s) audited via ${CONFIG_FILE} \`instructions.include\`: ${includedExtra.slice(0, 5).join(", ")}${includedExtra.length > 5 ? `, +${includedExtra.length - 5} more` : ""}.`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
disclosures.push(
|
|
1016
|
+
`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.`
|
|
1017
|
+
);
|
|
1018
|
+
return {
|
|
1019
|
+
lens: LENS_ID3,
|
|
1020
|
+
version: "1",
|
|
1021
|
+
title: "Instruction truth",
|
|
1022
|
+
kind: "truth",
|
|
1023
|
+
status: "ran",
|
|
1024
|
+
disclosures,
|
|
1025
|
+
findings,
|
|
1026
|
+
outOfScope: excluded
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
// src/engine/finding.ts
|
|
1032
|
+
var TIER_ORDER = { risk: 0, gap: 1, polish: 2 };
|
|
1033
|
+
var EFFORT_ORDER = { S: 0, M: 1, L: 2 };
|
|
1034
|
+
function rankFindings(findings) {
|
|
1035
|
+
return [...findings].sort(
|
|
1036
|
+
(a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier] || EFFORT_ORDER[a.effort] - EFFORT_ORDER[b.effort]
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/engine/run.ts
|
|
1041
|
+
var LENSES = [instructionTruthLens, gateIntegrityLens, contextEconomyLens];
|
|
1042
|
+
async function runAudit(root, opts = {}) {
|
|
1043
|
+
const facts = await scanProject(root);
|
|
1044
|
+
if ((opts.persistLedger ?? true) || await pathExists(path.join(root, ETYMD_DIR))) {
|
|
1045
|
+
await writeCachedFacts(root, facts);
|
|
1046
|
+
}
|
|
1047
|
+
const baseline = await readBaseline(root);
|
|
1048
|
+
const config = await readConfig(root);
|
|
1049
|
+
const profile = baseline?.profile ?? deriveProfile(facts);
|
|
1050
|
+
const selected = LENSES.filter(
|
|
1051
|
+
(lens) => (!opts.kind || lens.kind === opts.kind) && (!opts.lensIds || opts.lensIds.includes(lens.id))
|
|
1052
|
+
);
|
|
1053
|
+
const reports = [];
|
|
1054
|
+
for (const lens of selected) {
|
|
1055
|
+
try {
|
|
1056
|
+
reports.push(await lens.run({ root, facts, profile, baseline, config }));
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
reports.push({
|
|
1059
|
+
lens: lens.id,
|
|
1060
|
+
version: lens.version,
|
|
1061
|
+
title: lens.title,
|
|
1062
|
+
kind: lens.kind,
|
|
1063
|
+
status: "skipped",
|
|
1064
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1065
|
+
disclosures: [],
|
|
1066
|
+
findings: []
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const all = reports.flatMap((r) => r.findings);
|
|
1071
|
+
const previous = await readLedger(root);
|
|
1072
|
+
const outOfScope = reports.flatMap((r) => r.outOfScope ?? []);
|
|
1073
|
+
const { ledger, diff } = reconcileLedger(previous, all, void 0, outOfScope);
|
|
1074
|
+
const fullRun = selected.length === LENSES.length;
|
|
1075
|
+
if ((opts.persistLedger ?? true) && fullRun) {
|
|
1076
|
+
await writeLedger(root, ledger);
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
facts,
|
|
1080
|
+
profile,
|
|
1081
|
+
baseline,
|
|
1082
|
+
config,
|
|
1083
|
+
reports,
|
|
1084
|
+
findings: rankFindings(visibleFindings(all, previous)),
|
|
1085
|
+
ledger,
|
|
1086
|
+
diff
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// src/commands/audit.ts
|
|
1091
|
+
var TIER_RANK = { risk: 0, gap: 1, polish: 2 };
|
|
1092
|
+
async function run(opts) {
|
|
1093
|
+
const result = await runAudit(opts.cwd, {
|
|
1094
|
+
kind: opts.truth ? "truth" : void 0,
|
|
1095
|
+
lensIds: opts.lens ? [opts.lens] : void 0,
|
|
1096
|
+
persistLedger: !opts.noLedger
|
|
1097
|
+
});
|
|
1098
|
+
if (opts.failOn) {
|
|
1099
|
+
const threshold = TIER_RANK[opts.failOn];
|
|
1100
|
+
if (result.findings.some((f) => TIER_RANK[f.tier] <= threshold)) {
|
|
1101
|
+
process.exitCode = 1;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (opts.json) {
|
|
1105
|
+
print(
|
|
1106
|
+
JSON.stringify(
|
|
1107
|
+
{
|
|
1108
|
+
profile: result.profile,
|
|
1109
|
+
baseline: result.baseline ? { packVersion: result.baseline.packVersion, approvedAt: result.baseline.approvedAt } : null,
|
|
1110
|
+
reports: result.reports.map(({ findings: _findings, ...r }) => r),
|
|
1111
|
+
findings: result.findings,
|
|
1112
|
+
diff: {
|
|
1113
|
+
new: result.diff.new.map((f) => f.id),
|
|
1114
|
+
stillOpen: result.diff.stillOpen.map((f) => f.id),
|
|
1115
|
+
regressed: result.diff.regressed.map((f) => f.id),
|
|
1116
|
+
resolved: result.diff.resolved.map((e) => e.id),
|
|
1117
|
+
dismissed: result.diff.dismissed.map((f) => f.id),
|
|
1118
|
+
accepted: result.diff.accepted.map((f) => f.id)
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
null,
|
|
1122
|
+
2
|
|
1123
|
+
)
|
|
1124
|
+
);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
section(
|
|
1128
|
+
`${opts.truth ? "Doctor \u2014 is this still true?" : "Audit"} ${theme.dim(`\xB7 ${result.facts.name} \xB7 ${result.profile} profile`)}`
|
|
1129
|
+
);
|
|
1130
|
+
if (!result.baseline && !opts.truth) {
|
|
1131
|
+
print(
|
|
1132
|
+
` ${theme.dim("no committed baseline \u2014 run `etymd init` to approve one; drift checks are limited")}`
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
renderLensCoverage(result.reports);
|
|
1136
|
+
section(`Findings ${theme.dim("(ranked: risk \u2192 gap \u2192 polish, cheapest first)")}`);
|
|
1137
|
+
renderFindings(result.findings);
|
|
1138
|
+
renderLedgerDiff(result.diff);
|
|
1139
|
+
const top = result.findings[0];
|
|
1140
|
+
if (top) {
|
|
1141
|
+
print();
|
|
1142
|
+
print(
|
|
1143
|
+
` ${theme.dim("next:")} ${theme.heading(top.claim)}${top.action ? theme.dim(` \u2014 ${top.action}`) : ""}`
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
export { run };
|