etymd 0.8.0 → 0.9.1
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 +31 -0
- package/dist/{approve-MYOIDCAM.js → approve-HED3NCTE.js} +2 -2
- package/dist/{audit-HO56CZNV.js → audit-Y35XVD6Z.js} +4 -4
- package/dist/{brief-XI4RCNYS.js → brief-2CXAUDNN.js} +2 -2
- package/dist/{chunk-72Y2V7VF.js → chunk-AQM4GYGN.js} +1 -1
- package/dist/{chunk-PRIH2NZF.js → chunk-KGVRZ6WC.js} +772 -677
- package/dist/{chunk-KBYEWHYX.js → chunk-ULM25GWG.js} +1 -1
- package/dist/{chunk-CIRNG736.js → chunk-UN7IAYM6.js} +1 -1
- package/dist/cli.js +14 -14
- package/dist/{doctor-ZAAEVVB4.js → doctor-DXXQJ2L3.js} +4 -4
- package/dist/{fleet-6U6XW72I.js → fleet-DPJSZDEY.js} +4 -4
- package/dist/{gates-N25LGIR2.js → gates-RR3V4PII.js} +2 -2
- package/dist/index.js +775 -680
- package/dist/{init-SSAJVX52.js → init-RC35RS5J.js} +2 -2
- package/dist/scan-KX522VKV.js +5 -0
- package/dist/{scan-H2IPH4C3.js → scan-MBXSD5RO.js} +2 -2
- package/dist/{screen-6R6NXDSY.js → screen-EHNK6ANK.js} +28 -4
- package/package.json +1 -1
- package/dist/scan-LVJOAOUJ.js +0 -5
package/dist/index.js
CHANGED
|
@@ -30,7 +30,7 @@ var init_package = __esm({
|
|
|
30
30
|
"package.json"() {
|
|
31
31
|
package_default = {
|
|
32
32
|
name: "etymd",
|
|
33
|
-
version: "0.
|
|
33
|
+
version: "0.9.1",
|
|
34
34
|
description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules & skills against the actual repo, with drift caught over time and a regression ledger.",
|
|
35
35
|
keywords: [
|
|
36
36
|
"cli",
|
|
@@ -2121,793 +2121,888 @@ init_config();
|
|
|
2121
2121
|
init_facts();
|
|
2122
2122
|
init_util();
|
|
2123
2123
|
|
|
2124
|
-
// src/lenses/
|
|
2125
|
-
|
|
2124
|
+
// src/lenses/state-freshness.ts
|
|
2125
|
+
init_config();
|
|
2126
2126
|
init_util();
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
}
|
|
2146
|
-
const rulesDir = path.join(root, ".cursor", "rules");
|
|
2147
|
-
if (await isDirectory(rulesDir)) {
|
|
2148
|
-
try {
|
|
2149
|
-
for (const entry of await promises.readdir(rulesDir)) {
|
|
2150
|
-
if (entry.endsWith(".md") || entry.endsWith(".mdc"))
|
|
2151
|
-
await add(path.join(".cursor/rules", entry));
|
|
2152
|
-
}
|
|
2153
|
-
} catch {
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
const skillsDir = path.join(root, ".claude", "skills");
|
|
2157
|
-
if (await isDirectory(skillsDir)) {
|
|
2158
|
-
try {
|
|
2159
|
-
for (const entry of await promises.readdir(skillsDir)) {
|
|
2160
|
-
const skill = path.join(".claude/skills", entry, "SKILL.md");
|
|
2161
|
-
await add(skill);
|
|
2162
|
-
}
|
|
2163
|
-
} catch {
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
|
-
const detected = new Set(files.map((f) => f.path));
|
|
2167
|
-
const included = [];
|
|
2168
|
-
for (const rel of await expandFileGlobs(root, scope?.include ?? [])) {
|
|
2169
|
-
if (detected.has(rel)) continue;
|
|
2170
|
-
const before = files.length;
|
|
2171
|
-
await add(rel);
|
|
2172
|
-
if (files.length > before) included.push(rel);
|
|
2173
|
-
}
|
|
2174
|
-
const exclude = scope?.exclude ?? [];
|
|
2175
|
-
if (!exclude.length) return { files, excluded: [], included };
|
|
2176
|
-
const kept = [];
|
|
2177
|
-
const excluded = [];
|
|
2178
|
-
for (const file of files) {
|
|
2179
|
-
if (matchesAnyGlob(file.path, exclude)) excluded.push(file.path);
|
|
2180
|
-
else kept.push(file);
|
|
2181
|
-
}
|
|
2182
|
-
return { files: kept, excluded, included };
|
|
2183
|
-
}
|
|
2184
|
-
function extractCodeTokens(text) {
|
|
2185
|
-
const tokens = [];
|
|
2186
|
-
for (const m of text.matchAll(/`([^`\n]+)`/g)) tokens.push(m[1].trim());
|
|
2187
|
-
for (const block of text.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) {
|
|
2188
|
-
for (const line of block[1].split("\n")) {
|
|
2189
|
-
const trimmed = line.trim();
|
|
2190
|
-
if (trimmed && !trimmed.startsWith("#")) tokens.push(trimmed);
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
return tokens;
|
|
2194
|
-
}
|
|
2195
|
-
var PM_BUILTINS = /* @__PURE__ */ new Set([
|
|
2196
|
-
"install",
|
|
2197
|
-
"i",
|
|
2198
|
-
"add",
|
|
2199
|
-
"remove",
|
|
2200
|
-
"rm",
|
|
2201
|
-
"up",
|
|
2202
|
-
"update",
|
|
2203
|
-
"upgrade",
|
|
2204
|
-
"dlx",
|
|
2205
|
-
"exec",
|
|
2206
|
-
"create",
|
|
2207
|
-
"init",
|
|
2208
|
-
"link",
|
|
2209
|
-
"unlink",
|
|
2210
|
-
"publish",
|
|
2211
|
-
"pack",
|
|
2212
|
-
"audit",
|
|
2213
|
-
"outdated",
|
|
2214
|
-
"why",
|
|
2215
|
-
"list",
|
|
2216
|
-
"ls",
|
|
2217
|
-
"view",
|
|
2218
|
-
"info",
|
|
2219
|
-
"config",
|
|
2220
|
-
"store",
|
|
2221
|
-
"import",
|
|
2222
|
-
"rebuild",
|
|
2223
|
-
"prune",
|
|
2224
|
-
"setup",
|
|
2225
|
-
"env",
|
|
2226
|
-
"bin",
|
|
2227
|
-
"root",
|
|
2228
|
-
"licenses",
|
|
2229
|
-
"patch",
|
|
2230
|
-
"approve-builds",
|
|
2231
|
-
"workspaces",
|
|
2232
|
-
"workspace",
|
|
2233
|
-
"cache",
|
|
2234
|
-
"version",
|
|
2235
|
-
"help"
|
|
2236
|
-
]);
|
|
2237
|
-
function extractCommandClaims(text) {
|
|
2238
|
-
const scripts = /* @__PURE__ */ new Map();
|
|
2239
|
-
let filteredSkipped = 0;
|
|
2240
|
-
for (const token of extractCodeTokens(text)) {
|
|
2241
|
-
for (const m of token.matchAll(
|
|
2242
|
-
/(?:^|&&\s*|\|\|\s*|;\s*|\|\s*|\$\s+|\(\s*)(pnpm|yarn|npm|bun)\s+(?:(run)\s+)?(-{0,2}[A-Za-z0-9:._@/[\]-]+)/g
|
|
2243
|
-
)) {
|
|
2244
|
-
const pm = m[1];
|
|
2245
|
-
const ranExplicit = Boolean(m[2]);
|
|
2246
|
-
const arg = m[3];
|
|
2247
|
-
if (arg.startsWith("-")) {
|
|
2248
|
-
filteredSkipped += 1;
|
|
2249
|
-
continue;
|
|
2250
|
-
}
|
|
2251
|
-
if (pm === "npm" && !ranExplicit && arg !== "test" && arg !== "start") continue;
|
|
2252
|
-
if ((pm === "bun" || pm === "yarn" || pm === "pnpm") && !ranExplicit && PM_BUILTINS.has(arg))
|
|
2253
|
-
continue;
|
|
2254
|
-
if (ranExplicit && PM_BUILTINS.has(arg)) continue;
|
|
2255
|
-
scripts.set(arg, token);
|
|
2256
|
-
}
|
|
2127
|
+
var LENS_ID3 = "state-freshness";
|
|
2128
|
+
var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
|
|
2129
|
+
var KNOWN_FORMAT_VERSION = 1;
|
|
2130
|
+
var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
|
|
2131
|
+
var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
|
|
2132
|
+
var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
|
|
2133
|
+
var MS_PER_DAY = 864e5;
|
|
2134
|
+
function parseDecisionsFormat(text) {
|
|
2135
|
+
const m = MARKER_RE.exec(text);
|
|
2136
|
+
if (!m) return null;
|
|
2137
|
+
const problems = [];
|
|
2138
|
+
const fields = [];
|
|
2139
|
+
const offset = m.index ?? 0;
|
|
2140
|
+
const version = Number(m[1]);
|
|
2141
|
+
if (version !== KNOWN_FORMAT_VERSION) {
|
|
2142
|
+
problems.push(
|
|
2143
|
+
`declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
|
|
2144
|
+
);
|
|
2257
2145
|
}
|
|
2258
|
-
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
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;
|
|
2265
|
-
var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
|
|
2266
|
-
var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
|
|
2267
|
-
function isPlaceholderClaim(token) {
|
|
2268
|
-
return token.split("/").some((seg) => PLACEHOLDER_PREFIX_RE.test(seg) || PLACEHOLDER_SEGMENTS.has(seg.toLowerCase()));
|
|
2269
|
-
}
|
|
2270
|
-
function claimContext(text, index) {
|
|
2271
|
-
const start = text.lastIndexOf("\n", index) + 1;
|
|
2272
|
-
const endRaw = text.indexOf("\n", index);
|
|
2273
|
-
const end = endRaw === -1 ? text.length : endRaw;
|
|
2274
|
-
const line = text.slice(start, end);
|
|
2275
|
-
if (!/^\s*(?:[-*+]|\d+[.)]|\|)/.test(line)) return line;
|
|
2276
|
-
let cursor = start;
|
|
2277
|
-
while (cursor > 0) {
|
|
2278
|
-
const prevEnd = cursor - 1;
|
|
2279
|
-
const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
2280
|
-
const prev = text.slice(prevStart, prevEnd);
|
|
2281
|
-
cursor = prevStart;
|
|
2282
|
-
if (!prev.trim()) continue;
|
|
2283
|
-
if (/^\s*(?:[-*+]|\d+[.)]|\|)/.test(prev)) continue;
|
|
2284
|
-
return `${prev}
|
|
2285
|
-
${line}`;
|
|
2146
|
+
const attrs = (m[2] ?? "").trim();
|
|
2147
|
+
if (!attrs) return { fields, offset, problems };
|
|
2148
|
+
const declared = /^fields=(.*)$/.exec(attrs);
|
|
2149
|
+
if (!declared) {
|
|
2150
|
+
problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
|
|
2151
|
+
return { fields, offset, problems };
|
|
2286
2152
|
}
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
|
|
2296
|
-
continue;
|
|
2297
|
-
if (token.includes("://") || token.startsWith("www.")) continue;
|
|
2298
|
-
if (/[*?{}<>|]/.test(token)) continue;
|
|
2299
|
-
if (token.includes("@")) continue;
|
|
2300
|
-
if (!PATH_TOKEN_RE.test(token)) continue;
|
|
2301
|
-
if (token.split("/").some((seg) => seg.startsWith("$"))) continue;
|
|
2302
|
-
const isDirClaim = token.endsWith("/");
|
|
2303
|
-
const ext = token.toLowerCase().match(/\.([a-z0-9]{1,8})$/)?.[1];
|
|
2304
|
-
if (!isDirClaim && !(ext && KNOWN_EXTENSIONS.has(ext))) continue;
|
|
2305
|
-
const claim = token.replace(/\/$/, "");
|
|
2306
|
-
if (isPlaceholderClaim(claim)) {
|
|
2307
|
-
placeholder.add(claim);
|
|
2153
|
+
const seen = new Set(BUILT_IN_FIELDS);
|
|
2154
|
+
for (const raw of declared[1].split(",")) {
|
|
2155
|
+
const name = raw.trim();
|
|
2156
|
+
if (!name) continue;
|
|
2157
|
+
if (!FIELD_NAME_RE.test(name)) {
|
|
2158
|
+
problems.push(
|
|
2159
|
+
`declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
|
|
2160
|
+
);
|
|
2308
2161
|
continue;
|
|
2309
2162
|
}
|
|
2310
|
-
const
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
const prospective = [];
|
|
2315
|
-
for (const [claim, only] of prospectiveOnly) {
|
|
2316
|
-
if (only) prospective.push(claim);
|
|
2317
|
-
else paths.push(claim);
|
|
2163
|
+
const key = name.toLowerCase();
|
|
2164
|
+
if (seen.has(key)) continue;
|
|
2165
|
+
seen.add(key);
|
|
2166
|
+
fields.push(name);
|
|
2318
2167
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
function packageManagerUsage(text) {
|
|
2322
|
-
const counts = /* @__PURE__ */ new Map();
|
|
2323
|
-
for (const token of extractCodeTokens(text)) {
|
|
2324
|
-
for (const m of token.matchAll(/\b(pnpm|yarn|npm|bun)\s+(?:run\s+)?[A-Za-z-]/g)) {
|
|
2325
|
-
const pm = m[1];
|
|
2326
|
-
counts.set(pm, (counts.get(pm) ?? 0) + 1);
|
|
2327
|
-
}
|
|
2168
|
+
if (fields.length === 0 && problems.length === 0) {
|
|
2169
|
+
problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
|
|
2328
2170
|
}
|
|
2329
|
-
return
|
|
2171
|
+
return { fields, offset, problems };
|
|
2330
2172
|
}
|
|
2331
|
-
|
|
2332
|
-
"
|
|
2333
|
-
"CLAUDE.md",
|
|
2334
|
-
"PROJECT_CONTEXT.md",
|
|
2335
|
-
"DECISIONS.md",
|
|
2336
|
-
"GEMINI.md"
|
|
2337
|
-
];
|
|
2338
|
-
function extractDocRefs(text) {
|
|
2339
|
-
return KNOWN_DOC_REFS.filter((name) => text.includes(name));
|
|
2173
|
+
function hasField(block, name) {
|
|
2174
|
+
return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
|
|
2340
2175
|
}
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2176
|
+
function parseDecisionEntries(text) {
|
|
2177
|
+
const headings = [...text.matchAll(/^## .*$/gm)];
|
|
2178
|
+
const entries = [];
|
|
2179
|
+
for (let i = 0; i < headings.length; i++) {
|
|
2180
|
+
const h = headings[i];
|
|
2181
|
+
const m = /^## (D-(\d+))\b/.exec(h[0]);
|
|
2182
|
+
if (!m) continue;
|
|
2183
|
+
const offset = h.index ?? 0;
|
|
2184
|
+
const start = offset + h[0].length;
|
|
2185
|
+
const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
|
|
2186
|
+
entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end), offset });
|
|
2187
|
+
}
|
|
2188
|
+
return entries;
|
|
2347
2189
|
}
|
|
2348
|
-
function
|
|
2349
|
-
const
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2190
|
+
function checkIdSequence(file, entries) {
|
|
2191
|
+
const findings = [];
|
|
2192
|
+
const nextFree = Math.max(0, ...entries.map((e) => e.num)) + 1;
|
|
2193
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2194
|
+
const duplicated = /* @__PURE__ */ new Set();
|
|
2195
|
+
let prev;
|
|
2196
|
+
for (const entry of entries) {
|
|
2197
|
+
if (seen.has(entry.num)) {
|
|
2198
|
+
if (!duplicated.has(entry.num)) {
|
|
2199
|
+
duplicated.add(entry.num);
|
|
2200
|
+
findings.push({
|
|
2201
|
+
id: `${LENS_ID3}/duplicate-id:${file}:${entry.id}`,
|
|
2202
|
+
lens: LENS_ID3,
|
|
2203
|
+
tier: "gap",
|
|
2204
|
+
claim: `${file} carries more than one ${entry.id} entry`,
|
|
2205
|
+
evidence: [`${file}: ${entry.id} appears twice`],
|
|
2206
|
+
why: "Two decisions under one id cannot be cited, superseded, or dismissed unambiguously \u2014 append races collide exactly here.",
|
|
2207
|
+
action: `Rename the later entry to D-${String(nextFree).padStart(3, "0")} (the next free id).`,
|
|
2208
|
+
effort: "S",
|
|
2209
|
+
confidence: "high"
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
} else {
|
|
2213
|
+
seen.set(entry.num, entry);
|
|
2214
|
+
if (prev && entry.num < prev.num) {
|
|
2215
|
+
findings.push({
|
|
2216
|
+
id: `${LENS_ID3}/id-order:${file}:${entry.id}`,
|
|
2217
|
+
lens: LENS_ID3,
|
|
2218
|
+
tier: "gap",
|
|
2219
|
+
claim: `${file} lists ${entry.id} after ${prev.id} \u2014 ids out of append order`,
|
|
2220
|
+
evidence: [`${file}: ${prev.id} precedes ${entry.id}`],
|
|
2221
|
+
why: "An append-only record reads in id order; out-of-order ids make the newest decision hard to find and the next id hard to pick.",
|
|
2222
|
+
action: `Rename the out-of-order entry into sequence (next free id: D-${String(nextFree).padStart(3, "0")}).`,
|
|
2361
2223
|
effort: "S",
|
|
2362
2224
|
confidence: "high"
|
|
2363
|
-
})
|
|
2364
|
-
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
prev = entry;
|
|
2365
2228
|
}
|
|
2366
2229
|
}
|
|
2367
|
-
return
|
|
2230
|
+
return findings;
|
|
2368
2231
|
}
|
|
2369
|
-
function
|
|
2370
|
-
const
|
|
2371
|
-
const
|
|
2372
|
-
|
|
2373
|
-
const
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2232
|
+
function checkFormatFields(file, entries, today, declaredFields, markerOffset) {
|
|
2233
|
+
const findings = [];
|
|
2234
|
+
for (const entry of entries) {
|
|
2235
|
+
const bound = entry.offset >= markerOffset;
|
|
2236
|
+
for (const field of bound ? declaredFields : []) {
|
|
2237
|
+
if (hasField(entry.block, field)) continue;
|
|
2238
|
+
findings.push({
|
|
2239
|
+
id: `${LENS_ID3}/field-missing:${file}:${entry.id}:${field}`,
|
|
2240
|
+
lens: LENS_ID3,
|
|
2241
|
+
tier: "gap",
|
|
2242
|
+
claim: `${file} ${entry.id} has no ${field}: field`,
|
|
2243
|
+
evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
|
|
2244
|
+
why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
|
|
2245
|
+
action: `Add a ${field}: line to ${entry.id}.`,
|
|
2246
|
+
effort: "S",
|
|
2247
|
+
confidence: "high"
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
if (bound && !/Scope[\s*]*:/.test(entry.block)) {
|
|
2251
|
+
findings.push({
|
|
2252
|
+
id: `${LENS_ID3}/scope-missing:${file}:${entry.id}`,
|
|
2253
|
+
lens: LENS_ID3,
|
|
2254
|
+
tier: "gap",
|
|
2255
|
+
claim: `${file} ${entry.id} has no Scope: field`,
|
|
2256
|
+
evidence: [`${file}: ${entry.id}`],
|
|
2257
|
+
why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
|
|
2258
|
+
action: "Add a Scope: line naming what the decision binds.",
|
|
2259
|
+
effort: "S",
|
|
2260
|
+
confidence: "high"
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
|
|
2264
|
+
if (revisit && revisit[1] < today) {
|
|
2265
|
+
findings.push({
|
|
2266
|
+
id: `${LENS_ID3}/revisit-due:${file}:${entry.id}`,
|
|
2267
|
+
lens: LENS_ID3,
|
|
2268
|
+
tier: "gap",
|
|
2269
|
+
claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
|
|
2270
|
+
evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
|
|
2271
|
+
why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
|
|
2272
|
+
action: "Re-evaluate the decision: supersede it or move the Revisit date.",
|
|
2273
|
+
effort: "S",
|
|
2274
|
+
confidence: "high"
|
|
2275
|
+
});
|
|
2387
2276
|
}
|
|
2388
2277
|
}
|
|
2389
|
-
return
|
|
2390
|
-
}
|
|
2391
|
-
function compareLayout(baseline, fresh) {
|
|
2392
|
-
const now = new Set(fresh.tree.dirs.map((d) => d.name));
|
|
2393
|
-
return baseline.tree.dirs.filter((d) => !now.has(d.name)).map(
|
|
2394
|
-
(d) => finding2({
|
|
2395
|
-
id: `${LENS_ID3}/dir-gone-${d.name}`,
|
|
2396
|
-
tier: "gap",
|
|
2397
|
-
claim: `Top-level \`${d.name}/\` from the baseline no longer exists \u2014 the repo map may be stale`,
|
|
2398
|
-
evidence: [`${d.name}/`],
|
|
2399
|
-
why: "A stale map sends agents (and people) to paths that are gone.",
|
|
2400
|
-
action: "Update the repo map and refresh the baseline.",
|
|
2401
|
-
effort: "S",
|
|
2402
|
-
confidence: "medium"
|
|
2403
|
-
})
|
|
2404
|
-
);
|
|
2278
|
+
return findings;
|
|
2405
2279
|
}
|
|
2406
|
-
var
|
|
2280
|
+
var stateFreshnessLens = {
|
|
2407
2281
|
id: LENS_ID3,
|
|
2408
2282
|
version: "1",
|
|
2409
|
-
title: "
|
|
2283
|
+
title: "State freshness",
|
|
2410
2284
|
kind: "truth",
|
|
2411
2285
|
async run(ctx) {
|
|
2286
|
+
const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
|
|
2412
2287
|
const findings = [];
|
|
2413
|
-
const disclosures = [];
|
|
2414
|
-
const
|
|
2415
|
-
const
|
|
2416
|
-
|
|
2417
|
-
const
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
included: includedExtra
|
|
2421
|
-
} = await listInstructionFiles(root, facts, config.instructions);
|
|
2422
|
-
if (!files.length) {
|
|
2423
|
-
findings.push(
|
|
2424
|
-
finding2({
|
|
2425
|
-
id: `${LENS_ID3}/no-contract`,
|
|
2426
|
-
tier: "gap",
|
|
2427
|
-
claim: "No agent instruction files exist (AGENTS.md or equivalents)",
|
|
2428
|
-
evidence: ["AGENTS.md (missing)"],
|
|
2429
|
-
why: "Every agent in this repo works from generic priors instead of the project's rules.",
|
|
2430
|
-
action: "Run `etymd init` to scaffold one.",
|
|
2431
|
-
effort: "M",
|
|
2432
|
-
confidence: "high"
|
|
2433
|
-
})
|
|
2434
|
-
);
|
|
2435
|
-
}
|
|
2436
|
-
const knownScripts = new Set(Object.keys(facts.commands.raw));
|
|
2437
|
-
for (const pkg of facts.packages) {
|
|
2438
|
-
const pkgJson = await readJson(
|
|
2439
|
-
path.join(root, pkg.dir, "package.json")
|
|
2440
|
-
);
|
|
2441
|
-
for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
|
|
2288
|
+
const disclosures = [...ctx.config?.problems ?? []];
|
|
2289
|
+
const outOfScope = [];
|
|
2290
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2291
|
+
const stateArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "state" && a.exists);
|
|
2292
|
+
const decisionArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "decisions" && a.exists);
|
|
2293
|
+
if (stateArtifacts.length === 0 && decisionArtifacts.length === 0) {
|
|
2294
|
+
disclosures.push("No state or decisions artifacts detected \u2014 nothing to check.");
|
|
2442
2295
|
}
|
|
2443
|
-
const
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
}
|
|
2450
|
-
return false;
|
|
2451
|
-
};
|
|
2452
|
-
const binResolves = async (name) => {
|
|
2453
|
-
const bases = [root, ...facts.packages.map((p) => path.join(root, p.dir))];
|
|
2454
|
-
for (const base of bases) {
|
|
2455
|
-
if (await pathExists(path.join(base, "node_modules", ".bin", name))) return true;
|
|
2456
|
-
}
|
|
2457
|
-
return false;
|
|
2458
|
-
};
|
|
2459
|
-
const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
|
|
2460
|
-
let totalFilteredSkipped = 0;
|
|
2461
|
-
let binaryResolved = 0;
|
|
2462
|
-
let unverifiableCommands = 0;
|
|
2463
|
-
let gitignoredSkipped = 0;
|
|
2464
|
-
let prospectiveSkipped = 0;
|
|
2465
|
-
let placeholderSkipped = 0;
|
|
2466
|
-
for (const file of files) {
|
|
2467
|
-
const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
|
|
2468
|
-
totalFilteredSkipped += filteredSkipped;
|
|
2469
|
-
for (const [script, raw] of claimed) {
|
|
2470
|
-
if (knownScripts.has(script)) continue;
|
|
2471
|
-
if (await binResolves(script)) {
|
|
2472
|
-
binaryResolved += 1;
|
|
2473
|
-
continue;
|
|
2474
|
-
}
|
|
2475
|
-
if (!nodeModulesInstalled) {
|
|
2476
|
-
unverifiableCommands += 1;
|
|
2477
|
-
continue;
|
|
2478
|
-
}
|
|
2479
|
-
findings.push(
|
|
2480
|
-
finding2({
|
|
2481
|
-
id: `${LENS_ID3}/stale-command:${file.path}:${script}`,
|
|
2482
|
-
tier: "risk",
|
|
2483
|
-
claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
|
|
2484
|
-
evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
|
|
2485
|
-
why: "An agent following this instruction runs a command that fails \u2014 or silently skips the check it was meant to run.",
|
|
2486
|
-
action: "Update the instruction to the current script name (or restore the script).",
|
|
2487
|
-
effort: "S",
|
|
2488
|
-
confidence: "high"
|
|
2489
|
-
})
|
|
2490
|
-
);
|
|
2491
|
-
}
|
|
2492
|
-
const { paths, prospective, placeholder } = extractPathClaims(file.text);
|
|
2493
|
-
prospectiveSkipped += prospective.length;
|
|
2494
|
-
placeholderSkipped += placeholder.length;
|
|
2495
|
-
const missing = [];
|
|
2496
|
-
for (const claim of paths) {
|
|
2497
|
-
if (!await pathResolves(claim)) missing.push(claim);
|
|
2296
|
+
const freshness = ctx.facts.freshness;
|
|
2297
|
+
if (!freshness) {
|
|
2298
|
+
disclosures.push("Scan carried no freshness facts \u2014 staleness unchecked.");
|
|
2299
|
+
} else {
|
|
2300
|
+
for (const u of freshness.unverifiable) {
|
|
2301
|
+
disclosures.push(`Freshness of ${u.path} is unverifiable (${u.reason}) \u2014 not flagged.`);
|
|
2498
2302
|
}
|
|
2499
|
-
const
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
if (gitignored.has(claim)) {
|
|
2504
|
-
gitignoredSkipped += 1;
|
|
2505
|
-
continue;
|
|
2506
|
-
}
|
|
2507
|
-
if (pathFindings >= MAX_PATH_FINDINGS_PER_FILE) {
|
|
2303
|
+
for (const a of stateArtifacts) {
|
|
2304
|
+
const fact = freshness.artifacts.find((f) => f.artifactId === a.id);
|
|
2305
|
+
if (!fact || !freshness.repoLastCommit) continue;
|
|
2306
|
+
if (fact.dirty) {
|
|
2508
2307
|
disclosures.push(
|
|
2509
|
-
`${
|
|
2308
|
+
`${a.path} has uncommitted changes \u2014 modified since its last commit; treated fresh-now, not flagged.`
|
|
2510
2309
|
);
|
|
2511
|
-
|
|
2310
|
+
continue;
|
|
2512
2311
|
}
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
id: `${LENS_ID3}/stale-path:${file.path}:${claim}`,
|
|
2517
|
-
tier: "gap",
|
|
2518
|
-
claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
|
|
2519
|
-
evidence: [file.path, `missing: ${claim}`],
|
|
2520
|
-
why: "Agents navigate by these references; a dead path wastes a lookup and erodes trust in the rest of the file.",
|
|
2521
|
-
action: "Fix or remove the reference.",
|
|
2522
|
-
effort: "S",
|
|
2523
|
-
confidence: "medium"
|
|
2524
|
-
})
|
|
2312
|
+
if (!fact.commitsSince) continue;
|
|
2313
|
+
const gapDays = Math.floor(
|
|
2314
|
+
(Date.parse(freshness.repoLastCommit) - Date.parse(fact.lastCommit)) / MS_PER_DAY
|
|
2525
2315
|
);
|
|
2316
|
+
if (gapDays <= budgets.staleAfterDays) continue;
|
|
2317
|
+
const escalated = gapDays > budgets.staleAfterDays * 3;
|
|
2318
|
+
findings.push({
|
|
2319
|
+
id: `${LENS_ID3}/stale-state:${a.path}`,
|
|
2320
|
+
lens: LENS_ID3,
|
|
2321
|
+
tier: escalated ? "risk" : "gap",
|
|
2322
|
+
claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
|
|
2323
|
+
evidence: [
|
|
2324
|
+
`${a.path} last commit: ${fact.lastCommit}`,
|
|
2325
|
+
`repo last commit: ${freshness.repoLastCommit}`
|
|
2326
|
+
],
|
|
2327
|
+
why: escalated ? `Over three times the ${budgets.staleAfterDays}-day threshold while commits kept landing \u2014 every session starts from a picture of the project that is no longer true.` : `A state doc more than ${budgets.staleAfterDays} days behind continued commit traffic misleads every session that loads it.`,
|
|
2328
|
+
action: "Refresh the state doc (or record why it is still current).",
|
|
2329
|
+
effort: "S",
|
|
2330
|
+
confidence: "high"
|
|
2331
|
+
});
|
|
2526
2332
|
}
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2333
|
+
}
|
|
2334
|
+
for (const a of stateArtifacts) {
|
|
2335
|
+
const text = await readText(path.join(ctx.root, a.path));
|
|
2336
|
+
if (text === null) {
|
|
2337
|
+
disclosures.push(`${a.path} could not be read \u2014 unexamined, not clean.`);
|
|
2338
|
+
outOfScope.push(a.path);
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
if (text.length > budgets.maxChars) {
|
|
2342
|
+
findings.push({
|
|
2343
|
+
id: `${LENS_ID3}/state-over-budget:${a.path}`,
|
|
2344
|
+
lens: LENS_ID3,
|
|
2345
|
+
tier: "gap",
|
|
2346
|
+
claim: `${a.path} is ${text.length} chars \u2014 over the ${budgets.maxChars}-char state budget`,
|
|
2347
|
+
evidence: [`${a.path}: ${text.length} chars`],
|
|
2348
|
+
why: "Session-injection hooks truncate state around 10,000 chars \u2014 an over-budget state doc gets cut mid-sentence, and every session pays its full weight before the task begins.",
|
|
2349
|
+
action: "Trim to budget; move overflow into decisions/docs and keep a pointer.",
|
|
2350
|
+
effort: "M",
|
|
2351
|
+
confidence: "high"
|
|
2352
|
+
});
|
|
2546
2353
|
}
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
claim: `${file.path} references ${ref} \u2014 no such file exists`,
|
|
2554
|
-
evidence: [file.path, `missing: ${ref}`],
|
|
2555
|
-
why: "The pointer chain agents follow breaks at a file they can never read.",
|
|
2556
|
-
action: `Create ${ref} or remove the reference.`,
|
|
2557
|
-
effort: "S",
|
|
2558
|
-
confidence: "high"
|
|
2559
|
-
})
|
|
2354
|
+
}
|
|
2355
|
+
for (const a of decisionArtifacts) {
|
|
2356
|
+
const text = await readText(path.join(ctx.root, a.path));
|
|
2357
|
+
if (text === null) {
|
|
2358
|
+
disclosures.push(
|
|
2359
|
+
`${a.path} recognized as a decisions convention (directory) \u2014 age-exempt; per-entry format checks apply only to marker-carrying decisions files.`
|
|
2560
2360
|
);
|
|
2361
|
+
continue;
|
|
2561
2362
|
}
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
...compareArtifacts(ctx.baseline.facts, facts),
|
|
2567
|
-
...compareLayout(ctx.baseline.facts, facts)
|
|
2568
|
-
);
|
|
2569
|
-
if (baselineCarriesMachinePath(ctx.baseline)) {
|
|
2363
|
+
const entries = parseDecisionEntries(text);
|
|
2364
|
+
findings.push(...checkIdSequence(a.path, entries));
|
|
2365
|
+
const format = parseDecisionsFormat(text);
|
|
2366
|
+
if (!format) {
|
|
2570
2367
|
disclosures.push(
|
|
2571
|
-
`${
|
|
2368
|
+
`${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
|
|
2572
2369
|
);
|
|
2370
|
+
outOfScope.push(a.path);
|
|
2371
|
+
continue;
|
|
2573
2372
|
}
|
|
2574
|
-
|
|
2373
|
+
for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
|
|
2374
|
+
const exempt = entries.filter((e) => e.offset < format.offset);
|
|
2375
|
+
if (format.fields.length > 0) {
|
|
2575
2376
|
disclosures.push(
|
|
2576
|
-
|
|
2377
|
+
`${a.path} declares required entry fields: ${format.fields.join(", ")} \u2014 checked on every entry at or after the marker (etymd attaches no meaning to the names).`
|
|
2577
2378
|
);
|
|
2578
2379
|
}
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
disclosures.push(
|
|
2586
|
-
`${binaryResolved} command claim(s) are installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
|
|
2587
|
-
);
|
|
2588
|
-
}
|
|
2589
|
-
if (unverifiableCommands) {
|
|
2590
|
-
disclosures.push(
|
|
2591
|
-
`node_modules is not installed \u2014 ${unverifiableCommands} command claim(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
|
|
2592
|
-
);
|
|
2593
|
-
}
|
|
2594
|
-
if (gitignoredSkipped) {
|
|
2595
|
-
disclosures.push(
|
|
2596
|
-
`${gitignoredSkipped} missing path claim(s) are gitignored (machine-local, e.g. .env) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
|
|
2597
|
-
);
|
|
2598
|
-
}
|
|
2599
|
-
if (prospectiveSkipped) {
|
|
2600
|
-
disclosures.push(
|
|
2601
|
-
`${prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
|
|
2602
|
-
);
|
|
2603
|
-
}
|
|
2604
|
-
if (placeholderSkipped) {
|
|
2605
|
-
disclosures.push(
|
|
2606
|
-
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
2607
|
-
);
|
|
2608
|
-
}
|
|
2609
|
-
if (excluded.length) {
|
|
2610
|
-
const shown = excluded.slice(0, 5).join(", ");
|
|
2611
|
-
disclosures.push(
|
|
2612
|
-
`${excluded.length} instruction file(s) excluded by ${CONFIG_FILE} and NOT audited: ${shown}${excluded.length > 5 ? `, +${excluded.length - 5} more` : ""}.`
|
|
2613
|
-
);
|
|
2614
|
-
}
|
|
2615
|
-
if (includedExtra.length) {
|
|
2616
|
-
disclosures.push(
|
|
2617
|
-
`${includedExtra.length} extra instruction file(s) audited via ${CONFIG_FILE} \`instructions.include\`: ${includedExtra.slice(0, 5).join(", ")}${includedExtra.length > 5 ? `, +${includedExtra.length - 5} more` : ""}.`
|
|
2618
|
-
);
|
|
2380
|
+
if (exempt.length > 0) {
|
|
2381
|
+
disclosures.push(
|
|
2382
|
+
`${a.path}: ${exempt.length} entr${exempt.length === 1 ? "y" : "ies"} precede the format marker (${exempt[0]?.id}\u2026${exempt[exempt.length - 1]?.id}) \u2014 field presence not checked there (forward-only from the marker's position).`
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
findings.push(...checkFormatFields(a.path, entries, today, format.fields, format.offset));
|
|
2619
2386
|
}
|
|
2620
2387
|
disclosures.push(
|
|
2621
|
-
`
|
|
2388
|
+
`Thresholds: staleAfterDays ${budgets.staleAfterDays} (3x escalates to risk), state budget ${budgets.maxChars} chars (${budgets.staleAfterDays === DEFAULT_CONFIG.state.staleAfterDays && budgets.maxChars === DEFAULT_CONFIG.state.maxChars ? `defaults \u2014 override under \`state\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Decisions artifacts are exempt from age \u2014 old decisions are history, not defects.`
|
|
2622
2389
|
);
|
|
2623
2390
|
return {
|
|
2624
2391
|
lens: LENS_ID3,
|
|
2625
2392
|
version: "1",
|
|
2626
|
-
title: "
|
|
2393
|
+
title: "State freshness",
|
|
2627
2394
|
kind: "truth",
|
|
2628
2395
|
status: "ran",
|
|
2629
2396
|
disclosures,
|
|
2630
2397
|
findings,
|
|
2631
|
-
outOfScope:
|
|
2398
|
+
...outOfScope.length ? { outOfScope } : {}
|
|
2632
2399
|
};
|
|
2633
2400
|
}
|
|
2634
2401
|
};
|
|
2635
2402
|
|
|
2636
|
-
// src/lenses/
|
|
2637
|
-
|
|
2403
|
+
// src/lenses/instruction-truth/claims.ts
|
|
2404
|
+
init_detect();
|
|
2638
2405
|
init_util();
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
);
|
|
2406
|
+
async function listInstructionFiles(root, facts, scope) {
|
|
2407
|
+
const files = [];
|
|
2408
|
+
const add = async (rel) => {
|
|
2409
|
+
const text = await readText(path.join(root, rel));
|
|
2410
|
+
if (text !== null) files.push({ path: normalizeRelPath(rel), text });
|
|
2411
|
+
};
|
|
2412
|
+
const singleFileArtifacts = [
|
|
2413
|
+
"agents",
|
|
2414
|
+
"claude",
|
|
2415
|
+
"gemini",
|
|
2416
|
+
"copilot",
|
|
2417
|
+
"cursorrules",
|
|
2418
|
+
"cline",
|
|
2419
|
+
"windsurf"
|
|
2420
|
+
];
|
|
2421
|
+
for (const id of singleFileArtifacts) {
|
|
2422
|
+
const artifact = facts.artifacts.find((a) => a.id === id);
|
|
2423
|
+
if (artifact?.exists) await add(artifact.path);
|
|
2657
2424
|
}
|
|
2658
|
-
const
|
|
2659
|
-
if (
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2425
|
+
const rulesDir = path.join(root, ".cursor", "rules");
|
|
2426
|
+
if (await isDirectory(rulesDir)) {
|
|
2427
|
+
try {
|
|
2428
|
+
for (const entry of await promises.readdir(rulesDir)) {
|
|
2429
|
+
if (entry.endsWith(".md") || entry.endsWith(".mdc"))
|
|
2430
|
+
await add(path.join(".cursor/rules", entry));
|
|
2431
|
+
}
|
|
2432
|
+
} catch {
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
const skillsDir = path.join(root, ".claude", "skills");
|
|
2436
|
+
if (await isDirectory(skillsDir)) {
|
|
2437
|
+
try {
|
|
2438
|
+
for (const entry of await promises.readdir(skillsDir)) {
|
|
2439
|
+
const skill = path.join(".claude/skills", entry, "SKILL.md");
|
|
2440
|
+
await add(skill);
|
|
2441
|
+
}
|
|
2442
|
+
} catch {
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
const detected = new Set(files.map((f) => f.path));
|
|
2446
|
+
const included = [];
|
|
2447
|
+
for (const rel of await expandFileGlobs(root, scope?.include ?? [])) {
|
|
2448
|
+
if (detected.has(rel)) continue;
|
|
2449
|
+
const before = files.length;
|
|
2450
|
+
await add(rel);
|
|
2451
|
+
if (files.length > before) included.push(rel);
|
|
2452
|
+
}
|
|
2453
|
+
const exclude = scope?.exclude ?? [];
|
|
2454
|
+
if (!exclude.length) return { files, excluded: [], included };
|
|
2455
|
+
const kept = [];
|
|
2456
|
+
const excluded = [];
|
|
2457
|
+
for (const file of files) {
|
|
2458
|
+
if (matchesAnyGlob(file.path, exclude)) excluded.push(file.path);
|
|
2459
|
+
else kept.push(file);
|
|
2460
|
+
}
|
|
2461
|
+
return { files: kept, excluded, included };
|
|
2462
|
+
}
|
|
2463
|
+
async function listStateDocuments(root, facts) {
|
|
2464
|
+
const docs = [];
|
|
2465
|
+
for (const artifact of facts.artifacts) {
|
|
2466
|
+
if (artifact.kind !== "state" || !artifact.exists) continue;
|
|
2467
|
+
const text = await readText(path.join(root, artifact.path));
|
|
2468
|
+
if (text !== null) docs.push({ path: normalizeRelPath(artifact.path), text });
|
|
2469
|
+
}
|
|
2470
|
+
return docs;
|
|
2471
|
+
}
|
|
2472
|
+
function extractCodeTokens(text) {
|
|
2473
|
+
const tokens = [];
|
|
2474
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) tokens.push(m[1].trim());
|
|
2475
|
+
for (const block of text.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) {
|
|
2476
|
+
for (const line of block[1].split("\n")) {
|
|
2477
|
+
const trimmed = line.trim();
|
|
2478
|
+
if (trimmed && !trimmed.startsWith("#")) tokens.push(trimmed);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
return tokens;
|
|
2482
|
+
}
|
|
2483
|
+
var PM_BUILTINS = /* @__PURE__ */ new Set([
|
|
2484
|
+
"install",
|
|
2485
|
+
"i",
|
|
2486
|
+
"add",
|
|
2487
|
+
"remove",
|
|
2488
|
+
"rm",
|
|
2489
|
+
"up",
|
|
2490
|
+
"update",
|
|
2491
|
+
"upgrade",
|
|
2492
|
+
"dlx",
|
|
2493
|
+
"exec",
|
|
2494
|
+
"create",
|
|
2495
|
+
"init",
|
|
2496
|
+
"link",
|
|
2497
|
+
"unlink",
|
|
2498
|
+
"publish",
|
|
2499
|
+
"pack",
|
|
2500
|
+
"audit",
|
|
2501
|
+
"outdated",
|
|
2502
|
+
"why",
|
|
2503
|
+
"list",
|
|
2504
|
+
"ls",
|
|
2505
|
+
"view",
|
|
2506
|
+
"info",
|
|
2507
|
+
"config",
|
|
2508
|
+
"store",
|
|
2509
|
+
"import",
|
|
2510
|
+
"rebuild",
|
|
2511
|
+
"prune",
|
|
2512
|
+
"setup",
|
|
2513
|
+
"env",
|
|
2514
|
+
"bin",
|
|
2515
|
+
"root",
|
|
2516
|
+
"licenses",
|
|
2517
|
+
"patch",
|
|
2518
|
+
"approve-builds",
|
|
2519
|
+
"workspaces",
|
|
2520
|
+
"workspace",
|
|
2521
|
+
"cache",
|
|
2522
|
+
"version",
|
|
2523
|
+
"help"
|
|
2524
|
+
]);
|
|
2525
|
+
function extractCommandClaims(text) {
|
|
2526
|
+
const scripts = /* @__PURE__ */ new Map();
|
|
2527
|
+
let filteredSkipped = 0;
|
|
2528
|
+
for (const token of extractCodeTokens(text)) {
|
|
2529
|
+
for (const m of token.matchAll(
|
|
2530
|
+
/(?:^|&&\s*|\|\|\s*|;\s*|\|\s*|\$\s+|\(\s*)(pnpm|yarn|npm|bun)\s+(?:(run)\s+)?(-{0,2}[A-Za-z0-9:._@/[\]-]+)/g
|
|
2531
|
+
)) {
|
|
2532
|
+
const pm = m[1];
|
|
2533
|
+
const ranExplicit = Boolean(m[2]);
|
|
2534
|
+
const arg = m[3];
|
|
2535
|
+
if (arg.startsWith("-")) {
|
|
2536
|
+
filteredSkipped += 1;
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
if (pm === "npm" && !ranExplicit && arg !== "test" && arg !== "start") continue;
|
|
2540
|
+
if ((pm === "bun" || pm === "yarn" || pm === "pnpm") && !ranExplicit && PM_BUILTINS.has(arg))
|
|
2541
|
+
continue;
|
|
2542
|
+
if (ranExplicit && PM_BUILTINS.has(arg)) continue;
|
|
2543
|
+
scripts.set(arg, token);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
return { scripts, filteredSkipped };
|
|
2547
|
+
}
|
|
2548
|
+
var PATH_TOKEN_RE = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.$-]+)+\/?$/;
|
|
2549
|
+
var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2550
|
+
..."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(" ")
|
|
2551
|
+
]);
|
|
2552
|
+
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;
|
|
2553
|
+
var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
|
|
2554
|
+
var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
|
|
2555
|
+
function isPlaceholderClaim(token) {
|
|
2556
|
+
return token.split("/").some((seg) => PLACEHOLDER_PREFIX_RE.test(seg) || PLACEHOLDER_SEGMENTS.has(seg.toLowerCase()));
|
|
2557
|
+
}
|
|
2558
|
+
function claimContext(text, index) {
|
|
2559
|
+
const start = text.lastIndexOf("\n", index) + 1;
|
|
2560
|
+
const endRaw = text.indexOf("\n", index);
|
|
2561
|
+
const end = endRaw === -1 ? text.length : endRaw;
|
|
2562
|
+
const line = text.slice(start, end);
|
|
2563
|
+
if (!/^\s*(?:[-*+]|\d+[.)]|\|)/.test(line)) return line;
|
|
2564
|
+
let cursor = start;
|
|
2565
|
+
while (cursor > 0) {
|
|
2566
|
+
const prevEnd = cursor - 1;
|
|
2567
|
+
const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
2568
|
+
const prev = text.slice(prevStart, prevEnd);
|
|
2569
|
+
cursor = prevStart;
|
|
2570
|
+
if (!prev.trim()) continue;
|
|
2571
|
+
if (/^\s*(?:[-*+]|\d+[.)]|\|)/.test(prev)) continue;
|
|
2572
|
+
return `${prev}
|
|
2573
|
+
${line}`;
|
|
2664
2574
|
}
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2575
|
+
return line;
|
|
2576
|
+
}
|
|
2577
|
+
function extractPathClaims(text) {
|
|
2578
|
+
const prospectiveOnly = /* @__PURE__ */ new Map();
|
|
2579
|
+
const placeholder = /* @__PURE__ */ new Set();
|
|
2580
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
2581
|
+
const token = m[1].trim();
|
|
2582
|
+
if (token.includes(" ") || token.length > 120) continue;
|
|
2583
|
+
if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
|
|
2584
|
+
continue;
|
|
2585
|
+
if (token.includes("://") || token.startsWith("www.")) continue;
|
|
2586
|
+
if (/[*?{}<>|]/.test(token)) continue;
|
|
2587
|
+
if (token.includes("@")) continue;
|
|
2588
|
+
if (!PATH_TOKEN_RE.test(token)) continue;
|
|
2589
|
+
if (token.split("/").some((seg) => seg.startsWith("$"))) continue;
|
|
2590
|
+
const isDirClaim = token.endsWith("/");
|
|
2591
|
+
const ext = token.toLowerCase().match(/\.([a-z0-9]{1,8})$/)?.[1];
|
|
2592
|
+
if (!isDirClaim && !(ext && KNOWN_EXTENSIONS.has(ext))) continue;
|
|
2593
|
+
const claim = token.replace(/\/$/, "");
|
|
2594
|
+
if (isPlaceholderClaim(claim)) {
|
|
2595
|
+
placeholder.add(claim);
|
|
2673
2596
|
continue;
|
|
2674
2597
|
}
|
|
2675
|
-
const
|
|
2676
|
-
|
|
2677
|
-
seen.add(key);
|
|
2678
|
-
fields.push(name);
|
|
2598
|
+
const prospective2 = CREATION_CONTEXT_RE.test(claimContext(text, m.index ?? 0));
|
|
2599
|
+
prospectiveOnly.set(claim, (prospectiveOnly.get(claim) ?? true) && prospective2);
|
|
2679
2600
|
}
|
|
2680
|
-
|
|
2681
|
-
|
|
2601
|
+
const paths = [];
|
|
2602
|
+
const prospective = [];
|
|
2603
|
+
for (const [claim, only] of prospectiveOnly) {
|
|
2604
|
+
if (only) prospective.push(claim);
|
|
2605
|
+
else paths.push(claim);
|
|
2682
2606
|
}
|
|
2683
|
-
return {
|
|
2607
|
+
return { paths, prospective, placeholder: [...placeholder] };
|
|
2684
2608
|
}
|
|
2685
|
-
|
|
2686
|
-
|
|
2609
|
+
var LOCAL_REF_LEADINS = new Set(
|
|
2610
|
+
"decision decisions entry entries ruling rulings record records ledger id ids item items see per in of on at by to as is was are were the a an and or but not with under over from via vs than after before since between through against latest newest earliest only also still now supersedes superseded superseding amends amended extends extended cites cited citing adds added adding wrote written writes locked locks closed closes opened opens resolves resolved reopened recorded number numbers".split(" ")
|
|
2611
|
+
);
|
|
2612
|
+
function extractDecisionRefs(text) {
|
|
2613
|
+
const byNum = /* @__PURE__ */ new Map();
|
|
2614
|
+
for (const m of text.matchAll(/\bD-(\d{1,4})\b/g)) {
|
|
2615
|
+
const index = m.index ?? 0;
|
|
2616
|
+
if (index > 0 && /[-/_.]/.test(text[index - 1])) continue;
|
|
2617
|
+
const before = text.slice(Math.max(0, index - 48), index);
|
|
2618
|
+
const lead = /([A-Za-z][A-Za-z0-9'’-]*)[ \t]+$/.exec(before)?.[1];
|
|
2619
|
+
const local = !lead || LOCAL_REF_LEADINS.has(lead.toLowerCase());
|
|
2620
|
+
const num = Number(m[1]);
|
|
2621
|
+
const seen = byNum.get(num);
|
|
2622
|
+
if (!seen) byNum.set(num, { asWritten: m[0], local });
|
|
2623
|
+
else seen.local = seen.local || local;
|
|
2624
|
+
}
|
|
2625
|
+
const refs = /* @__PURE__ */ new Map();
|
|
2626
|
+
let qualifiedSkipped = 0;
|
|
2627
|
+
for (const [num, ref] of byNum) {
|
|
2628
|
+
if (ref.local) refs.set(num, ref.asWritten);
|
|
2629
|
+
else qualifiedSkipped += 1;
|
|
2630
|
+
}
|
|
2631
|
+
return { refs, qualifiedSkipped };
|
|
2687
2632
|
}
|
|
2688
|
-
function
|
|
2689
|
-
const
|
|
2690
|
-
const
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
const offset = h.index ?? 0;
|
|
2696
|
-
const start = offset + h[0].length;
|
|
2697
|
-
const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
|
|
2698
|
-
entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end), offset });
|
|
2633
|
+
function packageManagerUsage(text) {
|
|
2634
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2635
|
+
for (const token of extractCodeTokens(text)) {
|
|
2636
|
+
for (const m of token.matchAll(/\b(pnpm|yarn|npm|bun)\s+(?:run\s+)?[A-Za-z-]/g)) {
|
|
2637
|
+
const pm = m[1];
|
|
2638
|
+
counts.set(pm, (counts.get(pm) ?? 0) + 1);
|
|
2639
|
+
}
|
|
2699
2640
|
}
|
|
2700
|
-
return
|
|
2641
|
+
return counts;
|
|
2701
2642
|
}
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2643
|
+
var KNOWN_DOC_REFS = [
|
|
2644
|
+
"AGENTS.md",
|
|
2645
|
+
"CLAUDE.md",
|
|
2646
|
+
"PROJECT_CONTEXT.md",
|
|
2647
|
+
"DECISIONS.md",
|
|
2648
|
+
"GEMINI.md"
|
|
2649
|
+
];
|
|
2650
|
+
function extractDocRefs(text) {
|
|
2651
|
+
return KNOWN_DOC_REFS.filter((name) => text.includes(name));
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// src/lenses/instruction-truth/lens.ts
|
|
2655
|
+
var LENS_ID4 = "instruction-truth";
|
|
2656
|
+
var MAX_PATH_FINDINGS_PER_FILE = 15;
|
|
2657
|
+
function finding2(partial) {
|
|
2658
|
+
return { lens: LENS_ID4, ...partial };
|
|
2659
|
+
}
|
|
2660
|
+
function compareCommands(baseline, fresh) {
|
|
2661
|
+
const out = [];
|
|
2662
|
+
for (const role of ["test", "lint", "typecheck", "format", "formatCheck", "build"]) {
|
|
2663
|
+
const before = baseline.commands[role];
|
|
2664
|
+
if (before && !(before in fresh.commands.raw)) {
|
|
2665
|
+
out.push(
|
|
2666
|
+
finding2({
|
|
2667
|
+
id: `${LENS_ID4}/command-gone-${role}`,
|
|
2668
|
+
tier: "risk",
|
|
2669
|
+
claim: `Documented ${role} command \`${before}\` no longer exists in package.json`,
|
|
2670
|
+
evidence: ["package.json"],
|
|
2671
|
+
why: "Every doc, hook, and agent instruction naming it now fails or silently skips a check.",
|
|
2672
|
+
action: "Re-point the contract/hooks at the renamed script, then refresh the baseline.",
|
|
2720
2673
|
effort: "S",
|
|
2721
2674
|
confidence: "high"
|
|
2722
|
-
})
|
|
2723
|
-
|
|
2724
|
-
}
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2675
|
+
})
|
|
2676
|
+
);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
return out;
|
|
2680
|
+
}
|
|
2681
|
+
function compareArtifacts(baseline, fresh) {
|
|
2682
|
+
const out = [];
|
|
2683
|
+
const freshById = new Map(fresh.artifacts.map((a) => [a.id, a]));
|
|
2684
|
+
for (const a of baseline.artifacts) {
|
|
2685
|
+
const now = freshById.get(a.id);
|
|
2686
|
+
if (a.exists && now && !now.exists) {
|
|
2687
|
+
out.push(
|
|
2688
|
+
finding2({
|
|
2689
|
+
id: `${LENS_ID4}/artifact-gone-${a.id}`,
|
|
2730
2690
|
tier: "gap",
|
|
2731
|
-
claim: `${
|
|
2732
|
-
evidence: [
|
|
2733
|
-
why: "
|
|
2734
|
-
action:
|
|
2691
|
+
claim: `${a.label} was present at baseline but is now missing`,
|
|
2692
|
+
evidence: [a.path],
|
|
2693
|
+
why: "Agents and docs still assume it exists; instructions referring to it now mislead.",
|
|
2694
|
+
action: "Restore it or update the contract and refresh the baseline.",
|
|
2735
2695
|
effort: "S",
|
|
2736
2696
|
confidence: "high"
|
|
2737
|
-
})
|
|
2738
|
-
|
|
2739
|
-
prev = entry;
|
|
2697
|
+
})
|
|
2698
|
+
);
|
|
2740
2699
|
}
|
|
2741
2700
|
}
|
|
2742
|
-
return
|
|
2701
|
+
return out;
|
|
2743
2702
|
}
|
|
2744
|
-
function
|
|
2745
|
-
const
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
effort: "S",
|
|
2759
|
-
confidence: "high"
|
|
2760
|
-
});
|
|
2761
|
-
}
|
|
2762
|
-
if (bound && !/Scope[\s*]*:/.test(entry.block)) {
|
|
2763
|
-
findings.push({
|
|
2764
|
-
id: `${LENS_ID4}/scope-missing:${file}:${entry.id}`,
|
|
2765
|
-
lens: LENS_ID4,
|
|
2766
|
-
tier: "gap",
|
|
2767
|
-
claim: `${file} ${entry.id} has no Scope: field`,
|
|
2768
|
-
evidence: [`${file}: ${entry.id}`],
|
|
2769
|
-
why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
|
|
2770
|
-
action: "Add a Scope: line naming what the decision binds.",
|
|
2771
|
-
effort: "S",
|
|
2772
|
-
confidence: "high"
|
|
2773
|
-
});
|
|
2774
|
-
}
|
|
2775
|
-
const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
|
|
2776
|
-
if (revisit && revisit[1] < today) {
|
|
2777
|
-
findings.push({
|
|
2778
|
-
id: `${LENS_ID4}/revisit-due:${file}:${entry.id}`,
|
|
2779
|
-
lens: LENS_ID4,
|
|
2780
|
-
tier: "gap",
|
|
2781
|
-
claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
|
|
2782
|
-
evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
|
|
2783
|
-
why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
|
|
2784
|
-
action: "Re-evaluate the decision: supersede it or move the Revisit date.",
|
|
2785
|
-
effort: "S",
|
|
2786
|
-
confidence: "high"
|
|
2787
|
-
});
|
|
2788
|
-
}
|
|
2789
|
-
}
|
|
2790
|
-
return findings;
|
|
2703
|
+
function compareLayout(baseline, fresh) {
|
|
2704
|
+
const now = new Set(fresh.tree.dirs.map((d) => d.name));
|
|
2705
|
+
return baseline.tree.dirs.filter((d) => !now.has(d.name)).map(
|
|
2706
|
+
(d) => finding2({
|
|
2707
|
+
id: `${LENS_ID4}/dir-gone-${d.name}`,
|
|
2708
|
+
tier: "gap",
|
|
2709
|
+
claim: `Top-level \`${d.name}/\` from the baseline no longer exists \u2014 the repo map may be stale`,
|
|
2710
|
+
evidence: [`${d.name}/`],
|
|
2711
|
+
why: "A stale map sends agents (and people) to paths that are gone.",
|
|
2712
|
+
action: "Update the repo map and refresh the baseline.",
|
|
2713
|
+
effort: "S",
|
|
2714
|
+
confidence: "medium"
|
|
2715
|
+
})
|
|
2716
|
+
);
|
|
2791
2717
|
}
|
|
2792
|
-
var
|
|
2718
|
+
var instructionTruthLens = {
|
|
2793
2719
|
id: LENS_ID4,
|
|
2794
2720
|
version: "1",
|
|
2795
|
-
title: "
|
|
2721
|
+
title: "Instruction truth",
|
|
2796
2722
|
kind: "truth",
|
|
2797
2723
|
async run(ctx) {
|
|
2798
|
-
const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
|
|
2799
2724
|
const findings = [];
|
|
2800
|
-
const disclosures = [
|
|
2801
|
-
const
|
|
2802
|
-
const
|
|
2803
|
-
|
|
2804
|
-
const
|
|
2805
|
-
|
|
2806
|
-
|
|
2725
|
+
const disclosures = [];
|
|
2726
|
+
const { facts, root } = ctx;
|
|
2727
|
+
const config = ctx.config?.config ?? DEFAULT_CONFIG;
|
|
2728
|
+
disclosures.push(...ctx.config?.problems ?? []);
|
|
2729
|
+
const {
|
|
2730
|
+
files,
|
|
2731
|
+
excluded,
|
|
2732
|
+
included: includedExtra
|
|
2733
|
+
} = await listInstructionFiles(root, facts, config.instructions);
|
|
2734
|
+
if (!files.length) {
|
|
2735
|
+
findings.push(
|
|
2736
|
+
finding2({
|
|
2737
|
+
id: `${LENS_ID4}/no-contract`,
|
|
2738
|
+
tier: "gap",
|
|
2739
|
+
claim: "No agent instruction files exist (AGENTS.md or equivalents)",
|
|
2740
|
+
evidence: ["AGENTS.md (missing)"],
|
|
2741
|
+
why: "Every agent in this repo works from generic priors instead of the project's rules.",
|
|
2742
|
+
action: "Run `etymd init` to scaffold one.",
|
|
2743
|
+
effort: "M",
|
|
2744
|
+
confidence: "high"
|
|
2745
|
+
})
|
|
2746
|
+
);
|
|
2807
2747
|
}
|
|
2808
|
-
const
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2748
|
+
const knownScripts = new Set(Object.keys(facts.commands.raw));
|
|
2749
|
+
for (const pkg of facts.packages) {
|
|
2750
|
+
const pkgJson = await readJson(
|
|
2751
|
+
path.join(root, pkg.dir, "package.json")
|
|
2752
|
+
);
|
|
2753
|
+
for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
|
|
2754
|
+
}
|
|
2755
|
+
const pathResolves = async (claim) => {
|
|
2756
|
+
const bases = [root, ...facts.packages.map((p) => path.join(root, p.dir))];
|
|
2757
|
+
for (const base of bases) {
|
|
2758
|
+
if (await pathExists(path.join(base, claim))) return true;
|
|
2759
|
+
if (await pathExists(path.join(base, "src", claim))) return true;
|
|
2760
|
+
if (await pathExists(path.join(base, "scripts", claim))) return true;
|
|
2814
2761
|
}
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2762
|
+
return false;
|
|
2763
|
+
};
|
|
2764
|
+
const binResolves = async (name) => {
|
|
2765
|
+
const bases = [root, ...facts.packages.map((p) => path.join(root, p.dir))];
|
|
2766
|
+
for (const base of bases) {
|
|
2767
|
+
if (await pathExists(path.join(base, "node_modules", ".bin", name))) return true;
|
|
2768
|
+
}
|
|
2769
|
+
return false;
|
|
2770
|
+
};
|
|
2771
|
+
const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
|
|
2772
|
+
const manifestExists = await pathExists(path.join(root, "package.json")) || facts.packages.length > 0;
|
|
2773
|
+
let totalFilteredSkipped = 0;
|
|
2774
|
+
let binaryResolved = 0;
|
|
2775
|
+
let unverifiableCommands = 0;
|
|
2776
|
+
let gitignoredSkipped = 0;
|
|
2777
|
+
let prospectiveSkipped = 0;
|
|
2778
|
+
let placeholderSkipped = 0;
|
|
2779
|
+
const auditClaims = async (file) => {
|
|
2780
|
+
const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
|
|
2781
|
+
totalFilteredSkipped += filteredSkipped;
|
|
2782
|
+
for (const [script, raw] of claimed) {
|
|
2783
|
+
if (knownScripts.has(script)) continue;
|
|
2784
|
+
if (await binResolves(script)) {
|
|
2785
|
+
binaryResolved += 1;
|
|
2786
|
+
continue;
|
|
2787
|
+
}
|
|
2788
|
+
if (!nodeModulesInstalled && manifestExists) {
|
|
2789
|
+
unverifiableCommands += 1;
|
|
2790
|
+
continue;
|
|
2791
|
+
}
|
|
2792
|
+
findings.push(
|
|
2793
|
+
finding2({
|
|
2794
|
+
id: `${LENS_ID4}/stale-command:${file.path}:${script}`,
|
|
2795
|
+
tier: "risk",
|
|
2796
|
+
claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
|
|
2797
|
+
evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
|
|
2798
|
+
why: "An agent following this instruction runs a command that fails \u2014 or silently skips the check it was meant to run.",
|
|
2799
|
+
action: "Update the instruction to the current script name (or restore the script).",
|
|
2800
|
+
effort: "S",
|
|
2801
|
+
confidence: "high"
|
|
2802
|
+
})
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
const { paths, prospective, placeholder } = extractPathClaims(file.text);
|
|
2806
|
+
prospectiveSkipped += prospective.length;
|
|
2807
|
+
placeholderSkipped += placeholder.length;
|
|
2808
|
+
const missing = [];
|
|
2809
|
+
for (const claim of paths) {
|
|
2810
|
+
if (!await pathResolves(claim)) missing.push(claim);
|
|
2811
|
+
}
|
|
2812
|
+
const ignoredOut = missing.length ? await git(root, ["check-ignore", ...missing]) : null;
|
|
2813
|
+
const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
|
|
2814
|
+
let pathFindings = 0;
|
|
2815
|
+
for (const claim of missing) {
|
|
2816
|
+
if (gitignored.has(claim)) {
|
|
2817
|
+
gitignoredSkipped += 1;
|
|
2818
|
+
continue;
|
|
2819
|
+
}
|
|
2820
|
+
if (pathFindings >= MAX_PATH_FINDINGS_PER_FILE) {
|
|
2819
2821
|
disclosures.push(
|
|
2820
|
-
`${
|
|
2822
|
+
`${file.path}: more than ${MAX_PATH_FINDINGS_PER_FILE} missing-path claims \u2014 truncated.`
|
|
2821
2823
|
);
|
|
2822
|
-
|
|
2824
|
+
break;
|
|
2823
2825
|
}
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
(
|
|
2826
|
+
pathFindings += 1;
|
|
2827
|
+
findings.push(
|
|
2828
|
+
finding2({
|
|
2829
|
+
id: `${LENS_ID4}/stale-path:${file.path}:${claim}`,
|
|
2830
|
+
tier: "gap",
|
|
2831
|
+
claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
|
|
2832
|
+
evidence: [file.path, `missing: ${claim}`],
|
|
2833
|
+
why: "Agents navigate by these references; a dead path wastes a lookup and erodes trust in the rest of the file.",
|
|
2834
|
+
action: "Fix or remove the reference.",
|
|
2835
|
+
effort: "S",
|
|
2836
|
+
confidence: "medium"
|
|
2837
|
+
})
|
|
2827
2838
|
);
|
|
2828
|
-
if (gapDays <= budgets.staleAfterDays) continue;
|
|
2829
|
-
const escalated = gapDays > budgets.staleAfterDays * 3;
|
|
2830
|
-
findings.push({
|
|
2831
|
-
id: `${LENS_ID4}/stale-state:${a.path}`,
|
|
2832
|
-
lens: LENS_ID4,
|
|
2833
|
-
tier: escalated ? "risk" : "gap",
|
|
2834
|
-
claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
|
|
2835
|
-
evidence: [
|
|
2836
|
-
`${a.path} last commit: ${fact.lastCommit}`,
|
|
2837
|
-
`repo last commit: ${freshness.repoLastCommit}`
|
|
2838
|
-
],
|
|
2839
|
-
why: escalated ? `Over three times the ${budgets.staleAfterDays}-day threshold while commits kept landing \u2014 every session starts from a picture of the project that is no longer true.` : `A state doc more than ${budgets.staleAfterDays} days behind continued commit traffic misleads every session that loads it.`,
|
|
2840
|
-
action: "Refresh the state doc (or record why it is still current).",
|
|
2841
|
-
effort: "S",
|
|
2842
|
-
confidence: "high"
|
|
2843
|
-
});
|
|
2844
2839
|
}
|
|
2845
|
-
}
|
|
2846
|
-
for (const
|
|
2847
|
-
|
|
2848
|
-
if (
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2840
|
+
};
|
|
2841
|
+
for (const file of files) {
|
|
2842
|
+
await auditClaims(file);
|
|
2843
|
+
if (facts.packageManager !== "unknown") {
|
|
2844
|
+
const usage = packageManagerUsage(file.text);
|
|
2845
|
+
const own = usage.get(facts.packageManager) ?? 0;
|
|
2846
|
+
for (const [pm, count] of usage) {
|
|
2847
|
+
if (pm === facts.packageManager || count < 2 || count <= own) continue;
|
|
2848
|
+
findings.push(
|
|
2849
|
+
finding2({
|
|
2850
|
+
id: `${LENS_ID4}/pm-conflict:${file.path}`,
|
|
2851
|
+
tier: "gap",
|
|
2852
|
+
claim: `${file.path} instructs \`${pm}\` (${count}\xD7) but the repo uses ${facts.packageManager}`,
|
|
2853
|
+
evidence: [file.path, `lockfile \u2192 ${facts.packageManager}`],
|
|
2854
|
+
why: "Mixed package-manager instructions produce divergent lockfiles and broken installs.",
|
|
2855
|
+
action: `Rewrite the commands for ${facts.packageManager}.`,
|
|
2856
|
+
effort: "S",
|
|
2857
|
+
confidence: "medium"
|
|
2858
|
+
})
|
|
2859
|
+
);
|
|
2860
|
+
break;
|
|
2861
|
+
}
|
|
2852
2862
|
}
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2863
|
+
for (const ref of extractDocRefs(file.text)) {
|
|
2864
|
+
if (await pathExists(path.join(root, ref))) continue;
|
|
2865
|
+
findings.push(
|
|
2866
|
+
finding2({
|
|
2867
|
+
id: `${LENS_ID4}/dangling-ref:${file.path}:${ref}`,
|
|
2868
|
+
tier: "gap",
|
|
2869
|
+
claim: `${file.path} references ${ref} \u2014 no such file exists`,
|
|
2870
|
+
evidence: [file.path, `missing: ${ref}`],
|
|
2871
|
+
why: "The pointer chain agents follow breaks at a file they can never read.",
|
|
2872
|
+
action: `Create ${ref} or remove the reference.`,
|
|
2873
|
+
effort: "S",
|
|
2874
|
+
confidence: "high"
|
|
2875
|
+
})
|
|
2876
|
+
);
|
|
2865
2877
|
}
|
|
2866
2878
|
}
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2879
|
+
const auditedPaths = new Set(files.map((f) => f.path));
|
|
2880
|
+
const stateDocs = await listStateDocuments(root, facts);
|
|
2881
|
+
let qualifiedRefsSkipped = 0;
|
|
2882
|
+
let unresolvableRefs = 0;
|
|
2883
|
+
let ledgerIds = null;
|
|
2884
|
+
const ledgerSources = [];
|
|
2885
|
+
if (stateDocs.length) {
|
|
2886
|
+
for (const artifact of facts.artifacts) {
|
|
2887
|
+
if (artifact.kind !== "decisions" || !artifact.exists) continue;
|
|
2888
|
+
const text = await readText(path.join(root, artifact.path));
|
|
2889
|
+
if (text === null) continue;
|
|
2890
|
+
const entries = parseDecisionEntries(text);
|
|
2891
|
+
if (!entries.length) continue;
|
|
2892
|
+
ledgerIds ??= /* @__PURE__ */ new Set();
|
|
2893
|
+
for (const entry of entries) ledgerIds.add(entry.num);
|
|
2894
|
+
ledgerSources.push(artifact.path);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
for (const doc of stateDocs) {
|
|
2898
|
+
if (!auditedPaths.has(doc.path)) await auditClaims(doc);
|
|
2899
|
+
const { refs, qualifiedSkipped } = extractDecisionRefs(doc.text);
|
|
2900
|
+
qualifiedRefsSkipped += qualifiedSkipped;
|
|
2901
|
+
if (!refs.size) continue;
|
|
2902
|
+
if (!ledgerIds) {
|
|
2903
|
+
unresolvableRefs += refs.size;
|
|
2873
2904
|
continue;
|
|
2874
2905
|
}
|
|
2875
|
-
const
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2906
|
+
for (const [num, asWritten] of refs) {
|
|
2907
|
+
if (ledgerIds.has(num)) continue;
|
|
2908
|
+
findings.push(
|
|
2909
|
+
finding2({
|
|
2910
|
+
id: `${LENS_ID4}/dead-decision-ref:${doc.path}:${asWritten}`,
|
|
2911
|
+
tier: "gap",
|
|
2912
|
+
claim: `${doc.path} cites ${asWritten} \u2014 no such entry exists in ${ledgerSources.join(", ")}`,
|
|
2913
|
+
evidence: [doc.path, `${ledgerSources.join(", ")}: no ${asWritten} entry`],
|
|
2914
|
+
why: "A state doc is read as ground truth on return; a citation the decision record cannot back sends readers to a ruling that was never written.",
|
|
2915
|
+
action: "Fix the reference \u2014 or record the missing decision.",
|
|
2916
|
+
effort: "S",
|
|
2917
|
+
confidence: "medium"
|
|
2918
|
+
})
|
|
2881
2919
|
);
|
|
2882
|
-
outOfScope.push(a.path);
|
|
2883
|
-
continue;
|
|
2884
2920
|
}
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2921
|
+
}
|
|
2922
|
+
if (ctx.baseline) {
|
|
2923
|
+
findings.push(
|
|
2924
|
+
...compareCommands(ctx.baseline.facts, facts),
|
|
2925
|
+
...compareArtifacts(ctx.baseline.facts, facts),
|
|
2926
|
+
...compareLayout(ctx.baseline.facts, facts)
|
|
2927
|
+
);
|
|
2928
|
+
if (baselineCarriesMachinePath(ctx.baseline)) {
|
|
2888
2929
|
disclosures.push(
|
|
2889
|
-
`${
|
|
2930
|
+
`${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.`
|
|
2890
2931
|
);
|
|
2891
2932
|
}
|
|
2892
|
-
if (
|
|
2933
|
+
if (ctx.baseline.packVersion !== PACK_VERSION) {
|
|
2893
2934
|
disclosures.push(
|
|
2894
|
-
|
|
2935
|
+
`Baseline was approved under pack v${ctx.baseline.packVersion}; current pack is v${PACK_VERSION}.`
|
|
2895
2936
|
);
|
|
2896
2937
|
}
|
|
2897
|
-
|
|
2938
|
+
} else {
|
|
2939
|
+
disclosures.push(
|
|
2940
|
+
"No committed baseline (.etymd/baseline.json) \u2014 drift over time is not measurable; run `etymd init` to approve one."
|
|
2941
|
+
);
|
|
2942
|
+
}
|
|
2943
|
+
if (binaryResolved) {
|
|
2944
|
+
disclosures.push(
|
|
2945
|
+
`${binaryResolved} command claim(s) are installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
|
|
2946
|
+
);
|
|
2947
|
+
}
|
|
2948
|
+
if (unverifiableCommands) {
|
|
2949
|
+
disclosures.push(
|
|
2950
|
+
`node_modules is not installed \u2014 ${unverifiableCommands} command claim(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
|
|
2951
|
+
);
|
|
2952
|
+
}
|
|
2953
|
+
if (gitignoredSkipped) {
|
|
2954
|
+
disclosures.push(
|
|
2955
|
+
`${gitignoredSkipped} missing path claim(s) are gitignored (machine-local, e.g. .env) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
|
|
2956
|
+
);
|
|
2957
|
+
}
|
|
2958
|
+
if (prospectiveSkipped) {
|
|
2959
|
+
disclosures.push(
|
|
2960
|
+
`${prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
|
|
2961
|
+
);
|
|
2962
|
+
}
|
|
2963
|
+
if (placeholderSkipped) {
|
|
2964
|
+
disclosures.push(
|
|
2965
|
+
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
2966
|
+
);
|
|
2967
|
+
}
|
|
2968
|
+
if (stateDocs.length) {
|
|
2969
|
+
disclosures.push(
|
|
2970
|
+
`Checked ${stateDocs.length} state document(s) for command, path, and decision-reference claims (same skip classes as instruction files); decision ids resolved against ${ledgerSources.length ? ledgerSources.join(", ") : "nothing \u2014 no decisions file with D-NNN entries"}.`
|
|
2971
|
+
);
|
|
2972
|
+
}
|
|
2973
|
+
if (qualifiedRefsSkipped) {
|
|
2974
|
+
disclosures.push(
|
|
2975
|
+
`${qualifiedRefsSkipped} decision reference(s) name another record (e.g. a fleet-level ledger) \u2014 not claims about this repo's decisions; skipped, not flagged.`
|
|
2976
|
+
);
|
|
2977
|
+
}
|
|
2978
|
+
if (unresolvableRefs) {
|
|
2979
|
+
disclosures.push(
|
|
2980
|
+
`${unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
if (excluded.length) {
|
|
2984
|
+
const shown = excluded.slice(0, 5).join(", ");
|
|
2985
|
+
disclosures.push(
|
|
2986
|
+
`${excluded.length} instruction file(s) excluded by ${CONFIG_FILE} and NOT audited: ${shown}${excluded.length > 5 ? `, +${excluded.length - 5} more` : ""}.`
|
|
2987
|
+
);
|
|
2988
|
+
}
|
|
2989
|
+
if (includedExtra.length) {
|
|
2990
|
+
disclosures.push(
|
|
2991
|
+
`${includedExtra.length} extra instruction file(s) audited via ${CONFIG_FILE} \`instructions.include\`: ${includedExtra.slice(0, 5).join(", ")}${includedExtra.length > 5 ? `, +${includedExtra.length - 5} more` : ""}.`
|
|
2992
|
+
);
|
|
2898
2993
|
}
|
|
2899
2994
|
disclosures.push(
|
|
2900
|
-
`
|
|
2995
|
+
`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.`
|
|
2901
2996
|
);
|
|
2902
2997
|
return {
|
|
2903
2998
|
lens: LENS_ID4,
|
|
2904
2999
|
version: "1",
|
|
2905
|
-
title: "
|
|
3000
|
+
title: "Instruction truth",
|
|
2906
3001
|
kind: "truth",
|
|
2907
3002
|
status: "ran",
|
|
2908
3003
|
disclosures,
|
|
2909
3004
|
findings,
|
|
2910
|
-
|
|
3005
|
+
outOfScope: excluded
|
|
2911
3006
|
};
|
|
2912
3007
|
}
|
|
2913
3008
|
};
|