instrlint 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/LICENSE +21 -0
- package/README.md +185 -0
- package/README.zh-TW.md +191 -0
- package/dist/cli.cjs +2943 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2916 -0
- package/dist/cli.js.map +1 -0
- package/package.json +74 -0
- package/skills/claude-code/SKILL.md +69 -0
- package/skills/codex/SKILL.md +38 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2916 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/run-command.ts
|
|
7
|
+
import { execSync } from "child_process";
|
|
8
|
+
import { basename as basename2 } from "path";
|
|
9
|
+
import chalk4 from "chalk";
|
|
10
|
+
|
|
11
|
+
// src/core/scanner.ts
|
|
12
|
+
import { existsSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
function detectAll(projectRoot) {
|
|
15
|
+
const detections = [];
|
|
16
|
+
const claudeDir = join(projectRoot, ".claude");
|
|
17
|
+
if (existsSync(claudeDir)) {
|
|
18
|
+
const rootInDir = join(claudeDir, "CLAUDE.md");
|
|
19
|
+
const rootAtRoot = join(projectRoot, "CLAUDE.md");
|
|
20
|
+
detections.push({
|
|
21
|
+
tool: "claude-code",
|
|
22
|
+
rootFilePath: existsSync(rootInDir) ? rootInDir : existsSync(rootAtRoot) ? rootAtRoot : null,
|
|
23
|
+
configDir: claudeDir
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const agentsDir = join(projectRoot, ".agents");
|
|
27
|
+
if (existsSync(agentsDir)) {
|
|
28
|
+
const agentsMd = join(projectRoot, "AGENTS.md");
|
|
29
|
+
detections.push({
|
|
30
|
+
tool: "codex",
|
|
31
|
+
rootFilePath: existsSync(agentsMd) ? agentsMd : null,
|
|
32
|
+
configDir: agentsDir
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const codexDir = join(projectRoot, ".codex");
|
|
36
|
+
if (existsSync(codexDir) && !detections.some((d) => d.tool === "codex")) {
|
|
37
|
+
const agentsMd = join(projectRoot, "AGENTS.md");
|
|
38
|
+
detections.push({
|
|
39
|
+
tool: "codex",
|
|
40
|
+
rootFilePath: existsSync(agentsMd) ? agentsMd : null,
|
|
41
|
+
configDir: codexDir
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const cursorDir = join(projectRoot, ".cursor");
|
|
45
|
+
const cursorRules = join(projectRoot, ".cursorrules");
|
|
46
|
+
if (existsSync(cursorDir) || existsSync(cursorRules)) {
|
|
47
|
+
detections.push({
|
|
48
|
+
tool: "cursor",
|
|
49
|
+
rootFilePath: existsSync(cursorRules) ? cursorRules : null,
|
|
50
|
+
configDir: existsSync(cursorDir) ? cursorDir : null
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return detections;
|
|
54
|
+
}
|
|
55
|
+
function detectByRootFile(projectRoot) {
|
|
56
|
+
const claudeMd = join(projectRoot, "CLAUDE.md");
|
|
57
|
+
if (existsSync(claudeMd)) {
|
|
58
|
+
return { tool: "claude-code", rootFilePath: claudeMd, configDir: null };
|
|
59
|
+
}
|
|
60
|
+
const agentsMd = join(projectRoot, "AGENTS.md");
|
|
61
|
+
if (existsSync(agentsMd)) {
|
|
62
|
+
return { tool: "codex", rootFilePath: agentsMd, configDir: null };
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function scanProject(projectRoot, forceTool) {
|
|
67
|
+
if (!existsSync(projectRoot)) {
|
|
68
|
+
throw new Error(`Project root does not exist: ${projectRoot}`);
|
|
69
|
+
}
|
|
70
|
+
if (forceTool != null) {
|
|
71
|
+
const valid = ["claude-code", "codex", "cursor"];
|
|
72
|
+
if (!valid.includes(forceTool)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Invalid tool: "${forceTool}". Must be one of: ${valid.join(", ")}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const tool = forceTool;
|
|
78
|
+
const detections2 = detectAll(projectRoot);
|
|
79
|
+
const match = detections2.find((d) => d.tool === tool);
|
|
80
|
+
return {
|
|
81
|
+
tool,
|
|
82
|
+
rootFilePath: match?.rootFilePath ?? null,
|
|
83
|
+
configDir: match?.configDir ?? null,
|
|
84
|
+
confidence: "high"
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const detections = detectAll(projectRoot);
|
|
88
|
+
if (detections.length === 0) {
|
|
89
|
+
const byFile = detectByRootFile(projectRoot);
|
|
90
|
+
if (byFile != null) {
|
|
91
|
+
return { ...byFile, confidence: "low" };
|
|
92
|
+
}
|
|
93
|
+
return { tool: "unknown", rootFilePath: null, configDir: null, confidence: "low" };
|
|
94
|
+
}
|
|
95
|
+
if (detections.length === 1) {
|
|
96
|
+
const d = detections[0];
|
|
97
|
+
return { tool: d.tool, rootFilePath: d.rootFilePath, configDir: d.configDir, confidence: "high" };
|
|
98
|
+
}
|
|
99
|
+
const first = detections[0];
|
|
100
|
+
return {
|
|
101
|
+
tool: first.tool,
|
|
102
|
+
rootFilePath: first.rootFilePath,
|
|
103
|
+
configDir: first.configDir,
|
|
104
|
+
confidence: "ambiguous"
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/adapters/claude-code.ts
|
|
109
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
|
|
110
|
+
import { basename, dirname, join as join2, relative } from "path";
|
|
111
|
+
|
|
112
|
+
// src/core/parser.ts
|
|
113
|
+
import { readFileSync } from "fs";
|
|
114
|
+
var KNOWN_KEYWORDS = /* @__PURE__ */ new Set([
|
|
115
|
+
"typescript",
|
|
116
|
+
"javascript",
|
|
117
|
+
"eslint",
|
|
118
|
+
"prettier",
|
|
119
|
+
"biome",
|
|
120
|
+
"react",
|
|
121
|
+
"vue",
|
|
122
|
+
"svelte",
|
|
123
|
+
"angular",
|
|
124
|
+
"next",
|
|
125
|
+
"nextjs",
|
|
126
|
+
"nuxt",
|
|
127
|
+
"jest",
|
|
128
|
+
"vitest",
|
|
129
|
+
"mocha",
|
|
130
|
+
"jasmine",
|
|
131
|
+
"playwright",
|
|
132
|
+
"cypress",
|
|
133
|
+
"postgresql",
|
|
134
|
+
"postgres",
|
|
135
|
+
"mysql",
|
|
136
|
+
"sqlite",
|
|
137
|
+
"mongodb",
|
|
138
|
+
"redis",
|
|
139
|
+
"docker",
|
|
140
|
+
"kubernetes",
|
|
141
|
+
"k8s",
|
|
142
|
+
"terraform",
|
|
143
|
+
"git",
|
|
144
|
+
"github",
|
|
145
|
+
"gitlab",
|
|
146
|
+
"npm",
|
|
147
|
+
"pnpm",
|
|
148
|
+
"yarn",
|
|
149
|
+
"bun",
|
|
150
|
+
"node",
|
|
151
|
+
"nodejs",
|
|
152
|
+
"deno",
|
|
153
|
+
"webpack",
|
|
154
|
+
"vite",
|
|
155
|
+
"esbuild",
|
|
156
|
+
"tsup",
|
|
157
|
+
"rollup",
|
|
158
|
+
"zod",
|
|
159
|
+
"prisma",
|
|
160
|
+
"drizzle",
|
|
161
|
+
"openai",
|
|
162
|
+
"anthropic",
|
|
163
|
+
"claude",
|
|
164
|
+
"commitlint",
|
|
165
|
+
"husky",
|
|
166
|
+
"lint-staged"
|
|
167
|
+
]);
|
|
168
|
+
var KEYWORD_REGEX = new RegExp(
|
|
169
|
+
`\\b(${[...KNOWN_KEYWORDS].join("|")})\\b`,
|
|
170
|
+
"gi"
|
|
171
|
+
);
|
|
172
|
+
var PATH_REGEX = /(?<!\w)(?:\.{1,2}\/|(?:src|tests?|dist|lib|docs?|config|scripts?|packages?)\/)[^\s,;`'")\]>]+/g;
|
|
173
|
+
var RULE_IMPERATIVE_WORDS = /\b(must|should|never|always|prefer|avoid|ensure|require|forbid|use|do not|don't)\b/i;
|
|
174
|
+
var RULE_NEGATION_PATTERN = /\b(not|don't|do not)\s+\w+/i;
|
|
175
|
+
var STRONG_IMPERATIVE = /\b(must|shall|always|never)\b/i;
|
|
176
|
+
function parseYamlFrontmatter(content) {
|
|
177
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/m.exec(content);
|
|
178
|
+
if (match == null) {
|
|
179
|
+
return { body: content };
|
|
180
|
+
}
|
|
181
|
+
const yaml = match[1] ?? "";
|
|
182
|
+
const body = match[2] ?? "";
|
|
183
|
+
const paths = extractYamlStringArray(yaml, "paths");
|
|
184
|
+
const globs = extractYamlStringArray(yaml, "globs");
|
|
185
|
+
return {
|
|
186
|
+
...paths != null ? { paths } : {},
|
|
187
|
+
...globs != null ? { globs } : {},
|
|
188
|
+
body
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function extractYamlStringArray(yaml, key) {
|
|
192
|
+
const blockMatch = new RegExp(
|
|
193
|
+
`^${key}:\\s*\\n((?:\\s+-\\s+.+\\n?)*)`,
|
|
194
|
+
"m"
|
|
195
|
+
).exec(yaml);
|
|
196
|
+
if (blockMatch != null) {
|
|
197
|
+
return (blockMatch[1] ?? "").split("\n").map(
|
|
198
|
+
(l) => l.replace(/^\s+-\s+/, "").replace(/["']/g, "").trim()
|
|
199
|
+
).filter((l) => l.length > 0);
|
|
200
|
+
}
|
|
201
|
+
const inlineMatch = new RegExp(`^${key}:\\s*\\[(.+)\\]`, "m").exec(yaml);
|
|
202
|
+
if (inlineMatch != null) {
|
|
203
|
+
return (inlineMatch[1] ?? "").split(",").map((s) => s.replace(/["']/g, "").trim()).filter((s) => s.length > 0);
|
|
204
|
+
}
|
|
205
|
+
return void 0;
|
|
206
|
+
}
|
|
207
|
+
function classifyLine(text, inCodeBlock, inHtmlComment) {
|
|
208
|
+
if (inCodeBlock) return "code";
|
|
209
|
+
if (inHtmlComment) return "comment";
|
|
210
|
+
const trimmed = text.trim();
|
|
211
|
+
if (trimmed.length === 0) return "blank";
|
|
212
|
+
if (/^#{1,6}\s/.test(trimmed)) return "heading";
|
|
213
|
+
if (trimmed.startsWith("```")) return "code";
|
|
214
|
+
if (/^<!--[\s\S]*-->$/.test(trimmed)) return "comment";
|
|
215
|
+
if (trimmed.startsWith("<!--")) return "comment";
|
|
216
|
+
if (isRule(trimmed)) return "rule";
|
|
217
|
+
return "other";
|
|
218
|
+
}
|
|
219
|
+
function isRule(text) {
|
|
220
|
+
const isList = text.startsWith("- ");
|
|
221
|
+
const body = isList ? text.slice(2) : text;
|
|
222
|
+
if (isList) {
|
|
223
|
+
if (RULE_IMPERATIVE_WORDS.test(body)) return true;
|
|
224
|
+
if (RULE_NEGATION_PATTERN.test(body)) return true;
|
|
225
|
+
} else {
|
|
226
|
+
if (STRONG_IMPERATIVE.test(body) && /^[A-Z]/.test(body)) return true;
|
|
227
|
+
if (RULE_IMPERATIVE_WORDS.test(body) && /^[A-Z][a-z]/.test(body))
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
function extractKeywords(text) {
|
|
233
|
+
const matches = text.matchAll(KEYWORD_REGEX);
|
|
234
|
+
const found = /* @__PURE__ */ new Set();
|
|
235
|
+
for (const m of matches) {
|
|
236
|
+
found.add(m[0].toLowerCase());
|
|
237
|
+
}
|
|
238
|
+
return [...found];
|
|
239
|
+
}
|
|
240
|
+
function extractPaths(text) {
|
|
241
|
+
const matches = text.matchAll(PATH_REGEX);
|
|
242
|
+
const found = [];
|
|
243
|
+
for (const m of matches) {
|
|
244
|
+
const cleaned = m[0].replace(/[.,;)>\]'"]+$/, "");
|
|
245
|
+
if (cleaned.length > 1) found.push(cleaned);
|
|
246
|
+
}
|
|
247
|
+
return [...new Set(found)];
|
|
248
|
+
}
|
|
249
|
+
function parseInstructionFile(filePath) {
|
|
250
|
+
const raw = readFileSync(filePath, "utf8");
|
|
251
|
+
const rawLines = raw.split(/\r?\n/);
|
|
252
|
+
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
|
|
253
|
+
rawLines.pop();
|
|
254
|
+
}
|
|
255
|
+
let inCodeBlock = false;
|
|
256
|
+
let inHtmlComment = false;
|
|
257
|
+
const lines = [];
|
|
258
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
259
|
+
const text = rawLines[i] ?? "";
|
|
260
|
+
const trimmed = text.trim();
|
|
261
|
+
const type = classifyLine(text, inCodeBlock, inHtmlComment);
|
|
262
|
+
if (!inHtmlComment && trimmed.startsWith("```")) {
|
|
263
|
+
inCodeBlock = !inCodeBlock;
|
|
264
|
+
}
|
|
265
|
+
if (!inCodeBlock) {
|
|
266
|
+
if (inHtmlComment) {
|
|
267
|
+
if (trimmed.includes("-->")) inHtmlComment = false;
|
|
268
|
+
} else if (trimmed.startsWith("<!--") && !trimmed.includes("-->")) {
|
|
269
|
+
inHtmlComment = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
lines.push({
|
|
273
|
+
lineNumber: i + 1,
|
|
274
|
+
text,
|
|
275
|
+
type,
|
|
276
|
+
keywords: extractKeywords(text),
|
|
277
|
+
referencedPaths: extractPaths(text)
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
path: filePath,
|
|
282
|
+
lines,
|
|
283
|
+
lineCount: lines.length,
|
|
284
|
+
// tokenCount and tokenMethod will be filled by the adapter/estimator
|
|
285
|
+
tokenCount: 0,
|
|
286
|
+
tokenMethod: "estimated"
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/detectors/token-estimator.ts
|
|
291
|
+
var encoder = null;
|
|
292
|
+
var initPromise = (async () => {
|
|
293
|
+
try {
|
|
294
|
+
const { getEncoding } = await import("js-tiktoken");
|
|
295
|
+
encoder = getEncoding("cl100k_base");
|
|
296
|
+
} catch {
|
|
297
|
+
process.stderr.write(
|
|
298
|
+
"[instrlint] Warning: js-tiktoken failed to load \u2014 falling back to character estimation\n"
|
|
299
|
+
);
|
|
300
|
+
encoder = null;
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
async function ensureInitialized() {
|
|
304
|
+
await initPromise;
|
|
305
|
+
}
|
|
306
|
+
var CJK_REGEX = /[\u3000-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
|
|
307
|
+
function cjkRatio(text) {
|
|
308
|
+
if (text.length === 0) return 0;
|
|
309
|
+
const matches = text.match(CJK_REGEX);
|
|
310
|
+
return (matches?.length ?? 0) / text.length;
|
|
311
|
+
}
|
|
312
|
+
function countTokens(text) {
|
|
313
|
+
if (text.length === 0) return { count: 0, method: "measured" };
|
|
314
|
+
if (encoder != null) {
|
|
315
|
+
try {
|
|
316
|
+
return { count: encoder.encode(text).length, method: "measured" };
|
|
317
|
+
} catch {
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return estimateFallback(text);
|
|
321
|
+
}
|
|
322
|
+
function estimateFallback(text) {
|
|
323
|
+
const ratio = cjkRatio(text);
|
|
324
|
+
const charsPerToken = 4 * (1 - ratio) + 2 * ratio;
|
|
325
|
+
return {
|
|
326
|
+
count: Math.ceil(text.length / charsPerToken),
|
|
327
|
+
method: "estimated"
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function estimateMcpTokens(config) {
|
|
331
|
+
if (config.toolCount != null) {
|
|
332
|
+
return { count: config.toolCount * 400, method: "estimated" };
|
|
333
|
+
}
|
|
334
|
+
return { count: 2500, method: "estimated" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/adapters/claude-code.ts
|
|
338
|
+
function withTokens(file) {
|
|
339
|
+
const raw = (() => {
|
|
340
|
+
try {
|
|
341
|
+
return readFileSync2(file.path, "utf8");
|
|
342
|
+
} catch {
|
|
343
|
+
return "";
|
|
344
|
+
}
|
|
345
|
+
})();
|
|
346
|
+
const { count, method } = countTokens(raw);
|
|
347
|
+
return { ...file, tokenCount: count, tokenMethod: method };
|
|
348
|
+
}
|
|
349
|
+
function safeParseFile(filePath) {
|
|
350
|
+
try {
|
|
351
|
+
const file = parseInstructionFile(filePath);
|
|
352
|
+
return withTokens(file);
|
|
353
|
+
} catch {
|
|
354
|
+
process.stderr.write(
|
|
355
|
+
`[instrlint] Warning: could not read ${filePath}, skipping
|
|
356
|
+
`
|
|
357
|
+
);
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function findRootFile(projectRoot) {
|
|
362
|
+
const candidates = [
|
|
363
|
+
join2(projectRoot, "CLAUDE.md"),
|
|
364
|
+
join2(projectRoot, ".claude", "CLAUDE.md")
|
|
365
|
+
];
|
|
366
|
+
return candidates.find(existsSync2) ?? null;
|
|
367
|
+
}
|
|
368
|
+
function loadRules(projectRoot) {
|
|
369
|
+
const rulesDir = join2(projectRoot, ".claude", "rules");
|
|
370
|
+
if (!existsSync2(rulesDir)) return [];
|
|
371
|
+
const rules = [];
|
|
372
|
+
let entries = [];
|
|
373
|
+
try {
|
|
374
|
+
entries = readdirSync(rulesDir).filter((f) => f.endsWith(".md"));
|
|
375
|
+
} catch {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
for (const filename of entries) {
|
|
379
|
+
const filePath = join2(rulesDir, filename);
|
|
380
|
+
try {
|
|
381
|
+
const raw = readFileSync2(filePath, "utf8");
|
|
382
|
+
const { paths, globs, body } = parseYamlFrontmatter(raw);
|
|
383
|
+
const baseFile = parseInstructionFile(filePath);
|
|
384
|
+
const { count, method } = countTokens(body);
|
|
385
|
+
rules.push({
|
|
386
|
+
...baseFile,
|
|
387
|
+
tokenCount: count,
|
|
388
|
+
tokenMethod: method,
|
|
389
|
+
...paths != null ? { paths } : {},
|
|
390
|
+
...globs != null ? { globs } : {}
|
|
391
|
+
});
|
|
392
|
+
} catch {
|
|
393
|
+
process.stderr.write(
|
|
394
|
+
`[instrlint] Warning: could not parse rule ${filePath}, skipping
|
|
395
|
+
`
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return rules;
|
|
400
|
+
}
|
|
401
|
+
function loadSkills(projectRoot) {
|
|
402
|
+
const skillsDir = join2(projectRoot, ".claude", "skills");
|
|
403
|
+
if (!existsSync2(skillsDir)) return [];
|
|
404
|
+
const skills = [];
|
|
405
|
+
let entries = [];
|
|
406
|
+
try {
|
|
407
|
+
entries = readdirSync(skillsDir);
|
|
408
|
+
} catch {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
for (const skillName of entries) {
|
|
412
|
+
const skillFile = join2(skillsDir, skillName, "SKILL.md");
|
|
413
|
+
if (!existsSync2(skillFile)) continue;
|
|
414
|
+
const parsed = safeParseFile(skillFile);
|
|
415
|
+
if (parsed != null) {
|
|
416
|
+
skills.push({ ...parsed, skillName });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return skills;
|
|
420
|
+
}
|
|
421
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
422
|
+
"node_modules",
|
|
423
|
+
"dist",
|
|
424
|
+
".git",
|
|
425
|
+
".claude",
|
|
426
|
+
".turbo",
|
|
427
|
+
"coverage"
|
|
428
|
+
]);
|
|
429
|
+
function findSubClaudeFiles(dir, projectRoot, depth = 0) {
|
|
430
|
+
if (depth > 10) return [];
|
|
431
|
+
const results = [];
|
|
432
|
+
let entries = [];
|
|
433
|
+
try {
|
|
434
|
+
entries = readdirSync(dir);
|
|
435
|
+
} catch {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
for (const entry of entries) {
|
|
439
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
440
|
+
const full = join2(dir, entry);
|
|
441
|
+
try {
|
|
442
|
+
const stat = statSync(full);
|
|
443
|
+
if (stat.isDirectory()) {
|
|
444
|
+
results.push(...findSubClaudeFiles(full, projectRoot, depth + 1));
|
|
445
|
+
} else if (entry === "CLAUDE.md") {
|
|
446
|
+
if (relative(projectRoot, full) === "CLAUDE.md") continue;
|
|
447
|
+
const parsed = safeParseFile(full);
|
|
448
|
+
if (parsed != null) results.push(parsed);
|
|
449
|
+
}
|
|
450
|
+
} catch {
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return results;
|
|
454
|
+
}
|
|
455
|
+
function parseMcpServers(projectRoot) {
|
|
456
|
+
const candidates = [
|
|
457
|
+
join2(projectRoot, ".claude", "settings.json"),
|
|
458
|
+
join2(projectRoot, ".claude", "settings.local.json")
|
|
459
|
+
];
|
|
460
|
+
const servers = [];
|
|
461
|
+
for (const settingsPath of candidates) {
|
|
462
|
+
if (!existsSync2(settingsPath)) continue;
|
|
463
|
+
try {
|
|
464
|
+
const raw = readFileSync2(settingsPath, "utf8");
|
|
465
|
+
const parsed = JSON.parse(raw);
|
|
466
|
+
if (parsed == null || typeof parsed !== "object" || !("mcpServers" in parsed)) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const mcpServers = parsed.mcpServers;
|
|
470
|
+
for (const [name, entry] of Object.entries(mcpServers)) {
|
|
471
|
+
const toolCount = Array.isArray(entry.tools) ? entry.tools.length : void 0;
|
|
472
|
+
const config = {
|
|
473
|
+
name,
|
|
474
|
+
estimatedTokens: 0,
|
|
475
|
+
...toolCount !== void 0 ? { toolCount } : {}
|
|
476
|
+
};
|
|
477
|
+
const { count } = estimateMcpTokens(config);
|
|
478
|
+
servers.push({ ...config, estimatedTokens: count });
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
process.stderr.write(
|
|
482
|
+
`[instrlint] Warning: could not parse MCP config in ${settingsPath}, skipping
|
|
483
|
+
`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return servers;
|
|
488
|
+
}
|
|
489
|
+
function loadClaudeCodeProject(projectRoot) {
|
|
490
|
+
const rootFilePath = findRootFile(projectRoot);
|
|
491
|
+
const rootFile = rootFilePath != null ? safeParseFile(rootFilePath) ?? emptyFile(rootFilePath) : emptyFile(join2(projectRoot, "CLAUDE.md"));
|
|
492
|
+
const rules = loadRules(projectRoot);
|
|
493
|
+
const skills = loadSkills(projectRoot);
|
|
494
|
+
const subFiles = findSubClaudeFiles(projectRoot, projectRoot);
|
|
495
|
+
const mcpServers = parseMcpServers(projectRoot);
|
|
496
|
+
return {
|
|
497
|
+
tool: "claude-code",
|
|
498
|
+
rootFile,
|
|
499
|
+
rules,
|
|
500
|
+
skills,
|
|
501
|
+
subFiles,
|
|
502
|
+
mcpServers
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
function emptyFile(path) {
|
|
506
|
+
return {
|
|
507
|
+
path,
|
|
508
|
+
lines: [],
|
|
509
|
+
lineCount: 0,
|
|
510
|
+
tokenCount: 0,
|
|
511
|
+
tokenMethod: "estimated"
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/adapters/codex.ts
|
|
516
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
|
|
517
|
+
import { join as join3 } from "path";
|
|
518
|
+
function safeParseFile2(filePath) {
|
|
519
|
+
try {
|
|
520
|
+
const raw = readFileSync3(filePath, "utf8");
|
|
521
|
+
const file = parseInstructionFile(filePath);
|
|
522
|
+
const { count, method } = countTokens(raw);
|
|
523
|
+
return { ...file, tokenCount: count, tokenMethod: method };
|
|
524
|
+
} catch {
|
|
525
|
+
process.stderr.write(
|
|
526
|
+
`[instrlint] Warning: could not read ${filePath}, skipping
|
|
527
|
+
`
|
|
528
|
+
);
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function emptyFile2(path) {
|
|
533
|
+
return {
|
|
534
|
+
path,
|
|
535
|
+
lines: [],
|
|
536
|
+
lineCount: 0,
|
|
537
|
+
tokenCount: 0,
|
|
538
|
+
tokenMethod: "estimated"
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function loadSkills2(projectRoot) {
|
|
542
|
+
const skillsDir = join3(projectRoot, ".agents", "skills");
|
|
543
|
+
if (!existsSync3(skillsDir)) return [];
|
|
544
|
+
const skills = [];
|
|
545
|
+
let entries = [];
|
|
546
|
+
try {
|
|
547
|
+
entries = readdirSync2(skillsDir);
|
|
548
|
+
} catch {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
for (const skillName of entries) {
|
|
552
|
+
const skillFile = join3(skillsDir, skillName, "SKILL.md");
|
|
553
|
+
if (!existsSync3(skillFile)) continue;
|
|
554
|
+
const parsed = safeParseFile2(skillFile);
|
|
555
|
+
if (parsed != null) {
|
|
556
|
+
skills.push({ ...parsed, skillName });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return skills;
|
|
560
|
+
}
|
|
561
|
+
function parseMcpFromToml(toml) {
|
|
562
|
+
const servers = [];
|
|
563
|
+
const sectionRe = /^\[mcp_servers\.([^\]]+)\]/;
|
|
564
|
+
const keyValueRe = /^(\w+)\s*=\s*(.+)$/;
|
|
565
|
+
let currentName = null;
|
|
566
|
+
let currentTools;
|
|
567
|
+
const flush = () => {
|
|
568
|
+
if (currentName != null) {
|
|
569
|
+
const config = {
|
|
570
|
+
name: currentName,
|
|
571
|
+
estimatedTokens: 0,
|
|
572
|
+
...currentTools !== void 0 ? { toolCount: currentTools.length } : {}
|
|
573
|
+
};
|
|
574
|
+
const { count } = estimateMcpTokens(config);
|
|
575
|
+
servers.push({ ...config, estimatedTokens: count });
|
|
576
|
+
}
|
|
577
|
+
currentName = null;
|
|
578
|
+
currentTools = void 0;
|
|
579
|
+
};
|
|
580
|
+
for (const line of toml.split("\n")) {
|
|
581
|
+
const trimmed = line.trim();
|
|
582
|
+
if (trimmed.startsWith("#") || trimmed === "") continue;
|
|
583
|
+
const sectionMatch = sectionRe.exec(trimmed);
|
|
584
|
+
if (sectionMatch != null) {
|
|
585
|
+
flush();
|
|
586
|
+
currentName = sectionMatch[1];
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (currentName == null) continue;
|
|
590
|
+
const kvMatch = keyValueRe.exec(trimmed);
|
|
591
|
+
if (kvMatch == null) continue;
|
|
592
|
+
const key = kvMatch[1];
|
|
593
|
+
const raw = kvMatch[2].trim();
|
|
594
|
+
if (key === "tools") {
|
|
595
|
+
const items = raw.match(/"([^"]+)"/g);
|
|
596
|
+
currentTools = items != null ? items.map((s) => s.slice(1, -1)) : [];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
flush();
|
|
600
|
+
return servers;
|
|
601
|
+
}
|
|
602
|
+
function loadMcpServers(projectRoot) {
|
|
603
|
+
const tomlPath = join3(projectRoot, ".codex", "config.toml");
|
|
604
|
+
if (!existsSync3(tomlPath)) return [];
|
|
605
|
+
try {
|
|
606
|
+
const raw = readFileSync3(tomlPath, "utf8");
|
|
607
|
+
return parseMcpFromToml(raw);
|
|
608
|
+
} catch {
|
|
609
|
+
process.stderr.write(
|
|
610
|
+
`[instrlint] Warning: could not parse MCP config in ${tomlPath}, skipping
|
|
611
|
+
`
|
|
612
|
+
);
|
|
613
|
+
return [];
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function loadCodexProject(projectRoot) {
|
|
617
|
+
const rootFilePath = join3(projectRoot, "AGENTS.md");
|
|
618
|
+
const rootFile = existsSync3(rootFilePath) ? safeParseFile2(rootFilePath) ?? emptyFile2(rootFilePath) : emptyFile2(rootFilePath);
|
|
619
|
+
const skills = loadSkills2(projectRoot);
|
|
620
|
+
const mcpServers = loadMcpServers(projectRoot);
|
|
621
|
+
return {
|
|
622
|
+
tool: "codex",
|
|
623
|
+
rootFile,
|
|
624
|
+
rules: [],
|
|
625
|
+
skills,
|
|
626
|
+
subFiles: [],
|
|
627
|
+
mcpServers
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/adapters/cursor.ts
|
|
632
|
+
import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
|
|
633
|
+
import { join as join4 } from "path";
|
|
634
|
+
function safeParseFile3(filePath) {
|
|
635
|
+
try {
|
|
636
|
+
const raw = readFileSync4(filePath, "utf8");
|
|
637
|
+
const file = parseInstructionFile(filePath);
|
|
638
|
+
const { count, method } = countTokens(raw);
|
|
639
|
+
return { ...file, tokenCount: count, tokenMethod: method };
|
|
640
|
+
} catch {
|
|
641
|
+
process.stderr.write(
|
|
642
|
+
`[instrlint] Warning: could not read ${filePath}, skipping
|
|
643
|
+
`
|
|
644
|
+
);
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function emptyFile3(path) {
|
|
649
|
+
return {
|
|
650
|
+
path,
|
|
651
|
+
lines: [],
|
|
652
|
+
lineCount: 0,
|
|
653
|
+
tokenCount: 0,
|
|
654
|
+
tokenMethod: "estimated"
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function findRootFile2(projectRoot) {
|
|
658
|
+
const cursorRules = join4(projectRoot, ".cursorrules");
|
|
659
|
+
if (existsSync4(cursorRules)) return cursorRules;
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
function loadRules2(projectRoot) {
|
|
663
|
+
const rulesDir = join4(projectRoot, ".cursor", "rules");
|
|
664
|
+
if (!existsSync4(rulesDir)) return [];
|
|
665
|
+
const rules = [];
|
|
666
|
+
let entries = [];
|
|
667
|
+
try {
|
|
668
|
+
entries = readdirSync3(rulesDir).filter((f) => f.endsWith(".md"));
|
|
669
|
+
} catch {
|
|
670
|
+
return [];
|
|
671
|
+
}
|
|
672
|
+
for (const filename of entries) {
|
|
673
|
+
const filePath = join4(rulesDir, filename);
|
|
674
|
+
try {
|
|
675
|
+
const raw = readFileSync4(filePath, "utf8");
|
|
676
|
+
const { globs, body } = parseYamlFrontmatter(raw);
|
|
677
|
+
const baseFile = parseInstructionFile(filePath);
|
|
678
|
+
const { count, method } = countTokens(body);
|
|
679
|
+
rules.push({
|
|
680
|
+
...baseFile,
|
|
681
|
+
tokenCount: count,
|
|
682
|
+
tokenMethod: method,
|
|
683
|
+
...globs != null ? { globs } : {}
|
|
684
|
+
});
|
|
685
|
+
} catch {
|
|
686
|
+
process.stderr.write(
|
|
687
|
+
`[instrlint] Warning: could not parse rule ${filePath}, skipping
|
|
688
|
+
`
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return rules;
|
|
693
|
+
}
|
|
694
|
+
function loadMcpServers2(projectRoot) {
|
|
695
|
+
const mcpPath = join4(projectRoot, ".cursor", "mcp.json");
|
|
696
|
+
if (!existsSync4(mcpPath)) return [];
|
|
697
|
+
try {
|
|
698
|
+
const raw = readFileSync4(mcpPath, "utf8");
|
|
699
|
+
const parsed = JSON.parse(raw);
|
|
700
|
+
if (parsed == null || typeof parsed !== "object" || !("mcpServers" in parsed)) {
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
const mcpServers = parsed.mcpServers;
|
|
704
|
+
return Object.entries(mcpServers).map(([name, entry]) => {
|
|
705
|
+
const toolCount = Array.isArray(entry.tools) ? entry.tools.length : void 0;
|
|
706
|
+
const config = {
|
|
707
|
+
name,
|
|
708
|
+
estimatedTokens: 0,
|
|
709
|
+
...toolCount !== void 0 ? { toolCount } : {}
|
|
710
|
+
};
|
|
711
|
+
const { count } = estimateMcpTokens(config);
|
|
712
|
+
return { ...config, estimatedTokens: count };
|
|
713
|
+
});
|
|
714
|
+
} catch {
|
|
715
|
+
process.stderr.write(
|
|
716
|
+
`[instrlint] Warning: could not parse MCP config in ${mcpPath}, skipping
|
|
717
|
+
`
|
|
718
|
+
);
|
|
719
|
+
return [];
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function loadCursorProject(projectRoot) {
|
|
723
|
+
const rootFilePath = findRootFile2(projectRoot);
|
|
724
|
+
const rootFile = rootFilePath != null ? safeParseFile3(rootFilePath) ?? emptyFile3(rootFilePath) : emptyFile3(join4(projectRoot, ".cursorrules"));
|
|
725
|
+
const rules = loadRules2(projectRoot);
|
|
726
|
+
const mcpServers = loadMcpServers2(projectRoot);
|
|
727
|
+
return {
|
|
728
|
+
tool: "cursor",
|
|
729
|
+
rootFile,
|
|
730
|
+
rules,
|
|
731
|
+
skills: [],
|
|
732
|
+
subFiles: [],
|
|
733
|
+
mcpServers
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/adapters/dispatch.ts
|
|
738
|
+
function loadProject(projectRoot, tool) {
|
|
739
|
+
switch (tool) {
|
|
740
|
+
case "claude-code":
|
|
741
|
+
return loadClaudeCodeProject(projectRoot);
|
|
742
|
+
case "codex":
|
|
743
|
+
return loadCodexProject(projectRoot);
|
|
744
|
+
case "cursor":
|
|
745
|
+
return loadCursorProject(projectRoot);
|
|
746
|
+
default:
|
|
747
|
+
return loadClaudeCodeProject(projectRoot);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/analyzers/budget.ts
|
|
752
|
+
var CONTEXT_WINDOW = 2e5;
|
|
753
|
+
var SYSTEM_PROMPT_TOKENS = 12e3;
|
|
754
|
+
var WARN_LINE_THRESHOLD = 200;
|
|
755
|
+
var CRITICAL_LINE_THRESHOLD = 400;
|
|
756
|
+
var WARN_BASELINE_PCT = 0.25;
|
|
757
|
+
var MCP_INFO_THRESHOLD = 1e4;
|
|
758
|
+
function sumTokens(items) {
|
|
759
|
+
if (items.length === 0) return { tokens: 0, method: "measured" };
|
|
760
|
+
const tokens = items.reduce((acc, f) => acc + f.tokenCount, 0);
|
|
761
|
+
const method = items.every((f) => f.tokenMethod === "measured") ? "measured" : "estimated";
|
|
762
|
+
return { tokens, method };
|
|
763
|
+
}
|
|
764
|
+
function analyzeBudget(instructions) {
|
|
765
|
+
const findings = [];
|
|
766
|
+
const rootFileTokens = instructions.rootFile.tokenCount;
|
|
767
|
+
const rootFileMethod = instructions.rootFile.tokenMethod;
|
|
768
|
+
const rootLines = instructions.rootFile.lineCount;
|
|
769
|
+
if (rootLines > CRITICAL_LINE_THRESHOLD) {
|
|
770
|
+
findings.push({
|
|
771
|
+
severity: "critical",
|
|
772
|
+
category: "budget",
|
|
773
|
+
file: instructions.rootFile.path,
|
|
774
|
+
messageKey: "budget.rootFileCritical",
|
|
775
|
+
messageParams: { lines: String(rootLines) },
|
|
776
|
+
suggestion: `Root instruction file is ${rootLines} lines \u2014 agent compliance drops significantly above 200 lines`,
|
|
777
|
+
autoFixable: false
|
|
778
|
+
});
|
|
779
|
+
} else if (rootLines > WARN_LINE_THRESHOLD) {
|
|
780
|
+
findings.push({
|
|
781
|
+
severity: "warning",
|
|
782
|
+
category: "budget",
|
|
783
|
+
file: instructions.rootFile.path,
|
|
784
|
+
messageKey: "budget.rootFileWarning",
|
|
785
|
+
messageParams: { lines: String(rootLines) },
|
|
786
|
+
suggestion: `Root instruction file is ${rootLines} lines (recommended: < 200)`,
|
|
787
|
+
autoFixable: false
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
const { tokens: rulesTokens, method: rulesMethod } = sumTokens(instructions.rules);
|
|
791
|
+
const { tokens: skillsTokens, method: skillsMethod } = sumTokens(instructions.skills);
|
|
792
|
+
const { tokens: subFilesTokens, method: subFilesMethod } = sumTokens(
|
|
793
|
+
instructions.subFiles
|
|
794
|
+
);
|
|
795
|
+
const mcpTokens = instructions.mcpServers.reduce(
|
|
796
|
+
(acc, s) => acc + s.estimatedTokens,
|
|
797
|
+
0
|
|
798
|
+
);
|
|
799
|
+
for (const server of instructions.mcpServers) {
|
|
800
|
+
if (server.estimatedTokens > MCP_INFO_THRESHOLD) {
|
|
801
|
+
findings.push({
|
|
802
|
+
severity: "info",
|
|
803
|
+
category: "budget",
|
|
804
|
+
file: ".claude/settings.json",
|
|
805
|
+
messageKey: "budget.mcpLargeServer",
|
|
806
|
+
messageParams: {
|
|
807
|
+
name: server.name,
|
|
808
|
+
tokens: server.estimatedTokens.toLocaleString("en")
|
|
809
|
+
},
|
|
810
|
+
suggestion: `MCP server '${server.name}' consumes ~${server.estimatedTokens.toLocaleString("en")} tokens`,
|
|
811
|
+
autoFixable: false
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const totalBaseline = SYSTEM_PROMPT_TOKENS + rootFileTokens + rulesTokens + skillsTokens + subFilesTokens + mcpTokens;
|
|
816
|
+
const availableTokens = CONTEXT_WINDOW - totalBaseline;
|
|
817
|
+
const pct2 = totalBaseline / CONTEXT_WINDOW;
|
|
818
|
+
if (pct2 > WARN_BASELINE_PCT) {
|
|
819
|
+
findings.push({
|
|
820
|
+
severity: "warning",
|
|
821
|
+
category: "budget",
|
|
822
|
+
file: instructions.rootFile.path,
|
|
823
|
+
messageKey: "budget.baselineHigh",
|
|
824
|
+
messageParams: { pct: Math.round(pct2 * 100).toString() },
|
|
825
|
+
suggestion: `Baseline context consumption is ${Math.round(pct2 * 100)}% of window`,
|
|
826
|
+
autoFixable: false
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
const tokenMethod = [rootFileMethod, rulesMethod, skillsMethod, subFilesMethod].every(
|
|
830
|
+
(m) => m === "measured"
|
|
831
|
+
) ? "measured" : "estimated";
|
|
832
|
+
const fileBreakdown = [
|
|
833
|
+
{ path: instructions.rootFile.path, tokenCount: rootFileTokens, tokenMethod: rootFileMethod },
|
|
834
|
+
...instructions.rules.map((r) => ({
|
|
835
|
+
path: r.path,
|
|
836
|
+
tokenCount: r.tokenCount,
|
|
837
|
+
tokenMethod: r.tokenMethod
|
|
838
|
+
})),
|
|
839
|
+
...instructions.skills.map((s) => ({
|
|
840
|
+
path: s.path,
|
|
841
|
+
tokenCount: s.tokenCount,
|
|
842
|
+
tokenMethod: s.tokenMethod
|
|
843
|
+
})),
|
|
844
|
+
...instructions.subFiles.map((f) => ({
|
|
845
|
+
path: f.path,
|
|
846
|
+
tokenCount: f.tokenCount,
|
|
847
|
+
tokenMethod: f.tokenMethod
|
|
848
|
+
}))
|
|
849
|
+
];
|
|
850
|
+
const summary = {
|
|
851
|
+
systemPromptTokens: SYSTEM_PROMPT_TOKENS,
|
|
852
|
+
rootFileTokens,
|
|
853
|
+
rootFileMethod,
|
|
854
|
+
rulesTokens,
|
|
855
|
+
rulesMethod,
|
|
856
|
+
skillsTokens,
|
|
857
|
+
skillsMethod,
|
|
858
|
+
subFilesTokens,
|
|
859
|
+
subFilesMethod,
|
|
860
|
+
mcpTokens,
|
|
861
|
+
totalBaseline,
|
|
862
|
+
availableTokens,
|
|
863
|
+
fileBreakdown,
|
|
864
|
+
tokenMethod
|
|
865
|
+
};
|
|
866
|
+
return { findings, summary };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/detectors/config-overlap.ts
|
|
870
|
+
import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
|
|
871
|
+
import { join as join5 } from "path";
|
|
872
|
+
function readJsonFile(projectRoot, filename) {
|
|
873
|
+
try {
|
|
874
|
+
const content = readFileSync5(join5(projectRoot, filename), "utf8");
|
|
875
|
+
return JSON.parse(content);
|
|
876
|
+
} catch {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function readTextFile(projectRoot, filename) {
|
|
881
|
+
try {
|
|
882
|
+
return readFileSync5(join5(projectRoot, filename), "utf8");
|
|
883
|
+
} catch {
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function checkEditorConfig(projectRoot, key) {
|
|
888
|
+
const content = readTextFile(projectRoot, ".editorconfig");
|
|
889
|
+
if (!content) return false;
|
|
890
|
+
return new RegExp(`^${key}\\s*=\\s*.+`, "m").test(content);
|
|
891
|
+
}
|
|
892
|
+
function checkPrettierConfig(projectRoot, field) {
|
|
893
|
+
for (const filename of [".prettierrc", ".prettierrc.json"]) {
|
|
894
|
+
const parsed = readJsonFile(projectRoot, filename);
|
|
895
|
+
if (parsed !== null && typeof parsed === "object" && parsed !== null) {
|
|
896
|
+
if (field in parsed) return true;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
function getPrettierField(projectRoot, field) {
|
|
902
|
+
for (const filename of [".prettierrc", ".prettierrc.json"]) {
|
|
903
|
+
const parsed = readJsonFile(projectRoot, filename);
|
|
904
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
905
|
+
const obj = parsed;
|
|
906
|
+
if (field in obj) return obj[field];
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return void 0;
|
|
910
|
+
}
|
|
911
|
+
function checkEslintRule(projectRoot, ruleName) {
|
|
912
|
+
const parsed = readJsonFile(projectRoot, ".eslintrc.json");
|
|
913
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
914
|
+
const rules = parsed["rules"];
|
|
915
|
+
if (!rules || typeof rules !== "object") return false;
|
|
916
|
+
const val = rules[ruleName];
|
|
917
|
+
if (val === void 0) return false;
|
|
918
|
+
if (val === "off" || val === 0) return false;
|
|
919
|
+
if (Array.isArray(val) && (val[0] === "off" || val[0] === 0)) return false;
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
var PATTERNS = [
|
|
923
|
+
{
|
|
924
|
+
id: "ts-strict",
|
|
925
|
+
rulePattern: /\b(typescript|ts)\b.*\bstrict\b|\bstrict\s*(mode|typing)/i,
|
|
926
|
+
configCheck: (root) => {
|
|
927
|
+
const tsconfig = readJsonFile(root, "tsconfig.json");
|
|
928
|
+
if (!tsconfig || typeof tsconfig !== "object") return false;
|
|
929
|
+
const opts = tsconfig["compilerOptions"];
|
|
930
|
+
if (!opts || typeof opts !== "object") return false;
|
|
931
|
+
return opts["strict"] === true;
|
|
932
|
+
},
|
|
933
|
+
configName: "tsconfig.json (compilerOptions.strict: true)"
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
id: "indent-spaces",
|
|
937
|
+
rulePattern: /\b(2|two|4|four)\s*(-?\s*)space\s*indent/i,
|
|
938
|
+
configCheck: (root) => checkEditorConfig(root, "indent_size") || checkPrettierConfig(root, "tabWidth"),
|
|
939
|
+
configName: ".editorconfig / .prettierrc (indentation)"
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
id: "import-order",
|
|
943
|
+
rulePattern: /import\s*(order|sort)|sort\s*import/i,
|
|
944
|
+
configCheck: (root) => {
|
|
945
|
+
const pkg = readJsonFile(root, "package.json");
|
|
946
|
+
if (!pkg || typeof pkg !== "object") return false;
|
|
947
|
+
const devDeps = pkg["devDependencies"];
|
|
948
|
+
const hasPlugin = devDeps && typeof devDeps === "object" && "eslint-plugin-import" in devDeps;
|
|
949
|
+
return hasPlugin === true && checkEslintRule(root, "import/order");
|
|
950
|
+
},
|
|
951
|
+
configName: "eslint-plugin-import (import/order)"
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
id: "conventional-commit",
|
|
955
|
+
rulePattern: /conventional\s*commit/i,
|
|
956
|
+
configCheck: (root) => {
|
|
957
|
+
const pkg = readJsonFile(root, "package.json");
|
|
958
|
+
if (pkg && typeof pkg === "object" && "commitlint" in pkg)
|
|
959
|
+
return true;
|
|
960
|
+
const configFiles = [
|
|
961
|
+
"commitlint.config.js",
|
|
962
|
+
"commitlint.config.cjs",
|
|
963
|
+
"commitlint.config.ts",
|
|
964
|
+
".commitlintrc",
|
|
965
|
+
".commitlintrc.json",
|
|
966
|
+
".commitlintrc.yaml",
|
|
967
|
+
".commitlintrc.yml"
|
|
968
|
+
];
|
|
969
|
+
return configFiles.some((f) => existsSync5(join5(root, f)));
|
|
970
|
+
},
|
|
971
|
+
configName: "commitlint config"
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
id: "semicolons",
|
|
975
|
+
rulePattern: /\b(semicolons?|always\s*use\s*;|semi\s*colon)/i,
|
|
976
|
+
configCheck: (root) => checkPrettierConfig(root, "semi"),
|
|
977
|
+
configName: ".prettierrc (semi)"
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
id: "single-quote",
|
|
981
|
+
rulePattern: /single\s*quotes?|prefer\s*'|use\s*'/i,
|
|
982
|
+
configCheck: (root) => getPrettierField(root, "singleQuote") === true,
|
|
983
|
+
configName: ".prettierrc (singleQuote: true)"
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
id: "trailing-comma",
|
|
987
|
+
rulePattern: /trailing\s*comma/i,
|
|
988
|
+
configCheck: (root) => checkPrettierConfig(root, "trailingComma"),
|
|
989
|
+
configName: ".prettierrc (trailingComma)"
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
id: "max-line-length",
|
|
993
|
+
rulePattern: /\b(max|maximum)\s*(line\s*)?(length|width|chars?)\b|\bprint\s*width\b/i,
|
|
994
|
+
configCheck: (root) => checkPrettierConfig(root, "printWidth"),
|
|
995
|
+
configName: ".prettierrc (printWidth)"
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
id: "no-console",
|
|
999
|
+
rulePattern: /\b(no|avoid|remove)\b.*\bconsole\.(log|warn|error)/i,
|
|
1000
|
+
configCheck: (root) => checkEslintRule(root, "no-console"),
|
|
1001
|
+
configName: "eslint (no-console)"
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
id: "no-unused-vars",
|
|
1005
|
+
rulePattern: /\b(no|remove|avoid)\s*(unused|dead)\s*(var|variable|import)/i,
|
|
1006
|
+
configCheck: (root) => {
|
|
1007
|
+
const tsconfig = readJsonFile(root, "tsconfig.json");
|
|
1008
|
+
if (tsconfig && typeof tsconfig === "object") {
|
|
1009
|
+
const opts = tsconfig["compilerOptions"];
|
|
1010
|
+
if (opts && typeof opts === "object") {
|
|
1011
|
+
const o = opts;
|
|
1012
|
+
if (o["noUnusedLocals"] === true || o["noUnusedParameters"] === true)
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return checkEslintRule(root, "no-unused-vars") || checkEslintRule(root, "@typescript-eslint/no-unused-vars");
|
|
1017
|
+
},
|
|
1018
|
+
configName: "tsconfig / eslint (no-unused-vars)"
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
id: "test-framework",
|
|
1022
|
+
rulePattern: /\b(use|prefer)\s*(jest|vitest|mocha|jasmine)\b/i,
|
|
1023
|
+
configCheck: (root) => {
|
|
1024
|
+
const configs = [
|
|
1025
|
+
"vitest.config.ts",
|
|
1026
|
+
"vitest.config.js",
|
|
1027
|
+
"jest.config.ts",
|
|
1028
|
+
"jest.config.js",
|
|
1029
|
+
"jest.config.cjs",
|
|
1030
|
+
".mocharc.js",
|
|
1031
|
+
".mocharc.json",
|
|
1032
|
+
".mocharc.yaml"
|
|
1033
|
+
];
|
|
1034
|
+
if (configs.some((f) => existsSync5(join5(root, f)))) return true;
|
|
1035
|
+
const pkg = readJsonFile(root, "package.json");
|
|
1036
|
+
if (!pkg || typeof pkg !== "object") return false;
|
|
1037
|
+
const devDeps = pkg["devDependencies"] ?? {};
|
|
1038
|
+
return ["jest", "vitest", "mocha", "jasmine"].some(
|
|
1039
|
+
(fw) => typeof devDeps === "object" && fw in devDeps
|
|
1040
|
+
);
|
|
1041
|
+
},
|
|
1042
|
+
configName: "test framework config file"
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
id: "formatter",
|
|
1046
|
+
rulePattern: /\b(format|prettier|biome)\s*(code|files|on\s*save)/i,
|
|
1047
|
+
configCheck: (root) => {
|
|
1048
|
+
const prettierFiles = [
|
|
1049
|
+
".prettierrc",
|
|
1050
|
+
".prettierrc.json",
|
|
1051
|
+
".prettierrc.yaml",
|
|
1052
|
+
".prettierrc.yml"
|
|
1053
|
+
];
|
|
1054
|
+
if (prettierFiles.some((f) => existsSync5(join5(root, f)))) return true;
|
|
1055
|
+
return existsSync5(join5(root, "biome.json"));
|
|
1056
|
+
},
|
|
1057
|
+
configName: "prettier / biome config file"
|
|
1058
|
+
},
|
|
1059
|
+
{
|
|
1060
|
+
id: "end-of-line",
|
|
1061
|
+
rulePattern: /\b(line\s*ending|eol|crlf|lf)\b/i,
|
|
1062
|
+
configCheck: (root) => checkEditorConfig(root, "end_of_line") || checkPrettierConfig(root, "endOfLine"),
|
|
1063
|
+
configName: ".editorconfig / .prettierrc (endOfLine)"
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
id: "tab-width",
|
|
1067
|
+
rulePattern: /\btab\s*(width|size)\b/i,
|
|
1068
|
+
configCheck: (root) => checkEditorConfig(root, "tab_width") || checkEditorConfig(root, "indent_size") || checkPrettierConfig(root, "tabWidth"),
|
|
1069
|
+
configName: ".editorconfig / .prettierrc (tabWidth)"
|
|
1070
|
+
},
|
|
1071
|
+
{
|
|
1072
|
+
id: "no-default-export",
|
|
1073
|
+
rulePattern: /\b(no|avoid|prefer\s*named)\s*(default\s*export)/i,
|
|
1074
|
+
configCheck: (root) => checkEslintRule(root, "import/no-default-export") || checkEslintRule(root, "no-restricted-exports"),
|
|
1075
|
+
configName: "eslint-plugin-import (no-default-export)"
|
|
1076
|
+
}
|
|
1077
|
+
];
|
|
1078
|
+
function collectRuleLines(instructions) {
|
|
1079
|
+
const result = [];
|
|
1080
|
+
for (const l of instructions.rootFile.lines) {
|
|
1081
|
+
if (l.type === "rule")
|
|
1082
|
+
result.push({ line: l, file: instructions.rootFile.path });
|
|
1083
|
+
}
|
|
1084
|
+
for (const sub of instructions.subFiles) {
|
|
1085
|
+
for (const l of sub.lines) {
|
|
1086
|
+
if (l.type === "rule") result.push({ line: l, file: sub.path });
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
for (const rule of instructions.rules) {
|
|
1090
|
+
for (const l of rule.lines) {
|
|
1091
|
+
if (l.type === "rule") result.push({ line: l, file: rule.path });
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return result;
|
|
1095
|
+
}
|
|
1096
|
+
function detectConfigOverlaps(instructions, projectRoot) {
|
|
1097
|
+
const findings = [];
|
|
1098
|
+
const ruleLines = collectRuleLines(instructions);
|
|
1099
|
+
for (const { line, file } of ruleLines) {
|
|
1100
|
+
for (const pattern of PATTERNS) {
|
|
1101
|
+
if (pattern.rulePattern.test(line.text) && pattern.configCheck(projectRoot)) {
|
|
1102
|
+
const ruleText = line.text.trim();
|
|
1103
|
+
const short = ruleText.length > 60 ? ruleText.slice(0, 60) + "\u2026" : ruleText;
|
|
1104
|
+
findings.push({
|
|
1105
|
+
severity: "warning",
|
|
1106
|
+
category: "dead-rule",
|
|
1107
|
+
file,
|
|
1108
|
+
line: line.lineNumber,
|
|
1109
|
+
messageKey: "deadRule.configOverlap",
|
|
1110
|
+
messageParams: {
|
|
1111
|
+
rule: ruleText.slice(0, 80),
|
|
1112
|
+
config: pattern.configName
|
|
1113
|
+
},
|
|
1114
|
+
suggestion: `Rule "${short}" is already enforced by ${pattern.configName}`,
|
|
1115
|
+
autoFixable: true
|
|
1116
|
+
});
|
|
1117
|
+
break;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return findings;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// src/utils/text.ts
|
|
1125
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
1126
|
+
"the",
|
|
1127
|
+
"a",
|
|
1128
|
+
"an",
|
|
1129
|
+
"is",
|
|
1130
|
+
"are",
|
|
1131
|
+
"to",
|
|
1132
|
+
"for",
|
|
1133
|
+
"and",
|
|
1134
|
+
"or",
|
|
1135
|
+
"in",
|
|
1136
|
+
"of",
|
|
1137
|
+
"with",
|
|
1138
|
+
"that",
|
|
1139
|
+
"this"
|
|
1140
|
+
]);
|
|
1141
|
+
function tokenizeWords(text) {
|
|
1142
|
+
return text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
|
|
1143
|
+
}
|
|
1144
|
+
function removeStopWords(words) {
|
|
1145
|
+
return words.filter((w) => !STOP_WORDS.has(w));
|
|
1146
|
+
}
|
|
1147
|
+
function jaccardSimilarity(setA, setB) {
|
|
1148
|
+
if (setA.length === 0 && setB.length === 0) return 0;
|
|
1149
|
+
const a = new Set(setA);
|
|
1150
|
+
const b = new Set(setB);
|
|
1151
|
+
let intersection = 0;
|
|
1152
|
+
for (const word of a) {
|
|
1153
|
+
if (b.has(word)) intersection++;
|
|
1154
|
+
}
|
|
1155
|
+
const union = (/* @__PURE__ */ new Set([...a, ...b])).size;
|
|
1156
|
+
return union === 0 ? 0 : intersection / union;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// src/detectors/duplicate.ts
|
|
1160
|
+
var SIMILARITY_THRESHOLD = 0.7;
|
|
1161
|
+
var MIN_WORDS_AFTER_STOP = 4;
|
|
1162
|
+
function collectRuleLines2(instructions) {
|
|
1163
|
+
const result = [];
|
|
1164
|
+
const add = (lines, file) => {
|
|
1165
|
+
for (const l of lines) {
|
|
1166
|
+
if (l.type !== "rule") continue;
|
|
1167
|
+
const words = removeStopWords(tokenizeWords(l.text));
|
|
1168
|
+
if (words.length < MIN_WORDS_AFTER_STOP) continue;
|
|
1169
|
+
result.push({ line: l, file, words });
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
add(instructions.rootFile.lines, instructions.rootFile.path);
|
|
1173
|
+
for (const sub of instructions.subFiles) add(sub.lines, sub.path);
|
|
1174
|
+
for (const rule of instructions.rules) add(rule.lines, rule.path);
|
|
1175
|
+
return result;
|
|
1176
|
+
}
|
|
1177
|
+
function detectDuplicates(instructions) {
|
|
1178
|
+
const findings = [];
|
|
1179
|
+
const ruleLines = collectRuleLines2(instructions);
|
|
1180
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1181
|
+
for (let i = 0; i < ruleLines.length; i++) {
|
|
1182
|
+
for (let j = i + 1; j < ruleLines.length; j++) {
|
|
1183
|
+
const a = ruleLines[i];
|
|
1184
|
+
const b = ruleLines[j];
|
|
1185
|
+
const pairKey = `${a.file}:${a.line.lineNumber}|${b.file}:${b.line.lineNumber}`;
|
|
1186
|
+
if (reported.has(pairKey)) continue;
|
|
1187
|
+
const sim = jaccardSimilarity(a.words, b.words);
|
|
1188
|
+
if (sim < SIMILARITY_THRESHOLD) continue;
|
|
1189
|
+
reported.add(pairKey);
|
|
1190
|
+
const isExact = sim >= 1;
|
|
1191
|
+
const simPct = `${Math.round(sim * 100)}`;
|
|
1192
|
+
findings.push({
|
|
1193
|
+
severity: isExact ? "warning" : "info",
|
|
1194
|
+
category: "duplicate",
|
|
1195
|
+
file: b.file,
|
|
1196
|
+
line: b.line.lineNumber,
|
|
1197
|
+
messageKey: isExact ? "deadRule.exactDuplicate" : "deadRule.nearDuplicate",
|
|
1198
|
+
messageParams: {
|
|
1199
|
+
otherFile: a.file,
|
|
1200
|
+
otherLine: String(a.line.lineNumber),
|
|
1201
|
+
similarity: simPct
|
|
1202
|
+
},
|
|
1203
|
+
suggestion: isExact ? `Exact duplicate of line ${a.line.lineNumber} in ${a.file}` : `Very similar to line ${a.line.lineNumber} in ${a.file} (${simPct}% similar)`,
|
|
1204
|
+
autoFixable: isExact
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return findings;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/analyzers/dead-rules.ts
|
|
1212
|
+
function analyzeDeadRules(instructions, projectRoot) {
|
|
1213
|
+
return {
|
|
1214
|
+
findings: [
|
|
1215
|
+
...detectConfigOverlaps(instructions, projectRoot),
|
|
1216
|
+
...detectDuplicates(instructions)
|
|
1217
|
+
]
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// src/detectors/contradiction.ts
|
|
1222
|
+
var NEGATION_WORDS = ["never", "don't", "avoid", "forbid"];
|
|
1223
|
+
function isNegated(text, word) {
|
|
1224
|
+
const sentences = text.split(/[.!?]+\s+/);
|
|
1225
|
+
const escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1226
|
+
const wordPresent = new RegExp(`\\b${escapedWord}\\b`, "i");
|
|
1227
|
+
for (const sentence of sentences) {
|
|
1228
|
+
if (!wordPresent.test(sentence)) continue;
|
|
1229
|
+
const lower = sentence.toLowerCase();
|
|
1230
|
+
for (const neg of NEGATION_WORDS) {
|
|
1231
|
+
const pattern = new RegExp(
|
|
1232
|
+
`\\b${neg}\\b(?:\\s+\\w+){0,1}\\s+\\b${escapedWord}\\b`,
|
|
1233
|
+
"i"
|
|
1234
|
+
);
|
|
1235
|
+
if (pattern.test(lower)) return true;
|
|
1236
|
+
}
|
|
1237
|
+
const notPattern = new RegExp(
|
|
1238
|
+
`\\b(?:do\\s+)?not\\b(?:\\s+\\w+){0,1}\\s+\\b${escapedWord}\\b`,
|
|
1239
|
+
"i"
|
|
1240
|
+
);
|
|
1241
|
+
if (notPattern.test(lower)) return true;
|
|
1242
|
+
}
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
var POLARITY_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
1246
|
+
// Polarity / negation markers (meta-level, not domain content)
|
|
1247
|
+
"never",
|
|
1248
|
+
"always",
|
|
1249
|
+
"avoid",
|
|
1250
|
+
"not",
|
|
1251
|
+
// Generic imperative verbs (describe HOW to comply, not WHAT topic)
|
|
1252
|
+
"use",
|
|
1253
|
+
"ensure",
|
|
1254
|
+
"require",
|
|
1255
|
+
"prefer",
|
|
1256
|
+
"follow",
|
|
1257
|
+
"keep",
|
|
1258
|
+
// Common modals and auxiliaries
|
|
1259
|
+
"must",
|
|
1260
|
+
"should",
|
|
1261
|
+
"can",
|
|
1262
|
+
"will",
|
|
1263
|
+
"may",
|
|
1264
|
+
// Generic quantifiers
|
|
1265
|
+
"all",
|
|
1266
|
+
"every",
|
|
1267
|
+
"each",
|
|
1268
|
+
"any"
|
|
1269
|
+
]);
|
|
1270
|
+
function collectRuleLines3(instructions) {
|
|
1271
|
+
const sources = [
|
|
1272
|
+
instructions.rootFile,
|
|
1273
|
+
...instructions.subFiles,
|
|
1274
|
+
...instructions.rules
|
|
1275
|
+
];
|
|
1276
|
+
const annotated = [];
|
|
1277
|
+
for (const file of sources) {
|
|
1278
|
+
for (const line of file.lines) {
|
|
1279
|
+
if (line.type !== "rule") continue;
|
|
1280
|
+
const words = removeStopWords(tokenizeWords(line.text)).filter(
|
|
1281
|
+
(w) => !POLARITY_STOP_WORDS.has(w)
|
|
1282
|
+
);
|
|
1283
|
+
if (words.length < 3) continue;
|
|
1284
|
+
annotated.push({ line, words, file: file.path });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return annotated;
|
|
1288
|
+
}
|
|
1289
|
+
function detectContradictions(instructions) {
|
|
1290
|
+
const lines = collectRuleLines3(instructions);
|
|
1291
|
+
const findings = [];
|
|
1292
|
+
const reportedPairs = /* @__PURE__ */ new Set();
|
|
1293
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1294
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1295
|
+
const a = lines[i];
|
|
1296
|
+
const b = lines[j];
|
|
1297
|
+
const setA = new Set(a.words);
|
|
1298
|
+
const setB = new Set(b.words);
|
|
1299
|
+
const shared = [...setB].filter((w) => setA.has(w));
|
|
1300
|
+
if (shared.length < 3) continue;
|
|
1301
|
+
const hasContradiction = shared.some(
|
|
1302
|
+
(word) => isNegated(a.line.text, word) !== isNegated(b.line.text, word)
|
|
1303
|
+
);
|
|
1304
|
+
if (!hasContradiction) continue;
|
|
1305
|
+
const pairKey = `${a.file}:${a.line.lineNumber}|${b.file}:${b.line.lineNumber}`;
|
|
1306
|
+
if (reportedPairs.has(pairKey)) continue;
|
|
1307
|
+
reportedPairs.add(pairKey);
|
|
1308
|
+
const snippet = a.line.text.length > 60 ? `${a.line.text.slice(0, 60)}...` : a.line.text;
|
|
1309
|
+
findings.push({
|
|
1310
|
+
severity: "critical",
|
|
1311
|
+
category: "contradiction",
|
|
1312
|
+
file: b.file,
|
|
1313
|
+
line: b.line.lineNumber,
|
|
1314
|
+
messageKey: "structure.contradiction",
|
|
1315
|
+
messageParams: {
|
|
1316
|
+
snippet,
|
|
1317
|
+
lineA: String(a.line.lineNumber),
|
|
1318
|
+
lineB: String(b.line.lineNumber),
|
|
1319
|
+
fileA: a.file
|
|
1320
|
+
},
|
|
1321
|
+
suggestion: `Contradicting rules: "${snippet}" (${a.file} line ${a.line.lineNumber}) conflicts with line ${b.line.lineNumber}.`,
|
|
1322
|
+
autoFixable: false
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return findings;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// src/detectors/stale-refs.ts
|
|
1330
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1331
|
+
import { join as join6 } from "path";
|
|
1332
|
+
function collectLines(instructions) {
|
|
1333
|
+
const sources = [
|
|
1334
|
+
instructions.rootFile,
|
|
1335
|
+
...instructions.subFiles,
|
|
1336
|
+
...instructions.rules
|
|
1337
|
+
];
|
|
1338
|
+
const result = [];
|
|
1339
|
+
for (const file of sources) {
|
|
1340
|
+
for (const line of file.lines) {
|
|
1341
|
+
if (line.type === "blank" || line.type === "code") continue;
|
|
1342
|
+
if (line.referencedPaths.length === 0) continue;
|
|
1343
|
+
result.push({ line, file: file.path });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return result;
|
|
1347
|
+
}
|
|
1348
|
+
function detectStaleRefs(instructions, projectRoot) {
|
|
1349
|
+
const findings = [];
|
|
1350
|
+
for (const { line, file } of collectLines(instructions)) {
|
|
1351
|
+
for (const refPath of line.referencedPaths) {
|
|
1352
|
+
if (refPath.endsWith("/") || refPath.includes("*")) continue;
|
|
1353
|
+
const absolutePath = join6(projectRoot, refPath);
|
|
1354
|
+
if (!existsSync6(absolutePath)) {
|
|
1355
|
+
findings.push({
|
|
1356
|
+
severity: "warning",
|
|
1357
|
+
category: "stale-ref",
|
|
1358
|
+
file,
|
|
1359
|
+
line: line.lineNumber,
|
|
1360
|
+
messageKey: "structure.staleRef",
|
|
1361
|
+
messageParams: { path: refPath },
|
|
1362
|
+
suggestion: `Stale reference: "${refPath}" does not exist.`,
|
|
1363
|
+
autoFixable: true
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return findings;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// src/detectors/scope-classifier.ts
|
|
1372
|
+
var PATH_REF_PATTERN = /\b(?:src|tests?|lib|dist)\//i;
|
|
1373
|
+
var HOOK_PATTERN = /\b(?:never|don't|do\s+not|forbid)\b.*\b(?:commit|push|merge|build|run)\b/i;
|
|
1374
|
+
function classifyScope(instructions) {
|
|
1375
|
+
const findings = [];
|
|
1376
|
+
const rootFile = instructions.rootFile;
|
|
1377
|
+
for (const line of rootFile.lines) {
|
|
1378
|
+
if (line.type !== "rule") continue;
|
|
1379
|
+
if (HOOK_PATTERN.test(line.text)) {
|
|
1380
|
+
const snippet = line.text.length > 60 ? `${line.text.slice(0, 60)}...` : line.text;
|
|
1381
|
+
findings.push({
|
|
1382
|
+
severity: "info",
|
|
1383
|
+
category: "structure",
|
|
1384
|
+
file: rootFile.path,
|
|
1385
|
+
line: line.lineNumber,
|
|
1386
|
+
messageKey: "structure.scopeHook",
|
|
1387
|
+
messageParams: { line: String(line.lineNumber), snippet },
|
|
1388
|
+
suggestion: `Rule at line ${line.lineNumber} could be a git hook: "${snippet}"`,
|
|
1389
|
+
autoFixable: false
|
|
1390
|
+
});
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
if (PATH_REF_PATTERN.test(line.text)) {
|
|
1394
|
+
const snippet = line.text.length > 60 ? `${line.text.slice(0, 60)}...` : line.text;
|
|
1395
|
+
findings.push({
|
|
1396
|
+
severity: "info",
|
|
1397
|
+
category: "structure",
|
|
1398
|
+
file: rootFile.path,
|
|
1399
|
+
line: line.lineNumber,
|
|
1400
|
+
messageKey: "structure.scopePathScoped",
|
|
1401
|
+
messageParams: { line: String(line.lineNumber), snippet },
|
|
1402
|
+
suggestion: `Rule at line ${line.lineNumber} references a specific path \u2014 consider a path-scoped rule file: "${snippet}"`,
|
|
1403
|
+
autoFixable: false
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return findings;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// src/analyzers/structure.ts
|
|
1411
|
+
function analyzeStructure(instructions, projectRoot) {
|
|
1412
|
+
return {
|
|
1413
|
+
findings: [
|
|
1414
|
+
...detectContradictions(instructions),
|
|
1415
|
+
...detectStaleRefs(instructions, projectRoot),
|
|
1416
|
+
...classifyScope(instructions)
|
|
1417
|
+
]
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/core/scorer.ts
|
|
1422
|
+
var CONTEXT_WINDOW2 = 2e5;
|
|
1423
|
+
var CRITICAL_DEDUCTION = 10;
|
|
1424
|
+
var WARNING_DEDUCTION = 5;
|
|
1425
|
+
var INFO_DEDUCTION = 1;
|
|
1426
|
+
var MAX_CRITICAL_DEDUCTION = 40;
|
|
1427
|
+
var MAX_WARNING_DEDUCTION = 30;
|
|
1428
|
+
var MAX_INFO_DEDUCTION = 10;
|
|
1429
|
+
function gradeFromScore(score) {
|
|
1430
|
+
if (score >= 90) return "A";
|
|
1431
|
+
if (score >= 80) return "B";
|
|
1432
|
+
if (score >= 70) return "C";
|
|
1433
|
+
if (score >= 60) return "D";
|
|
1434
|
+
return "F";
|
|
1435
|
+
}
|
|
1436
|
+
function calculateScore(findings, budget) {
|
|
1437
|
+
const criticals = findings.filter((f) => f.severity === "critical").length;
|
|
1438
|
+
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
1439
|
+
const infos = findings.filter((f) => f.severity === "info").length;
|
|
1440
|
+
const criticalDeduction = Math.min(criticals * CRITICAL_DEDUCTION, MAX_CRITICAL_DEDUCTION);
|
|
1441
|
+
const warningDeduction = Math.min(warnings * WARNING_DEDUCTION, MAX_WARNING_DEDUCTION);
|
|
1442
|
+
const infoDeduction = Math.min(infos * INFO_DEDUCTION, MAX_INFO_DEDUCTION);
|
|
1443
|
+
const baselinePct = budget.totalBaseline / CONTEXT_WINDOW2;
|
|
1444
|
+
let budgetDeduction = 0;
|
|
1445
|
+
if (baselinePct > 0.5) budgetDeduction = 15;
|
|
1446
|
+
else if (baselinePct > 0.25) budgetDeduction = 5;
|
|
1447
|
+
const score = Math.max(
|
|
1448
|
+
0,
|
|
1449
|
+
100 - criticalDeduction - warningDeduction - infoDeduction - budgetDeduction
|
|
1450
|
+
);
|
|
1451
|
+
return { score, grade: gradeFromScore(score) };
|
|
1452
|
+
}
|
|
1453
|
+
function buildActionPlan(findings) {
|
|
1454
|
+
const priorityOf = (f) => {
|
|
1455
|
+
if (f.severity === "critical") return 1;
|
|
1456
|
+
if (f.severity === "warning") return 2;
|
|
1457
|
+
return 3;
|
|
1458
|
+
};
|
|
1459
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1460
|
+
return findings.filter((f) => {
|
|
1461
|
+
if (seen.has(f.suggestion)) return false;
|
|
1462
|
+
seen.add(f.suggestion);
|
|
1463
|
+
return true;
|
|
1464
|
+
}).map((f) => ({
|
|
1465
|
+
priority: priorityOf(f),
|
|
1466
|
+
description: f.suggestion,
|
|
1467
|
+
category: f.category
|
|
1468
|
+
})).sort((a, b) => a.priority - b.priority);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// src/core/reporter.ts
|
|
1472
|
+
import chalk2 from "chalk";
|
|
1473
|
+
|
|
1474
|
+
// src/commands/budget-command.ts
|
|
1475
|
+
import chalk from "chalk";
|
|
1476
|
+
|
|
1477
|
+
// src/i18n/en.json
|
|
1478
|
+
var en_default = {
|
|
1479
|
+
"label.tokenBudget": "TOKEN BUDGET",
|
|
1480
|
+
"label.deadRules": "DEAD RULES",
|
|
1481
|
+
"label.structure": "STRUCTURE",
|
|
1482
|
+
"label.actionPlan": "ACTION PLAN",
|
|
1483
|
+
"label.fixSummary": "FIX SUMMARY",
|
|
1484
|
+
"label.systemPrompt": "System prompt",
|
|
1485
|
+
"label.rootFile": "Root file",
|
|
1486
|
+
"label.ruleFiles": "Rule files",
|
|
1487
|
+
"label.skillFiles": "Skill files",
|
|
1488
|
+
"label.subDirFiles": "Sub-dir files",
|
|
1489
|
+
"label.mcpServers": "MCP servers",
|
|
1490
|
+
"label.baselineTotal": "Baseline total",
|
|
1491
|
+
"label.available": "Available",
|
|
1492
|
+
"label.redundantByConfig": "Redundant (enforced by config)",
|
|
1493
|
+
"label.duplicates": "Duplicates",
|
|
1494
|
+
"label.contradictions": "Contradictions",
|
|
1495
|
+
"label.staleReferences": "Stale References",
|
|
1496
|
+
"label.refactoringOpportunities": "Refactoring Opportunities",
|
|
1497
|
+
"label.tool": "Tool:",
|
|
1498
|
+
"label.score": "Score:",
|
|
1499
|
+
"status.noBudgetIssues": "\u2713 No budget issues found",
|
|
1500
|
+
"status.noDeadRules": "\u2713 No dead rules found",
|
|
1501
|
+
"status.noStructuralIssues": "\u2713 No structural issues found",
|
|
1502
|
+
"status.noAutoFixable": "\u2713 No auto-fixable issues found",
|
|
1503
|
+
"status.perfectScore": "\u2713 Perfect score \u2014 no issues found",
|
|
1504
|
+
"status.fixedIssues": "\u2713 Fixed {{count}} issue{{s}} \u2014 run `git diff` to review changes",
|
|
1505
|
+
"error.unknownTool": "No agent instruction files found. Run this command in a project that uses Claude Code, Codex, or Cursor.",
|
|
1506
|
+
"error.missingRootFile": "Found {{tool}} configuration but no root instruction file.",
|
|
1507
|
+
"error.dirtyWorkingTree": "Working tree is dirty. Commit or stash your changes before running --fix, or use --force to skip this check.",
|
|
1508
|
+
"fix.removedDeadRules": "Removed {{count}} redundant rule{{s}}",
|
|
1509
|
+
"fix.removedStaleRefs": "Removed {{count}} stale reference{{s}}",
|
|
1510
|
+
"fix.removedDuplicates": "Removed {{count}} exact duplicate{{s}}",
|
|
1511
|
+
"summary.redundantRules": "{{count}} redundant rule{{s}}",
|
|
1512
|
+
"summary.duplicates": "{{count}} duplicate{{s}}",
|
|
1513
|
+
"summary.contradictions": "{{count}} contradiction{{s}}",
|
|
1514
|
+
"summary.staleRefs": "{{count}} stale ref{{s}}",
|
|
1515
|
+
"summary.refactoringSuggestions": "{{count}} refactoring suggestion{{s}}",
|
|
1516
|
+
"summary.found": "{{parts}} found",
|
|
1517
|
+
"tokens.measured": "{{count}} tokens",
|
|
1518
|
+
"tokens.estimated": "~{{count}} tokens (estimated)",
|
|
1519
|
+
"actionPlan.andMore": "\u2026 and {{count}} more",
|
|
1520
|
+
"severity.critical": "{{count}} critical",
|
|
1521
|
+
"severity.warnings": "{{count}} warning{{s}}",
|
|
1522
|
+
"severity.suggestions": "{{count}} suggestion{{s}}",
|
|
1523
|
+
"budget.rootFileCritical": "Root instruction file is {{lines}} lines \u2014 agent compliance drops significantly above 200 lines",
|
|
1524
|
+
"budget.rootFileWarning": "Root instruction file is {{lines}} lines (recommended: < 200)",
|
|
1525
|
+
"budget.mcpLargeServer": "MCP server '{{name}}' consumes ~{{tokens}} tokens",
|
|
1526
|
+
"budget.baselineHigh": "Baseline context consumption is {{pct}}% of window",
|
|
1527
|
+
"deadRule.configOverlap": 'Rule "{{rule}}" is already enforced by {{config}}',
|
|
1528
|
+
"deadRule.exactDuplicate": "Exact duplicate of line {{otherLine}} in {{otherFile}}",
|
|
1529
|
+
"deadRule.nearDuplicate": "Very similar to line {{otherLine}} in {{otherFile}} ({{similarity}}% similar)",
|
|
1530
|
+
"structure.contradiction": 'Contradicting rules: "{{snippet}}" ({{fileA}} line {{lineA}}) conflicts with line {{lineB}}.',
|
|
1531
|
+
"structure.staleRef": 'Stale reference: "{{path}}" does not exist.',
|
|
1532
|
+
"structure.scopeHook": 'Rule at line {{line}} could be a git hook: "{{snippet}}"',
|
|
1533
|
+
"structure.scopePathScoped": 'Rule at line {{line}} references a specific path \u2014 consider a path-scoped rule file: "{{snippet}}"',
|
|
1534
|
+
"label.budget": "BUDGET",
|
|
1535
|
+
"label.findings": "FINDINGS",
|
|
1536
|
+
"label.topIssues": "TOP ISSUES",
|
|
1537
|
+
"label.category": "Category",
|
|
1538
|
+
"compact.budgetLine": "{{used}} / {{window}} tokens ({{pct}}%)",
|
|
1539
|
+
"compact.andMore": "\u2026 and {{count}} more (run instrlint <command> for details)",
|
|
1540
|
+
"compact.budget": "Budget",
|
|
1541
|
+
"compact.deadRules": "Dead rules",
|
|
1542
|
+
"compact.duplicates": "Duplicates",
|
|
1543
|
+
"compact.contradictions": "Contradictions",
|
|
1544
|
+
"compact.staleRefs": "Stale refs",
|
|
1545
|
+
"compact.structure": "Structure",
|
|
1546
|
+
"markdown.title": "instrlint Health Report \u2014 {{project}}",
|
|
1547
|
+
"markdown.scoreLine": "**Score: {{score}}/100 ({{grade}})** \xB7 Tool: `{{tool}}`",
|
|
1548
|
+
"markdown.summary": "## Summary",
|
|
1549
|
+
"markdown.severity": "Severity",
|
|
1550
|
+
"markdown.count": "Count",
|
|
1551
|
+
"markdown.critical": "\u{1F534} Critical",
|
|
1552
|
+
"markdown.warning": "\u{1F7E1} Warning",
|
|
1553
|
+
"markdown.info": "\u2139\uFE0F Info",
|
|
1554
|
+
"markdown.contradictions": "Contradictions",
|
|
1555
|
+
"markdown.staleReferences": "Stale References",
|
|
1556
|
+
"markdown.deadRules": "Dead Rules",
|
|
1557
|
+
"markdown.duplicateRules": "Duplicates",
|
|
1558
|
+
"markdown.budgetIssues": "Budget Issues",
|
|
1559
|
+
"markdown.refactoringOpportunities": "Refactoring Opportunities",
|
|
1560
|
+
"markdown.lineRef": "(line {{line}})",
|
|
1561
|
+
"markdown.actionPlan": "## Action Plan",
|
|
1562
|
+
"markdown.attribution": "*Generated by [instrlint](https://github.com/jed1978/instrlint)*",
|
|
1563
|
+
"ci.passed": "\u2713 instrlint passed score={{score}} grade={{grade}}",
|
|
1564
|
+
"ci.failed": "\u2716 instrlint failed score={{score}} grade={{grade}}",
|
|
1565
|
+
"ci.writtenTo": "\u2192 written to {{file}}",
|
|
1566
|
+
"initCi.created": "\u2713 Created {{path}}",
|
|
1567
|
+
"initCi.alreadyExists": "{{path}} already exists. Use --force to overwrite.",
|
|
1568
|
+
"install.installed": "\u2713 Installed to {{path}}",
|
|
1569
|
+
"install.alreadyExists": "{{path}} already exists. Use --force to overwrite.",
|
|
1570
|
+
"install.unknownTarget": "Specify --claude-code or --codex",
|
|
1571
|
+
"fix.manualActions": "MANUAL ACTIONS NEEDED",
|
|
1572
|
+
"fix.hookCreate": "Add to .claude/settings.json:",
|
|
1573
|
+
"fix.hookWarning": "\u26A0 Hook executes shell commands \u2014 review carefully before adding",
|
|
1574
|
+
"fix.pathScopedCreate": "Create {{path}}:",
|
|
1575
|
+
"fix.thenRemoveLine": "Then remove line {{line}} from {{file}}"
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
// src/i18n/zh-TW.json
|
|
1579
|
+
var zh_TW_default = {
|
|
1580
|
+
"label.tokenBudget": "TOKEN \u9810\u7B97",
|
|
1581
|
+
"label.deadRules": "\u5197\u9918\u898F\u5247",
|
|
1582
|
+
"label.structure": "\u7D50\u69CB\u5206\u6790",
|
|
1583
|
+
"label.actionPlan": "\u884C\u52D5\u8A08\u756B",
|
|
1584
|
+
"label.fixSummary": "\u4FEE\u5FA9\u6458\u8981",
|
|
1585
|
+
"label.systemPrompt": "\u7CFB\u7D71\u63D0\u793A",
|
|
1586
|
+
"label.rootFile": "\u6839\u6307\u4EE4\u6A94",
|
|
1587
|
+
"label.ruleFiles": "\u898F\u5247\u6A94",
|
|
1588
|
+
"label.skillFiles": "\u6280\u80FD\u6A94",
|
|
1589
|
+
"label.subDirFiles": "\u5B50\u76EE\u9304\u6A94",
|
|
1590
|
+
"label.mcpServers": "MCP \u4F3A\u670D\u5668",
|
|
1591
|
+
"label.baselineTotal": "\u521D\u59CB\u7E3D\u91CF",
|
|
1592
|
+
"label.available": "\u53EF\u7528",
|
|
1593
|
+
"label.redundantByConfig": "\u5197\u9918\uFF08\u5DF2\u7531\u914D\u7F6E\u5F37\u5236\u57F7\u884C\uFF09",
|
|
1594
|
+
"label.duplicates": "\u91CD\u8907\u9805\u76EE",
|
|
1595
|
+
"label.contradictions": "\u898F\u5247\u77DB\u76FE",
|
|
1596
|
+
"label.staleReferences": "\u904E\u6642\u53C3\u7167",
|
|
1597
|
+
"label.refactoringOpportunities": "\u91CD\u69CB\u5EFA\u8B70",
|
|
1598
|
+
"label.tool": "\u5DE5\u5177\uFF1A",
|
|
1599
|
+
"label.score": "\u5206\u6578\uFF1A",
|
|
1600
|
+
"status.noBudgetIssues": "\u2713 \u7121 Token \u9810\u7B97\u554F\u984C",
|
|
1601
|
+
"status.noDeadRules": "\u2713 \u7121\u5197\u9918\u898F\u5247",
|
|
1602
|
+
"status.noStructuralIssues": "\u2713 \u7121\u7D50\u69CB\u6027\u554F\u984C",
|
|
1603
|
+
"status.noAutoFixable": "\u2713 \u7121\u53EF\u81EA\u52D5\u4FEE\u5FA9\u7684\u554F\u984C",
|
|
1604
|
+
"status.perfectScore": "\u2713 \u6EFF\u5206 \u2014 \u7121\u4EFB\u4F55\u554F\u984C",
|
|
1605
|
+
"status.fixedIssues": "\u2713 \u5DF2\u4FEE\u5FA9 {{count}} \u500B\u554F\u984C \u2014 \u57F7\u884C `git diff` \u67E5\u770B\u8B8A\u66F4",
|
|
1606
|
+
"error.unknownTool": "\u627E\u4E0D\u5230\u4EE3\u7406\u6307\u4EE4\u6A94\u3002\u8ACB\u5728\u4F7F\u7528 Claude Code\u3001Codex \u6216 Cursor \u7684\u5C08\u6848\u4E2D\u57F7\u884C\u6B64\u6307\u4EE4\u3002",
|
|
1607
|
+
"error.missingRootFile": "\u627E\u5230 {{tool}} \u914D\u7F6E\uFF0C\u4F46\u627E\u4E0D\u5230\u6839\u6307\u4EE4\u6A94\u3002",
|
|
1608
|
+
"error.dirtyWorkingTree": "\u5DE5\u4F5C\u76EE\u9304\u6709\u672A\u63D0\u4EA4\u7684\u8B8A\u66F4\u3002\u8ACB\u5148\u63D0\u4EA4\u6216\u5132\u85CF\u8B8A\u66F4\u518D\u57F7\u884C --fix\uFF0C\u6216\u4F7F\u7528 --force \u8DF3\u904E\u6B64\u6AA2\u67E5\u3002",
|
|
1609
|
+
"fix.removedDeadRules": "\u5DF2\u79FB\u9664 {{count}} \u500B\u5197\u9918\u898F\u5247",
|
|
1610
|
+
"fix.removedStaleRefs": "\u5DF2\u79FB\u9664 {{count}} \u500B\u904E\u6642\u53C3\u7167",
|
|
1611
|
+
"fix.removedDuplicates": "\u5DF2\u79FB\u9664 {{count}} \u500B\u91CD\u8907\u9805\u76EE",
|
|
1612
|
+
"summary.redundantRules": "{{count}} \u500B\u5197\u9918\u898F\u5247",
|
|
1613
|
+
"summary.duplicates": "{{count}} \u500B\u91CD\u8907\u9805\u76EE",
|
|
1614
|
+
"summary.contradictions": "{{count}} \u500B\u898F\u5247\u77DB\u76FE",
|
|
1615
|
+
"summary.staleRefs": "{{count}} \u500B\u904E\u6642\u53C3\u7167",
|
|
1616
|
+
"summary.refactoringSuggestions": "{{count}} \u500B\u91CD\u69CB\u5EFA\u8B70",
|
|
1617
|
+
"summary.found": "\u767C\u73FE {{parts}}",
|
|
1618
|
+
"tokens.measured": "{{count}} tokens",
|
|
1619
|
+
"tokens.estimated": "~{{count}} tokens\uFF08\u4F30\u8A08\u503C\uFF09",
|
|
1620
|
+
"actionPlan.andMore": "\u2026 \u9084\u6709 {{count}} \u500B",
|
|
1621
|
+
"severity.critical": "{{count}} \u500B\u56B4\u91CD\u554F\u984C",
|
|
1622
|
+
"severity.warnings": "{{count}} \u500B\u8B66\u544A",
|
|
1623
|
+
"severity.suggestions": "{{count}} \u500B\u5EFA\u8B70",
|
|
1624
|
+
"budget.rootFileCritical": "\u6839\u6307\u4EE4\u6A94\u6709 {{lines}} \u884C \u2014 \u8D85\u904E 200 \u884C\u5F8C\u4EE3\u7406\u9075\u5FAA\u7387\u986F\u8457\u4E0B\u964D",
|
|
1625
|
+
"budget.rootFileWarning": "\u6839\u6307\u4EE4\u6A94\u6709 {{lines}} \u884C\uFF08\u5EFA\u8B70\uFF1A< 200 \u884C\uFF09",
|
|
1626
|
+
"budget.mcpLargeServer": "MCP \u4F3A\u670D\u5668\u300C{{name}}\u300D\u6D88\u8017\u7D04 {{tokens}} tokens",
|
|
1627
|
+
"budget.baselineHigh": "\u57FA\u7DDA\u60C5\u5883\u6D88\u8017\u4F54\u8996\u7A97 {{pct}}%",
|
|
1628
|
+
"deadRule.configOverlap": "\u898F\u5247\u300C{{rule}}\u300D\u5DF2\u7531 {{config}} \u5F37\u5236\u57F7\u884C",
|
|
1629
|
+
"deadRule.exactDuplicate": "\u8207 {{otherFile}} \u7B2C {{otherLine}} \u884C\u5B8C\u5168\u91CD\u8907",
|
|
1630
|
+
"deadRule.nearDuplicate": "\u8207 {{otherFile}} \u7B2C {{otherLine}} \u884C\u9AD8\u5EA6\u76F8\u4F3C\uFF08{{similarity}}% \u76F8\u4F3C\u5EA6\uFF09",
|
|
1631
|
+
"structure.contradiction": "\u898F\u5247\u77DB\u76FE\uFF1A\u300C{{snippet}}\u300D\uFF08{{fileA}} \u7B2C {{lineA}} \u884C\uFF09\u8207\u7B2C {{lineB}} \u884C\u885D\u7A81\u3002",
|
|
1632
|
+
"structure.staleRef": "\u904E\u6642\u53C3\u7167\uFF1A\u300C{{path}}\u300D\u4E0D\u5B58\u5728\u3002",
|
|
1633
|
+
"structure.scopeHook": "\u7B2C {{line}} \u884C\u7684\u898F\u5247\u9069\u5408\u505A\u6210 git hook\uFF1A\u300C{{snippet}}\u300D",
|
|
1634
|
+
"structure.scopePathScoped": "\u7B2C {{line}} \u884C\u7684\u898F\u5247\u53C3\u7167\u4E86\u7279\u5B9A\u8DEF\u5F91 \u2014 \u5EFA\u8B70\u5EFA\u7ACB\u8DEF\u5F91\u7BC4\u570D\u7684\u898F\u5247\u6A94\uFF1A\u300C{{snippet}}\u300D",
|
|
1635
|
+
"label.budget": "\u9810\u7B97",
|
|
1636
|
+
"label.findings": "\u554F\u984C\u7E3D\u89BD",
|
|
1637
|
+
"label.topIssues": "\u9996\u8981\u554F\u984C",
|
|
1638
|
+
"label.category": "Category",
|
|
1639
|
+
"compact.budgetLine": "{{used}} / {{window}} tokens ({{pct}}%)",
|
|
1640
|
+
"compact.andMore": "\u2026 \u9084\u6709 {{count}} \u500B\uFF08\u57F7\u884C instrlint <command> \u67E5\u770B\u8A73\u60C5\uFF09",
|
|
1641
|
+
"compact.budget": "Budget",
|
|
1642
|
+
"compact.deadRules": "Dead rules",
|
|
1643
|
+
"compact.duplicates": "Duplicates",
|
|
1644
|
+
"compact.contradictions": "Contradictions",
|
|
1645
|
+
"compact.staleRefs": "Stale refs",
|
|
1646
|
+
"compact.structure": "Structure",
|
|
1647
|
+
"markdown.title": "instrlint \u5065\u5EB7\u5831\u544A \u2014 {{project}}",
|
|
1648
|
+
"markdown.scoreLine": "**\u5206\u6578\uFF1A{{score}}/100 ({{grade}})** \xB7 \u5DE5\u5177\uFF1A`{{tool}}`",
|
|
1649
|
+
"markdown.summary": "## \u6458\u8981",
|
|
1650
|
+
"markdown.severity": "\u56B4\u91CD\u7A0B\u5EA6",
|
|
1651
|
+
"markdown.count": "\u6578\u91CF",
|
|
1652
|
+
"markdown.critical": "\u{1F534} \u56B4\u91CD",
|
|
1653
|
+
"markdown.warning": "\u{1F7E1} \u8B66\u544A",
|
|
1654
|
+
"markdown.info": "\u2139\uFE0F \u8CC7\u8A0A",
|
|
1655
|
+
"markdown.contradictions": "\u898F\u5247\u77DB\u76FE",
|
|
1656
|
+
"markdown.staleReferences": "\u904E\u6642\u53C3\u7167",
|
|
1657
|
+
"markdown.deadRules": "\u5197\u9918\u898F\u5247",
|
|
1658
|
+
"markdown.duplicateRules": "\u91CD\u8907\u9805\u76EE",
|
|
1659
|
+
"markdown.budgetIssues": "Token \u9810\u7B97\u554F\u984C",
|
|
1660
|
+
"markdown.refactoringOpportunities": "\u91CD\u69CB\u5EFA\u8B70",
|
|
1661
|
+
"markdown.lineRef": "\uFF08\u7B2C {{line}} \u884C\uFF09",
|
|
1662
|
+
"markdown.actionPlan": "## \u884C\u52D5\u8A08\u756B",
|
|
1663
|
+
"markdown.attribution": "*\u7531 [instrlint](https://github.com/jed1978/instrlint) \u751F\u6210*",
|
|
1664
|
+
"ci.passed": "\u2713 instrlint \u901A\u904E \u5206\u6578={{score}} \u7B49\u7D1A={{grade}}",
|
|
1665
|
+
"ci.failed": "\u2716 instrlint \u672A\u901A\u904E \u5206\u6578={{score}} \u7B49\u7D1A={{grade}}",
|
|
1666
|
+
"ci.writtenTo": "\u2192 \u5DF2\u5BEB\u5165 {{file}}",
|
|
1667
|
+
"initCi.created": "\u2713 \u5DF2\u5EFA\u7ACB {{path}}",
|
|
1668
|
+
"initCi.alreadyExists": "{{path}} \u5DF2\u5B58\u5728\u3002\u4F7F\u7528 --force \u8986\u84CB\u3002",
|
|
1669
|
+
"install.installed": "\u2713 \u5DF2\u5B89\u88DD\u81F3 {{path}}",
|
|
1670
|
+
"install.alreadyExists": "{{path}} \u5DF2\u5B58\u5728\u3002\u4F7F\u7528 --force \u8986\u84CB\u3002",
|
|
1671
|
+
"install.unknownTarget": "\u8ACB\u6307\u5B9A --claude-code \u6216 --codex",
|
|
1672
|
+
"fix.manualActions": "\u9700\u8981\u624B\u52D5\u64CD\u4F5C",
|
|
1673
|
+
"fix.hookCreate": "\u52A0\u5165 .claude/settings.json\uFF1A",
|
|
1674
|
+
"fix.hookWarning": "\u26A0 Hook \u6703\u57F7\u884C shell command\uFF0C\u8ACB\u4ED4\u7D30\u78BA\u8A8D\u5F8C\u518D\u52A0\u5165",
|
|
1675
|
+
"fix.pathScopedCreate": "\u5EFA\u7ACB {{path}}\uFF1A",
|
|
1676
|
+
"fix.thenRemoveLine": "\u642C\u79FB\u5F8C\u8ACB\u5F9E {{file}} \u7B2C {{line}} \u884C\u522A\u9664\u539F\u898F\u5247"
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1679
|
+
// src/i18n/index.ts
|
|
1680
|
+
var LOCALE_MAP = {
|
|
1681
|
+
en: en_default,
|
|
1682
|
+
"zh-TW": zh_TW_default
|
|
1683
|
+
};
|
|
1684
|
+
var _locale = "en";
|
|
1685
|
+
var _messages = LOCALE_MAP["en"];
|
|
1686
|
+
function detectLocale() {
|
|
1687
|
+
const env = process.env["INSTRLINT_LANG"];
|
|
1688
|
+
if (env === "zh-TW" || env === "en") return env;
|
|
1689
|
+
try {
|
|
1690
|
+
const sys = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
1691
|
+
if (sys.startsWith("zh")) return "zh-TW";
|
|
1692
|
+
} catch {
|
|
1693
|
+
}
|
|
1694
|
+
return "en";
|
|
1695
|
+
}
|
|
1696
|
+
function initLocale(lang) {
|
|
1697
|
+
const valid = ["en", "zh-TW"];
|
|
1698
|
+
const resolved = valid.includes(lang) ? lang : detectLocale();
|
|
1699
|
+
_locale = resolved;
|
|
1700
|
+
_messages = LOCALE_MAP[resolved];
|
|
1701
|
+
}
|
|
1702
|
+
function getLocale() {
|
|
1703
|
+
return _locale;
|
|
1704
|
+
}
|
|
1705
|
+
function t(key, params) {
|
|
1706
|
+
const template = _messages[key] ?? key;
|
|
1707
|
+
if (!params) return template;
|
|
1708
|
+
return template.replace(
|
|
1709
|
+
/\{\{(\w+)\}\}/g,
|
|
1710
|
+
(_, k) => params[k] ?? `{{${k}}}`
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
function plural(count) {
|
|
1714
|
+
return count === 1 ? "" : "s";
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// src/commands/budget-command.ts
|
|
1718
|
+
function getFmt() {
|
|
1719
|
+
return new Intl.NumberFormat(getLocale());
|
|
1720
|
+
}
|
|
1721
|
+
function formatTokens(count, method) {
|
|
1722
|
+
const formatted = getFmt().format(count);
|
|
1723
|
+
if (method === "measured") return t("tokens.measured", { count: formatted });
|
|
1724
|
+
return t("tokens.estimated", { count: formatted });
|
|
1725
|
+
}
|
|
1726
|
+
function bar(fraction, width = 24) {
|
|
1727
|
+
const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width);
|
|
1728
|
+
const empty = width - filled;
|
|
1729
|
+
return chalk.cyan("\u2588".repeat(filled)) + chalk.gray("\u2591".repeat(empty));
|
|
1730
|
+
}
|
|
1731
|
+
function pct(fraction) {
|
|
1732
|
+
return `${Math.round(fraction * 100)}%`;
|
|
1733
|
+
}
|
|
1734
|
+
function printBudgetTerminal(summary, findings, output = console) {
|
|
1735
|
+
const total = summary.totalBaseline;
|
|
1736
|
+
const window = total + summary.availableTokens;
|
|
1737
|
+
output.log("");
|
|
1738
|
+
output.log(chalk.bold.white(` ${t("label.tokenBudget")}`));
|
|
1739
|
+
output.log(chalk.gray(" \u2500".repeat(30)));
|
|
1740
|
+
const rows = [
|
|
1741
|
+
{
|
|
1742
|
+
labelKey: "label.systemPrompt",
|
|
1743
|
+
tokens: summary.systemPromptTokens,
|
|
1744
|
+
method: "estimated"
|
|
1745
|
+
},
|
|
1746
|
+
{
|
|
1747
|
+
labelKey: "label.rootFile",
|
|
1748
|
+
tokens: summary.rootFileTokens,
|
|
1749
|
+
method: summary.rootFileMethod
|
|
1750
|
+
},
|
|
1751
|
+
{
|
|
1752
|
+
labelKey: "label.ruleFiles",
|
|
1753
|
+
tokens: summary.rulesTokens,
|
|
1754
|
+
method: summary.rulesMethod
|
|
1755
|
+
},
|
|
1756
|
+
{
|
|
1757
|
+
labelKey: "label.skillFiles",
|
|
1758
|
+
tokens: summary.skillsTokens,
|
|
1759
|
+
method: summary.skillsMethod
|
|
1760
|
+
},
|
|
1761
|
+
{
|
|
1762
|
+
labelKey: "label.subDirFiles",
|
|
1763
|
+
tokens: summary.subFilesTokens,
|
|
1764
|
+
method: summary.subFilesMethod
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
labelKey: "label.mcpServers",
|
|
1768
|
+
tokens: summary.mcpTokens,
|
|
1769
|
+
method: "estimated"
|
|
1770
|
+
}
|
|
1771
|
+
];
|
|
1772
|
+
for (const row of rows) {
|
|
1773
|
+
if (row.tokens === 0) continue;
|
|
1774
|
+
const fraction = row.tokens / window;
|
|
1775
|
+
const label = t(row.labelKey).padEnd(14);
|
|
1776
|
+
const tokenStr = formatTokens(row.tokens, row.method).padStart(28);
|
|
1777
|
+
output.log(
|
|
1778
|
+
` ${chalk.white(label)} ${bar(fraction)} ${chalk.yellow(tokenStr)}`
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
output.log(chalk.gray(" \u2500".repeat(30)));
|
|
1782
|
+
const baselineFraction = total / window;
|
|
1783
|
+
const baselineStr = formatTokens(total, summary.tokenMethod).padStart(28);
|
|
1784
|
+
output.log(
|
|
1785
|
+
` ${t("label.baselineTotal").padEnd(14)} ${bar(baselineFraction)} ${chalk.bold.yellow(baselineStr)} ${chalk.gray(pct(baselineFraction))}`
|
|
1786
|
+
);
|
|
1787
|
+
const availStr = formatTokens(summary.availableTokens, "estimated").padStart(
|
|
1788
|
+
28
|
|
1789
|
+
);
|
|
1790
|
+
output.log(
|
|
1791
|
+
` ${t("label.available").padEnd(14)} ${"".padEnd(26)} ${chalk.green(availStr)}`
|
|
1792
|
+
);
|
|
1793
|
+
output.log("");
|
|
1794
|
+
if (findings.length === 0) {
|
|
1795
|
+
output.log(chalk.green(` ${t("status.noBudgetIssues")}`));
|
|
1796
|
+
} else {
|
|
1797
|
+
for (const f of findings) {
|
|
1798
|
+
const icon = f.severity === "critical" ? chalk.red(" \u2716") : f.severity === "warning" ? chalk.yellow(" \u26A0") : chalk.blue(" \u2139");
|
|
1799
|
+
output.log(`${icon} ${t(f.messageKey, f.messageParams)}`);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
output.log("");
|
|
1803
|
+
}
|
|
1804
|
+
async function runBudget(opts, output = console) {
|
|
1805
|
+
initLocale(opts.lang);
|
|
1806
|
+
await ensureInitialized();
|
|
1807
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
1808
|
+
const scan = scanProject(projectRoot, opts.tool);
|
|
1809
|
+
if (scan.tool === "unknown") {
|
|
1810
|
+
output.error(t("error.unknownTool"));
|
|
1811
|
+
return { exitCode: 1, errorMessage: "unknown tool" };
|
|
1812
|
+
}
|
|
1813
|
+
if (scan.rootFilePath === null) {
|
|
1814
|
+
output.error(t("error.missingRootFile", { tool: scan.tool }));
|
|
1815
|
+
return { exitCode: 1, errorMessage: "missing root file" };
|
|
1816
|
+
}
|
|
1817
|
+
const instructions = loadProject(projectRoot, scan.tool);
|
|
1818
|
+
const { findings, summary } = analyzeBudget(instructions);
|
|
1819
|
+
if (opts.format === "json") {
|
|
1820
|
+
output.log(JSON.stringify({ findings, summary }, null, 2));
|
|
1821
|
+
return { exitCode: 0 };
|
|
1822
|
+
}
|
|
1823
|
+
printBudgetTerminal(summary, findings, output);
|
|
1824
|
+
return { exitCode: 0 };
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// src/core/reporter.ts
|
|
1828
|
+
var BOX_W = 50;
|
|
1829
|
+
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
1830
|
+
function visLen(s) {
|
|
1831
|
+
return s.replace(ANSI_RE, "").length;
|
|
1832
|
+
}
|
|
1833
|
+
function padR(s, w) {
|
|
1834
|
+
return s + " ".repeat(Math.max(0, w - visLen(s)));
|
|
1835
|
+
}
|
|
1836
|
+
function gradeColor(grade) {
|
|
1837
|
+
if (grade === "A") return chalk2.green;
|
|
1838
|
+
if (grade === "B") return chalk2.cyan;
|
|
1839
|
+
if (grade === "C") return chalk2.yellow;
|
|
1840
|
+
if (grade === "D") return chalk2.magenta;
|
|
1841
|
+
return chalk2.red;
|
|
1842
|
+
}
|
|
1843
|
+
function gradeBadge(grade) {
|
|
1844
|
+
if (grade === "A") return chalk2.bgGreen(chalk2.bold.black(` ${grade} `));
|
|
1845
|
+
if (grade === "B") return chalk2.bgCyan(chalk2.bold.black(` ${grade} `));
|
|
1846
|
+
if (grade === "C") return chalk2.bgYellow(chalk2.bold.black(` ${grade} `));
|
|
1847
|
+
if (grade === "D") return chalk2.bgMagenta(chalk2.bold.white(` ${grade} `));
|
|
1848
|
+
return chalk2.bgRed(chalk2.bold.white(` ${grade} `));
|
|
1849
|
+
}
|
|
1850
|
+
function scoreBar(score, grade, width = 30) {
|
|
1851
|
+
const filled = Math.round(score / 100 * width);
|
|
1852
|
+
const empty = width - filled;
|
|
1853
|
+
return gradeColor(grade)("\u2588".repeat(filled)) + chalk2.gray("\u2591".repeat(empty));
|
|
1854
|
+
}
|
|
1855
|
+
function sectionHeader(title, width = BOX_W) {
|
|
1856
|
+
const inner = ` ${title} `;
|
|
1857
|
+
const remaining = Math.max(0, width - 2 - inner.length);
|
|
1858
|
+
return chalk2.gray(` \u2500\u2500${chalk2.bold.white(inner)}${"\u2500".repeat(remaining)}\u2500\u2500`);
|
|
1859
|
+
}
|
|
1860
|
+
function printCompactBudget(summary, output) {
|
|
1861
|
+
const total = summary.totalBaseline;
|
|
1862
|
+
const window = total + summary.availableTokens;
|
|
1863
|
+
const fraction = total / window;
|
|
1864
|
+
const fmt = new Intl.NumberFormat(getLocale());
|
|
1865
|
+
const usedPrefix = summary.tokenMethod === "estimated" ? "~" : "";
|
|
1866
|
+
const budgetLine = t("compact.budgetLine", {
|
|
1867
|
+
used: `${usedPrefix}${fmt.format(total)}`,
|
|
1868
|
+
window: fmt.format(window),
|
|
1869
|
+
pct: String(Math.round(fraction * 100))
|
|
1870
|
+
});
|
|
1871
|
+
output.log(` ${chalk2.yellow(budgetLine)} ${bar(fraction, 14)}`);
|
|
1872
|
+
}
|
|
1873
|
+
var SEVERITY_ORDER = {
|
|
1874
|
+
critical: 0,
|
|
1875
|
+
warning: 1,
|
|
1876
|
+
info: 2
|
|
1877
|
+
};
|
|
1878
|
+
function printFindingsTable(findings, output) {
|
|
1879
|
+
const categories = [
|
|
1880
|
+
{ key: "contradiction", label: t("compact.contradictions") },
|
|
1881
|
+
{ key: "budget", label: t("compact.budget") },
|
|
1882
|
+
{ key: "dead-rule", label: t("compact.deadRules") },
|
|
1883
|
+
{ key: "duplicate", label: t("compact.duplicates") },
|
|
1884
|
+
{ key: "stale-ref", label: t("compact.staleRefs") },
|
|
1885
|
+
{ key: "structure", label: t("compact.structure") }
|
|
1886
|
+
];
|
|
1887
|
+
const rows = categories.map(({ key, label }) => {
|
|
1888
|
+
const group = findings.filter((f) => f.category === key);
|
|
1889
|
+
return {
|
|
1890
|
+
label,
|
|
1891
|
+
critical: group.filter((f) => f.severity === "critical").length,
|
|
1892
|
+
warning: group.filter((f) => f.severity === "warning").length,
|
|
1893
|
+
info: group.filter((f) => f.severity === "info").length
|
|
1894
|
+
};
|
|
1895
|
+
}).filter((r) => r.critical + r.warning + r.info > 0);
|
|
1896
|
+
if (rows.length === 0) return;
|
|
1897
|
+
output.log(sectionHeader(t("label.findings")));
|
|
1898
|
+
for (const row of rows) {
|
|
1899
|
+
const parts = [];
|
|
1900
|
+
if (row.critical > 0) parts.push(chalk2.red(`\u2716 ${row.critical}`));
|
|
1901
|
+
if (row.warning > 0) parts.push(chalk2.yellow(`\u26A0 ${row.warning}`));
|
|
1902
|
+
if (row.info > 0) parts.push(chalk2.blue(`\u2139 ${row.info}`));
|
|
1903
|
+
output.log(
|
|
1904
|
+
` ${chalk2.whiteBright(row.label.padEnd(18))}${parts.join(" ")}`
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
function printTopIssues(findings, output) {
|
|
1909
|
+
if (findings.length === 0) return;
|
|
1910
|
+
const sorted = [...findings].sort(
|
|
1911
|
+
(a, b) => (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3)
|
|
1912
|
+
);
|
|
1913
|
+
const top = sorted.slice(0, 5);
|
|
1914
|
+
output.log("");
|
|
1915
|
+
output.log(sectionHeader(t("label.topIssues")));
|
|
1916
|
+
for (let i = 0; i < top.length; i++) {
|
|
1917
|
+
const f = top[i];
|
|
1918
|
+
const icon = f.severity === "critical" ? chalk2.red("\u2716") : f.severity === "warning" ? chalk2.yellow("\u26A0") : chalk2.blue("\u2139");
|
|
1919
|
+
const msg = t(f.messageKey, f.messageParams);
|
|
1920
|
+
const truncated = msg.length > 68 ? `${msg.slice(0, 68)}\u2026` : msg;
|
|
1921
|
+
output.log(` ${chalk2.white(`${i + 1}.`)} ${icon} ${truncated}`);
|
|
1922
|
+
}
|
|
1923
|
+
if (sorted.length > 5) {
|
|
1924
|
+
output.log(
|
|
1925
|
+
chalk2.white(
|
|
1926
|
+
` ${t("compact.andMore", { count: String(sorted.length - 5) })}`
|
|
1927
|
+
)
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
function printCombinedTerminal(report, output = console) {
|
|
1932
|
+
const { project, tool, score, grade, tokenMethod } = report;
|
|
1933
|
+
const border = "\u2500".repeat(BOX_W);
|
|
1934
|
+
output.log("");
|
|
1935
|
+
const B = chalk2.green;
|
|
1936
|
+
output.log(B(` \u256D${border}\u256E`));
|
|
1937
|
+
const line1 = ` ${chalk2.bold.white("instrlint")} ${B("\u2500")} ${chalk2.cyan(project)}`;
|
|
1938
|
+
output.log(` ${B("\u2502")}${padR(line1, BOX_W)}${B("\u2502")}`);
|
|
1939
|
+
const line2 = ` ${chalk2.white(tool)} ${B("\xB7")} ${chalk2.white(tokenMethod)}`;
|
|
1940
|
+
output.log(` ${B("\u2502")}${padR(line2, BOX_W)}${B("\u2502")}`);
|
|
1941
|
+
output.log(B(` \u251C${border}\u2524`));
|
|
1942
|
+
const scoreLine = ` ${scoreBar(score, grade)} ${chalk2.bold.white(String(score))}/100 ${gradeBadge(grade)}`;
|
|
1943
|
+
output.log(` ${B("\u2502")}${padR(scoreLine, BOX_W)}${B("\u2502")}`);
|
|
1944
|
+
output.log(B(` \u2570${border}\u256F`));
|
|
1945
|
+
output.log("");
|
|
1946
|
+
output.log(sectionHeader(t("label.budget")));
|
|
1947
|
+
printCompactBudget(report.budget, output);
|
|
1948
|
+
if (report.findings.length > 0) {
|
|
1949
|
+
output.log("");
|
|
1950
|
+
printFindingsTable(report.findings, output);
|
|
1951
|
+
printTopIssues(report.findings, output);
|
|
1952
|
+
}
|
|
1953
|
+
output.log("");
|
|
1954
|
+
if (report.findings.length === 0) {
|
|
1955
|
+
output.log(chalk2.green(` ${t("status.perfectScore")}`));
|
|
1956
|
+
} else {
|
|
1957
|
+
const criticals = report.findings.filter(
|
|
1958
|
+
(f) => f.severity === "critical"
|
|
1959
|
+
).length;
|
|
1960
|
+
const warnings = report.findings.filter(
|
|
1961
|
+
(f) => f.severity === "warning"
|
|
1962
|
+
).length;
|
|
1963
|
+
const infos = report.findings.filter((f) => f.severity === "info").length;
|
|
1964
|
+
const parts = [];
|
|
1965
|
+
if (criticals > 0)
|
|
1966
|
+
parts.push(
|
|
1967
|
+
chalk2.red(t("severity.critical", { count: String(criticals) }))
|
|
1968
|
+
);
|
|
1969
|
+
if (warnings > 0)
|
|
1970
|
+
parts.push(
|
|
1971
|
+
chalk2.yellow(
|
|
1972
|
+
t("severity.warnings", {
|
|
1973
|
+
count: String(warnings),
|
|
1974
|
+
s: plural(warnings)
|
|
1975
|
+
})
|
|
1976
|
+
)
|
|
1977
|
+
);
|
|
1978
|
+
if (infos > 0)
|
|
1979
|
+
parts.push(
|
|
1980
|
+
chalk2.blue(
|
|
1981
|
+
t("severity.suggestions", { count: String(infos), s: plural(infos) })
|
|
1982
|
+
)
|
|
1983
|
+
);
|
|
1984
|
+
const summary = parts.join(chalk2.gray(" \xB7 "));
|
|
1985
|
+
const summaryVisible = summary.replace(ANSI_RE, "");
|
|
1986
|
+
const pad = Math.max(0, BOX_W - 2 - summaryVisible.length);
|
|
1987
|
+
output.log(
|
|
1988
|
+
chalk2.gray(` \u2500\u2500`) + ` ${summary} ` + chalk2.gray("\u2500".repeat(pad))
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
output.log("");
|
|
1992
|
+
}
|
|
1993
|
+
function reportJson(report) {
|
|
1994
|
+
return JSON.stringify(report, null, 2);
|
|
1995
|
+
}
|
|
1996
|
+
function mdSeverityIcon(f) {
|
|
1997
|
+
if (f.severity === "critical") return "\u{1F534}";
|
|
1998
|
+
if (f.severity === "warning") return "\u{1F7E1}";
|
|
1999
|
+
return "\u2139\uFE0F";
|
|
2000
|
+
}
|
|
2001
|
+
function reportMarkdown(report, extraSections = []) {
|
|
2002
|
+
const { project, tool, score, grade, findings } = report;
|
|
2003
|
+
const criticals = findings.filter((f) => f.severity === "critical").length;
|
|
2004
|
+
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
2005
|
+
const infos = findings.filter((f) => f.severity === "info").length;
|
|
2006
|
+
const lines = [
|
|
2007
|
+
`# ${t("markdown.title", { project })}`,
|
|
2008
|
+
"",
|
|
2009
|
+
t("markdown.scoreLine", { score: String(score), grade, tool }),
|
|
2010
|
+
"",
|
|
2011
|
+
t("markdown.summary"),
|
|
2012
|
+
"",
|
|
2013
|
+
`| ${t("markdown.severity")} | ${t("markdown.count")} |`,
|
|
2014
|
+
"|----------|-------|",
|
|
2015
|
+
`| ${t("markdown.critical")} | ${criticals} |`,
|
|
2016
|
+
`| ${t("markdown.warning")} | ${warnings} |`,
|
|
2017
|
+
`| ${t("markdown.info")} | ${infos} |`,
|
|
2018
|
+
""
|
|
2019
|
+
];
|
|
2020
|
+
const categories = [
|
|
2021
|
+
{
|
|
2022
|
+
labelKey: "markdown.contradictions",
|
|
2023
|
+
filter: (f) => f.category === "contradiction"
|
|
2024
|
+
},
|
|
2025
|
+
{
|
|
2026
|
+
labelKey: "markdown.staleReferences",
|
|
2027
|
+
filter: (f) => f.category === "stale-ref"
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
labelKey: "markdown.deadRules",
|
|
2031
|
+
filter: (f) => f.category === "dead-rule"
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
labelKey: "markdown.duplicateRules",
|
|
2035
|
+
filter: (f) => f.category === "duplicate"
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
labelKey: "markdown.budgetIssues",
|
|
2039
|
+
filter: (f) => f.category === "budget"
|
|
2040
|
+
},
|
|
2041
|
+
{
|
|
2042
|
+
labelKey: "markdown.refactoringOpportunities",
|
|
2043
|
+
filter: (f) => f.category === "structure"
|
|
2044
|
+
}
|
|
2045
|
+
];
|
|
2046
|
+
for (const { labelKey, filter } of categories) {
|
|
2047
|
+
const group = findings.filter(filter);
|
|
2048
|
+
if (group.length === 0) continue;
|
|
2049
|
+
lines.push(`## ${t(labelKey)}`, "");
|
|
2050
|
+
for (const f of group) {
|
|
2051
|
+
const loc = f.line != null ? ` ${t("markdown.lineRef", { line: String(f.line) })}` : "";
|
|
2052
|
+
lines.push(
|
|
2053
|
+
`- ${mdSeverityIcon(f)} ${t(f.messageKey, f.messageParams)}${loc}`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
lines.push("");
|
|
2057
|
+
}
|
|
2058
|
+
if (report.actionPlan.length > 0) {
|
|
2059
|
+
lines.push(t("markdown.actionPlan"), "");
|
|
2060
|
+
for (let i = 0; i < Math.min(report.actionPlan.length, 10); i++) {
|
|
2061
|
+
const item = report.actionPlan[i];
|
|
2062
|
+
lines.push(`${i + 1}. ${item.description}`);
|
|
2063
|
+
}
|
|
2064
|
+
lines.push("");
|
|
2065
|
+
}
|
|
2066
|
+
if (extraSections.length > 0) {
|
|
2067
|
+
lines.push(...extraSections);
|
|
2068
|
+
}
|
|
2069
|
+
lines.push("---", t("markdown.attribution"));
|
|
2070
|
+
return lines.join("\n");
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// src/fixers/line-remover.ts
|
|
2074
|
+
import { readFileSync as readFileSync6, writeFileSync } from "fs";
|
|
2075
|
+
function removeLines(findings, categories) {
|
|
2076
|
+
const catSet = new Set(categories);
|
|
2077
|
+
const fixable = findings.filter(
|
|
2078
|
+
(f) => catSet.has(f.category) && f.autoFixable && f.line != null
|
|
2079
|
+
);
|
|
2080
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2081
|
+
for (const f of fixable) {
|
|
2082
|
+
const arr = byFile.get(f.file) ?? [];
|
|
2083
|
+
arr.push(f.line);
|
|
2084
|
+
byFile.set(f.file, arr);
|
|
2085
|
+
}
|
|
2086
|
+
let totalFixed = 0;
|
|
2087
|
+
for (const [filePath, lineNumbers] of byFile) {
|
|
2088
|
+
const uniqueSorted = [...new Set(lineNumbers)].sort((a, b) => b - a);
|
|
2089
|
+
const content = readFileSync6(filePath, "utf8");
|
|
2090
|
+
const lines = content.split("\n");
|
|
2091
|
+
for (const lineNum of uniqueSorted) {
|
|
2092
|
+
const idx = lineNum - 1;
|
|
2093
|
+
if (idx >= 0 && idx < lines.length) {
|
|
2094
|
+
lines.splice(idx, 1);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
writeFileSync(filePath, lines.join("\n"));
|
|
2098
|
+
totalFixed += uniqueSorted.length;
|
|
2099
|
+
}
|
|
2100
|
+
return totalFixed;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// src/fixers/remove-dead.ts
|
|
2104
|
+
function removeDeadRules(findings) {
|
|
2105
|
+
return removeLines(findings, ["dead-rule"]);
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// src/fixers/remove-stale.ts
|
|
2109
|
+
function removeStaleRefs(findings) {
|
|
2110
|
+
return removeLines(findings, ["stale-ref"]);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// src/fixers/deduplicate.ts
|
|
2114
|
+
function deduplicateRules(findings) {
|
|
2115
|
+
return removeLines(findings, ["duplicate"]);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// src/fixers/structure-suggestions.ts
|
|
2119
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
2120
|
+
import { relative as relative2 } from "path";
|
|
2121
|
+
import chalk3 from "chalk";
|
|
2122
|
+
function readFileLine(filePath, lineNumber) {
|
|
2123
|
+
try {
|
|
2124
|
+
const content = readFileSync7(filePath, "utf8");
|
|
2125
|
+
return content.split("\n")[lineNumber - 1]?.trim() ?? "";
|
|
2126
|
+
} catch {
|
|
2127
|
+
return "";
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
var PATH_DIR_RE = /\b(src|tests?|lib|dist)\//i;
|
|
2131
|
+
function extractPathDir(text) {
|
|
2132
|
+
const m = PATH_DIR_RE.exec(text);
|
|
2133
|
+
return m?.[1]?.toLowerCase();
|
|
2134
|
+
}
|
|
2135
|
+
function buildHookSnippet(ruleText) {
|
|
2136
|
+
const comment = ruleText.length > 80 ? `${ruleText.slice(0, 80)}\u2026` : ruleText;
|
|
2137
|
+
return JSON.stringify(
|
|
2138
|
+
{
|
|
2139
|
+
hooks: {
|
|
2140
|
+
PreToolUse: [
|
|
2141
|
+
{
|
|
2142
|
+
matcher: "Bash",
|
|
2143
|
+
hooks: [
|
|
2144
|
+
{
|
|
2145
|
+
type: "command",
|
|
2146
|
+
command: `# TODO: implement enforcement of:
|
|
2147
|
+
# ${comment}`
|
|
2148
|
+
}
|
|
2149
|
+
]
|
|
2150
|
+
}
|
|
2151
|
+
]
|
|
2152
|
+
}
|
|
2153
|
+
},
|
|
2154
|
+
null,
|
|
2155
|
+
2
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
function buildPathScopedFile(pathDir, ruleText) {
|
|
2159
|
+
const filePath = `.claude/rules/${pathDir}.md`;
|
|
2160
|
+
const content = `---
|
|
2161
|
+
globs:
|
|
2162
|
+
- "${pathDir}/**"
|
|
2163
|
+
---
|
|
2164
|
+
|
|
2165
|
+
${ruleText}
|
|
2166
|
+
`;
|
|
2167
|
+
return { filePath, content };
|
|
2168
|
+
}
|
|
2169
|
+
function buildStructureSuggestions(findings) {
|
|
2170
|
+
const suggestions = [];
|
|
2171
|
+
for (const finding of findings) {
|
|
2172
|
+
if (finding.category !== "structure") continue;
|
|
2173
|
+
if (finding.messageKey !== "structure.scopeHook" && finding.messageKey !== "structure.scopePathScoped") {
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
const ruleText = finding.line != null && finding.line > 0 ? readFileLine(finding.file, finding.line) || (finding.messageParams?.snippet ?? "") : finding.messageParams?.snippet ?? "";
|
|
2177
|
+
if (finding.messageKey === "structure.scopeHook") {
|
|
2178
|
+
suggestions.push({ finding, ruleText, type: "hook" });
|
|
2179
|
+
} else {
|
|
2180
|
+
const pathDir = extractPathDir(ruleText) || extractPathDir(finding.messageParams?.snippet ?? "") || "src";
|
|
2181
|
+
suggestions.push({ finding, ruleText, type: "path-scoped", pathDir });
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
return suggestions;
|
|
2185
|
+
}
|
|
2186
|
+
function terminalCodeBlock(code, output) {
|
|
2187
|
+
const lines = code.split("\n");
|
|
2188
|
+
output.log(chalk3.gray(" \u250C" + "\u2500".repeat(62)));
|
|
2189
|
+
for (const line of lines) {
|
|
2190
|
+
output.log(` ${chalk3.gray("\u2502")} ${chalk3.white(line)}`);
|
|
2191
|
+
}
|
|
2192
|
+
output.log(chalk3.gray(" \u2514" + "\u2500".repeat(62)));
|
|
2193
|
+
}
|
|
2194
|
+
function printStructureSuggestions(suggestions, projectRoot, output) {
|
|
2195
|
+
if (suggestions.length === 0) return;
|
|
2196
|
+
output.log("");
|
|
2197
|
+
output.log(chalk3.bold.white(` ${t("fix.manualActions")}`));
|
|
2198
|
+
output.log(chalk3.gray(" \u2500".repeat(30)));
|
|
2199
|
+
for (const s of suggestions) {
|
|
2200
|
+
const relFile = relative2(projectRoot, s.finding.file);
|
|
2201
|
+
const lineNum = s.finding.line ?? 0;
|
|
2202
|
+
output.log("");
|
|
2203
|
+
output.log(
|
|
2204
|
+
` ${chalk3.blue("\u2139")} ${chalk3.white(t(s.finding.messageKey, s.finding.messageParams))}`
|
|
2205
|
+
);
|
|
2206
|
+
output.log("");
|
|
2207
|
+
if (s.type === "hook") {
|
|
2208
|
+
output.log(` ${chalk3.cyan(t("fix.hookCreate"))}`);
|
|
2209
|
+
terminalCodeBlock(buildHookSnippet(s.ruleText), output);
|
|
2210
|
+
output.log(` ${chalk3.yellow(t("fix.hookWarning"))}`);
|
|
2211
|
+
if (lineNum > 0) {
|
|
2212
|
+
output.log(
|
|
2213
|
+
` ${chalk3.gray(t("fix.thenRemoveLine", { line: String(lineNum), file: relFile }))}`
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
} else {
|
|
2217
|
+
const dir = s.pathDir ?? "src";
|
|
2218
|
+
const { filePath, content } = buildPathScopedFile(dir, s.ruleText);
|
|
2219
|
+
output.log(` ${chalk3.cyan(t("fix.pathScopedCreate", { path: filePath }))}`);
|
|
2220
|
+
terminalCodeBlock(content, output);
|
|
2221
|
+
if (lineNum > 0) {
|
|
2222
|
+
output.log(
|
|
2223
|
+
` ${chalk3.gray(t("fix.thenRemoveLine", { line: String(lineNum), file: relFile }))}`
|
|
2224
|
+
);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
output.log("");
|
|
2229
|
+
}
|
|
2230
|
+
function markdownStructureSuggestions(suggestions, projectRoot) {
|
|
2231
|
+
if (suggestions.length === 0) return [];
|
|
2232
|
+
const lines = [`## ${t("fix.manualActions")}`, ""];
|
|
2233
|
+
for (const s of suggestions) {
|
|
2234
|
+
const relFile = relative2(projectRoot, s.finding.file);
|
|
2235
|
+
const lineNum = s.finding.line ?? 0;
|
|
2236
|
+
const icon = s.finding.severity === "critical" ? "\u{1F534}" : s.finding.severity === "warning" ? "\u{1F7E1}" : "\u2139\uFE0F";
|
|
2237
|
+
lines.push(
|
|
2238
|
+
`### ${icon} ${t(s.finding.messageKey, s.finding.messageParams)}`,
|
|
2239
|
+
""
|
|
2240
|
+
);
|
|
2241
|
+
if (s.type === "hook") {
|
|
2242
|
+
lines.push(t("fix.hookCreate"), "", "```json", buildHookSnippet(s.ruleText), "```", "");
|
|
2243
|
+
lines.push(`> ${t("fix.hookWarning")}`, "");
|
|
2244
|
+
if (lineNum > 0) {
|
|
2245
|
+
lines.push(
|
|
2246
|
+
`_${t("fix.thenRemoveLine", { line: String(lineNum), file: relFile })}_`,
|
|
2247
|
+
""
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
} else {
|
|
2251
|
+
const dir = s.pathDir ?? "src";
|
|
2252
|
+
const { filePath, content } = buildPathScopedFile(dir, s.ruleText);
|
|
2253
|
+
lines.push(t("fix.pathScopedCreate", { path: filePath }), "");
|
|
2254
|
+
lines.push("```markdown", content, "```", "");
|
|
2255
|
+
if (lineNum > 0) {
|
|
2256
|
+
lines.push(
|
|
2257
|
+
`_${t("fix.thenRemoveLine", { line: String(lineNum), file: relFile })}_`,
|
|
2258
|
+
""
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
return lines;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
// src/commands/run-command.ts
|
|
2267
|
+
function isGitClean(cwd) {
|
|
2268
|
+
try {
|
|
2269
|
+
const out = execSync("git status --porcelain", { cwd, encoding: "utf8" });
|
|
2270
|
+
return out.trim().length === 0;
|
|
2271
|
+
} catch {
|
|
2272
|
+
return true;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
async function runAll(opts, output = console) {
|
|
2276
|
+
initLocale(opts.lang);
|
|
2277
|
+
await ensureInitialized();
|
|
2278
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
2279
|
+
const scan = scanProject(projectRoot, opts.tool);
|
|
2280
|
+
if (scan.tool === "unknown") {
|
|
2281
|
+
output.error(t("error.unknownTool"));
|
|
2282
|
+
return { exitCode: 1, errorMessage: "unknown tool" };
|
|
2283
|
+
}
|
|
2284
|
+
if (scan.rootFilePath === null) {
|
|
2285
|
+
output.error(t("error.missingRootFile", { tool: scan.tool }));
|
|
2286
|
+
return { exitCode: 1, errorMessage: "missing root file" };
|
|
2287
|
+
}
|
|
2288
|
+
if (opts.fix && !opts.force && !isGitClean(projectRoot)) {
|
|
2289
|
+
output.error(t("error.dirtyWorkingTree"));
|
|
2290
|
+
return { exitCode: 1, errorMessage: "dirty working tree" };
|
|
2291
|
+
}
|
|
2292
|
+
const instructions = loadProject(projectRoot, scan.tool);
|
|
2293
|
+
const { findings: budgetFindings, summary } = analyzeBudget(instructions);
|
|
2294
|
+
const { findings: deadRuleFindings } = analyzeDeadRules(
|
|
2295
|
+
instructions,
|
|
2296
|
+
projectRoot
|
|
2297
|
+
);
|
|
2298
|
+
const { findings: structureFindings } = analyzeStructure(
|
|
2299
|
+
instructions,
|
|
2300
|
+
projectRoot
|
|
2301
|
+
);
|
|
2302
|
+
const allFindings = [
|
|
2303
|
+
...budgetFindings,
|
|
2304
|
+
...deadRuleFindings,
|
|
2305
|
+
...structureFindings
|
|
2306
|
+
];
|
|
2307
|
+
const { score, grade } = calculateScore(allFindings, summary);
|
|
2308
|
+
const actionPlan = buildActionPlan(allFindings);
|
|
2309
|
+
const report = {
|
|
2310
|
+
project: basename2(projectRoot),
|
|
2311
|
+
tool: instructions.tool,
|
|
2312
|
+
score,
|
|
2313
|
+
grade,
|
|
2314
|
+
locale: getLocale(),
|
|
2315
|
+
tokenMethod: summary.tokenMethod,
|
|
2316
|
+
findings: allFindings,
|
|
2317
|
+
budget: summary,
|
|
2318
|
+
actionPlan
|
|
2319
|
+
};
|
|
2320
|
+
if (opts.fix) {
|
|
2321
|
+
const suggestions = buildStructureSuggestions(allFindings);
|
|
2322
|
+
const deadFixed = removeDeadRules(allFindings);
|
|
2323
|
+
const staleFixed = removeStaleRefs(allFindings);
|
|
2324
|
+
const dupeFixed = deduplicateRules(allFindings);
|
|
2325
|
+
const total = deadFixed + staleFixed + dupeFixed;
|
|
2326
|
+
if (total === 0) {
|
|
2327
|
+
output.log(chalk4.green(` ${t("status.noAutoFixable")}`));
|
|
2328
|
+
} else {
|
|
2329
|
+
output.log("");
|
|
2330
|
+
output.log(chalk4.bold.white(` ${t("label.fixSummary")}`));
|
|
2331
|
+
output.log(chalk4.gray(" \u2500".repeat(30)));
|
|
2332
|
+
if (deadFixed > 0)
|
|
2333
|
+
output.log(
|
|
2334
|
+
` ${chalk4.yellow("\u26A0")} ${t("fix.removedDeadRules", { count: String(deadFixed), s: plural(deadFixed) })}`
|
|
2335
|
+
);
|
|
2336
|
+
if (staleFixed > 0)
|
|
2337
|
+
output.log(
|
|
2338
|
+
` ${chalk4.yellow("\u26A0")} ${t("fix.removedStaleRefs", { count: String(staleFixed), s: plural(staleFixed) })}`
|
|
2339
|
+
);
|
|
2340
|
+
if (dupeFixed > 0)
|
|
2341
|
+
output.log(
|
|
2342
|
+
` ${chalk4.yellow("\u26A0")} ${t("fix.removedDuplicates", { count: String(dupeFixed), s: plural(dupeFixed) })}`
|
|
2343
|
+
);
|
|
2344
|
+
output.log(chalk4.gray(" \u2500".repeat(30)));
|
|
2345
|
+
output.log(
|
|
2346
|
+
chalk4.green(
|
|
2347
|
+
` ${t("status.fixedIssues", { count: String(total), s: plural(total) })}`
|
|
2348
|
+
)
|
|
2349
|
+
);
|
|
2350
|
+
output.log("");
|
|
2351
|
+
}
|
|
2352
|
+
printStructureSuggestions(suggestions, projectRoot, output);
|
|
2353
|
+
return { exitCode: 0 };
|
|
2354
|
+
}
|
|
2355
|
+
if (opts.format === "json") {
|
|
2356
|
+
output.log(reportJson(report));
|
|
2357
|
+
return { exitCode: 0 };
|
|
2358
|
+
}
|
|
2359
|
+
if (opts.format === "markdown") {
|
|
2360
|
+
const mdSuggestions = buildStructureSuggestions(allFindings);
|
|
2361
|
+
const mdExtra = markdownStructureSuggestions(mdSuggestions, projectRoot);
|
|
2362
|
+
output.log(reportMarkdown(report, mdExtra));
|
|
2363
|
+
return { exitCode: 0 };
|
|
2364
|
+
}
|
|
2365
|
+
printCombinedTerminal(report, output);
|
|
2366
|
+
return { exitCode: 0 };
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// src/commands/deadrules-command.ts
|
|
2370
|
+
import chalk5 from "chalk";
|
|
2371
|
+
function printDeadRulesTerminal(findings, output = console) {
|
|
2372
|
+
const overlaps = findings.filter((f) => f.category === "dead-rule");
|
|
2373
|
+
const duplicates = findings.filter((f) => f.category === "duplicate");
|
|
2374
|
+
output.log("");
|
|
2375
|
+
output.log(chalk5.bold.white(` ${t("label.deadRules")}`));
|
|
2376
|
+
output.log(chalk5.gray(" \u2500".repeat(30)));
|
|
2377
|
+
if (overlaps.length > 0) {
|
|
2378
|
+
output.log(chalk5.bold(` ${t("label.redundantByConfig")}`));
|
|
2379
|
+
for (const f of overlaps) {
|
|
2380
|
+
output.log(` ${chalk5.yellow("\u26A0")} ${t(f.messageKey, f.messageParams)}`);
|
|
2381
|
+
}
|
|
2382
|
+
output.log("");
|
|
2383
|
+
}
|
|
2384
|
+
if (duplicates.length > 0) {
|
|
2385
|
+
output.log(chalk5.bold(` ${t("label.duplicates")}`));
|
|
2386
|
+
for (const f of duplicates) {
|
|
2387
|
+
const icon = f.severity === "warning" ? chalk5.yellow("\u26A0") : chalk5.blue("\u2139");
|
|
2388
|
+
output.log(` ${icon} ${t(f.messageKey, f.messageParams)}`);
|
|
2389
|
+
}
|
|
2390
|
+
output.log("");
|
|
2391
|
+
}
|
|
2392
|
+
output.log(chalk5.gray(" \u2500".repeat(30)));
|
|
2393
|
+
if (findings.length === 0) {
|
|
2394
|
+
output.log(chalk5.green(` ${t("status.noDeadRules")}`));
|
|
2395
|
+
} else {
|
|
2396
|
+
const sep = getLocale() === "zh-TW" ? "\u3001" : ", ";
|
|
2397
|
+
const parts = [];
|
|
2398
|
+
if (overlaps.length > 0)
|
|
2399
|
+
parts.push(
|
|
2400
|
+
t("summary.redundantRules", {
|
|
2401
|
+
count: String(overlaps.length),
|
|
2402
|
+
s: plural(overlaps.length)
|
|
2403
|
+
})
|
|
2404
|
+
);
|
|
2405
|
+
if (duplicates.length > 0)
|
|
2406
|
+
parts.push(
|
|
2407
|
+
t("summary.duplicates", {
|
|
2408
|
+
count: String(duplicates.length),
|
|
2409
|
+
s: plural(duplicates.length)
|
|
2410
|
+
})
|
|
2411
|
+
);
|
|
2412
|
+
output.log(
|
|
2413
|
+
chalk5.yellow(` ${t("summary.found", { parts: parts.join(sep) })}`)
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
output.log("");
|
|
2417
|
+
}
|
|
2418
|
+
async function runDeadRules(opts, output = console) {
|
|
2419
|
+
initLocale(opts.lang);
|
|
2420
|
+
await ensureInitialized();
|
|
2421
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
2422
|
+
const scan = scanProject(projectRoot, opts.tool);
|
|
2423
|
+
if (scan.tool === "unknown") {
|
|
2424
|
+
output.error(t("error.unknownTool"));
|
|
2425
|
+
return { exitCode: 1, errorMessage: "unknown tool" };
|
|
2426
|
+
}
|
|
2427
|
+
if (scan.rootFilePath === null) {
|
|
2428
|
+
output.error(t("error.missingRootFile", { tool: scan.tool }));
|
|
2429
|
+
return { exitCode: 1, errorMessage: "missing root file" };
|
|
2430
|
+
}
|
|
2431
|
+
const instructions = loadProject(projectRoot, scan.tool);
|
|
2432
|
+
const { findings } = analyzeDeadRules(instructions, projectRoot);
|
|
2433
|
+
if (opts.format === "json") {
|
|
2434
|
+
output.log(JSON.stringify({ findings }, null, 2));
|
|
2435
|
+
return { exitCode: 0 };
|
|
2436
|
+
}
|
|
2437
|
+
printDeadRulesTerminal(findings, output);
|
|
2438
|
+
return { exitCode: 0 };
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// src/commands/structure-command.ts
|
|
2442
|
+
import chalk6 from "chalk";
|
|
2443
|
+
function printStructureTerminal(findings, output = console) {
|
|
2444
|
+
const contradictions = findings.filter((f) => f.category === "contradiction");
|
|
2445
|
+
const staleRefs = findings.filter((f) => f.category === "stale-ref");
|
|
2446
|
+
const scoped = findings.filter((f) => f.category === "structure");
|
|
2447
|
+
output.log("");
|
|
2448
|
+
output.log(chalk6.bold.white(` ${t("label.structure")}`));
|
|
2449
|
+
output.log(chalk6.gray(" \u2500".repeat(30)));
|
|
2450
|
+
if (contradictions.length > 0) {
|
|
2451
|
+
output.log(chalk6.bold(` ${t("label.contradictions")}`));
|
|
2452
|
+
for (const f of contradictions) {
|
|
2453
|
+
output.log(` ${chalk6.red("\u2716")} ${t(f.messageKey, f.messageParams)}`);
|
|
2454
|
+
}
|
|
2455
|
+
output.log("");
|
|
2456
|
+
}
|
|
2457
|
+
if (staleRefs.length > 0) {
|
|
2458
|
+
output.log(chalk6.bold(` ${t("label.staleReferences")}`));
|
|
2459
|
+
for (const f of staleRefs) {
|
|
2460
|
+
output.log(` ${chalk6.yellow("\u26A0")} ${t(f.messageKey, f.messageParams)}`);
|
|
2461
|
+
}
|
|
2462
|
+
output.log("");
|
|
2463
|
+
}
|
|
2464
|
+
if (scoped.length > 0) {
|
|
2465
|
+
output.log(chalk6.bold(` ${t("label.refactoringOpportunities")}`));
|
|
2466
|
+
for (const f of scoped) {
|
|
2467
|
+
output.log(` ${chalk6.blue("\u2139")} ${t(f.messageKey, f.messageParams)}`);
|
|
2468
|
+
}
|
|
2469
|
+
output.log("");
|
|
2470
|
+
}
|
|
2471
|
+
output.log(chalk6.gray(" \u2500".repeat(30)));
|
|
2472
|
+
if (findings.length === 0) {
|
|
2473
|
+
output.log(chalk6.green(` ${t("status.noStructuralIssues")}`));
|
|
2474
|
+
} else {
|
|
2475
|
+
const sep = getLocale() === "zh-TW" ? "\u3001" : ", ";
|
|
2476
|
+
const parts = [];
|
|
2477
|
+
if (contradictions.length > 0)
|
|
2478
|
+
parts.push(
|
|
2479
|
+
t("summary.contradictions", {
|
|
2480
|
+
count: String(contradictions.length),
|
|
2481
|
+
s: plural(contradictions.length)
|
|
2482
|
+
})
|
|
2483
|
+
);
|
|
2484
|
+
if (staleRefs.length > 0)
|
|
2485
|
+
parts.push(
|
|
2486
|
+
t("summary.staleRefs", {
|
|
2487
|
+
count: String(staleRefs.length),
|
|
2488
|
+
s: plural(staleRefs.length)
|
|
2489
|
+
})
|
|
2490
|
+
);
|
|
2491
|
+
if (scoped.length > 0)
|
|
2492
|
+
parts.push(
|
|
2493
|
+
t("summary.refactoringSuggestions", {
|
|
2494
|
+
count: String(scoped.length),
|
|
2495
|
+
s: plural(scoped.length)
|
|
2496
|
+
})
|
|
2497
|
+
);
|
|
2498
|
+
output.log(
|
|
2499
|
+
chalk6.yellow(` ${t("summary.found", { parts: parts.join(sep) })}`)
|
|
2500
|
+
);
|
|
2501
|
+
}
|
|
2502
|
+
output.log("");
|
|
2503
|
+
}
|
|
2504
|
+
async function runStructure(opts, output = console) {
|
|
2505
|
+
initLocale(opts.lang);
|
|
2506
|
+
await ensureInitialized();
|
|
2507
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
2508
|
+
const scan = scanProject(projectRoot, opts.tool);
|
|
2509
|
+
if (scan.tool === "unknown") {
|
|
2510
|
+
output.error(t("error.unknownTool"));
|
|
2511
|
+
return { exitCode: 1, errorMessage: "unknown tool" };
|
|
2512
|
+
}
|
|
2513
|
+
if (scan.rootFilePath === null) {
|
|
2514
|
+
output.error(t("error.missingRootFile", { tool: scan.tool }));
|
|
2515
|
+
return { exitCode: 1, errorMessage: "missing root file" };
|
|
2516
|
+
}
|
|
2517
|
+
const instructions = loadProject(projectRoot, scan.tool);
|
|
2518
|
+
const { findings } = analyzeStructure(instructions, projectRoot);
|
|
2519
|
+
if (opts.format === "json") {
|
|
2520
|
+
output.log(JSON.stringify({ findings }, null, 2));
|
|
2521
|
+
return { exitCode: 0 };
|
|
2522
|
+
}
|
|
2523
|
+
printStructureTerminal(findings, output);
|
|
2524
|
+
return { exitCode: 0 };
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
// src/commands/ci-command.ts
|
|
2528
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
2529
|
+
import { basename as basename3 } from "path";
|
|
2530
|
+
|
|
2531
|
+
// src/reporters/sarif.ts
|
|
2532
|
+
function severityToLevel(severity) {
|
|
2533
|
+
if (severity === "critical") return "error";
|
|
2534
|
+
if (severity === "warning") return "warning";
|
|
2535
|
+
return "note";
|
|
2536
|
+
}
|
|
2537
|
+
function findingToRuleId(f) {
|
|
2538
|
+
return `instrlint/${f.category}/${f.messageKey.replace(/\./g, "/")}`;
|
|
2539
|
+
}
|
|
2540
|
+
function buildRules(findings) {
|
|
2541
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2542
|
+
const rules = [];
|
|
2543
|
+
for (const f of findings) {
|
|
2544
|
+
const id = findingToRuleId(f);
|
|
2545
|
+
if (seen.has(id)) continue;
|
|
2546
|
+
seen.add(id);
|
|
2547
|
+
rules.push({
|
|
2548
|
+
id,
|
|
2549
|
+
name: f.messageKey,
|
|
2550
|
+
shortDescription: { text: `${f.category}: ${f.messageKey}` }
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
return rules;
|
|
2554
|
+
}
|
|
2555
|
+
function reportSarif(report) {
|
|
2556
|
+
const rules = buildRules(report.findings);
|
|
2557
|
+
const results = report.findings.map((f) => ({
|
|
2558
|
+
ruleId: findingToRuleId(f),
|
|
2559
|
+
level: severityToLevel(f.severity),
|
|
2560
|
+
message: { text: f.suggestion },
|
|
2561
|
+
locations: [
|
|
2562
|
+
{
|
|
2563
|
+
physicalLocation: {
|
|
2564
|
+
artifactLocation: {
|
|
2565
|
+
uri: f.file.replace(/\\/g, "/"),
|
|
2566
|
+
uriBaseId: "%SRCROOT%"
|
|
2567
|
+
},
|
|
2568
|
+
...f.line != null ? { region: { startLine: f.line } } : {}
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
]
|
|
2572
|
+
}));
|
|
2573
|
+
const log = {
|
|
2574
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
|
2575
|
+
version: "2.1.0",
|
|
2576
|
+
runs: [
|
|
2577
|
+
{
|
|
2578
|
+
tool: {
|
|
2579
|
+
driver: {
|
|
2580
|
+
name: "instrlint",
|
|
2581
|
+
version: "0.1.0",
|
|
2582
|
+
informationUri: "https://github.com/jed1978/instrlint",
|
|
2583
|
+
rules
|
|
2584
|
+
}
|
|
2585
|
+
},
|
|
2586
|
+
results
|
|
2587
|
+
}
|
|
2588
|
+
]
|
|
2589
|
+
};
|
|
2590
|
+
return JSON.stringify(log, null, 2);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/commands/ci-command.ts
|
|
2594
|
+
function shouldFail(findings, failOn) {
|
|
2595
|
+
if (failOn === "info") return findings.length > 0;
|
|
2596
|
+
if (failOn === "warning")
|
|
2597
|
+
return findings.some(
|
|
2598
|
+
(f) => f.severity === "critical" || f.severity === "warning"
|
|
2599
|
+
);
|
|
2600
|
+
return findings.some((f) => f.severity === "critical");
|
|
2601
|
+
}
|
|
2602
|
+
async function runCi(opts, output = console) {
|
|
2603
|
+
initLocale(opts.lang);
|
|
2604
|
+
await ensureInitialized();
|
|
2605
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
2606
|
+
const failOn = opts.failOn ?? "critical";
|
|
2607
|
+
const format = opts.format ?? "terminal";
|
|
2608
|
+
const scan = scanProject(projectRoot, opts.tool);
|
|
2609
|
+
if (scan.tool === "unknown") {
|
|
2610
|
+
output.error(t("error.unknownTool"));
|
|
2611
|
+
return { exitCode: 1, errorMessage: "unknown tool" };
|
|
2612
|
+
}
|
|
2613
|
+
if (scan.rootFilePath === null) {
|
|
2614
|
+
output.error(t("error.missingRootFile", { tool: scan.tool }));
|
|
2615
|
+
return { exitCode: 1, errorMessage: "missing root file" };
|
|
2616
|
+
}
|
|
2617
|
+
const instructions = loadProject(projectRoot, scan.tool);
|
|
2618
|
+
const { findings: budgetFindings, summary } = analyzeBudget(instructions);
|
|
2619
|
+
const { findings: deadRuleFindings } = analyzeDeadRules(
|
|
2620
|
+
instructions,
|
|
2621
|
+
projectRoot
|
|
2622
|
+
);
|
|
2623
|
+
const { findings: structureFindings } = analyzeStructure(
|
|
2624
|
+
instructions,
|
|
2625
|
+
projectRoot
|
|
2626
|
+
);
|
|
2627
|
+
const allFindings = [
|
|
2628
|
+
...budgetFindings,
|
|
2629
|
+
...deadRuleFindings,
|
|
2630
|
+
...structureFindings
|
|
2631
|
+
];
|
|
2632
|
+
const { score, grade } = calculateScore(allFindings, summary);
|
|
2633
|
+
const actionPlan = buildActionPlan(allFindings);
|
|
2634
|
+
const report = {
|
|
2635
|
+
project: basename3(projectRoot),
|
|
2636
|
+
tool: instructions.tool,
|
|
2637
|
+
score,
|
|
2638
|
+
grade,
|
|
2639
|
+
locale: getLocale(),
|
|
2640
|
+
tokenMethod: summary.tokenMethod,
|
|
2641
|
+
findings: allFindings,
|
|
2642
|
+
budget: summary,
|
|
2643
|
+
actionPlan
|
|
2644
|
+
};
|
|
2645
|
+
let formatted;
|
|
2646
|
+
if (format === "sarif") {
|
|
2647
|
+
formatted = reportSarif(report);
|
|
2648
|
+
} else if (format === "json") {
|
|
2649
|
+
formatted = reportJson(report);
|
|
2650
|
+
} else if (format === "markdown") {
|
|
2651
|
+
formatted = reportMarkdown(report);
|
|
2652
|
+
} else {
|
|
2653
|
+
formatted = reportJson(report);
|
|
2654
|
+
}
|
|
2655
|
+
if (opts.output != null) {
|
|
2656
|
+
writeFileSync2(opts.output, formatted, "utf8");
|
|
2657
|
+
const pass = !shouldFail(allFindings, failOn);
|
|
2658
|
+
const statusKey = pass ? "ci.passed" : "ci.failed";
|
|
2659
|
+
output.error(
|
|
2660
|
+
`${t(statusKey, { score: String(score), grade })} ${t("ci.writtenTo", { file: opts.output })}`
|
|
2661
|
+
);
|
|
2662
|
+
} else {
|
|
2663
|
+
output.log(formatted);
|
|
2664
|
+
}
|
|
2665
|
+
const failed = shouldFail(allFindings, failOn);
|
|
2666
|
+
return { exitCode: failed ? 1 : 0 };
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// src/commands/init-ci-command.ts
|
|
2670
|
+
import { existsSync as existsSync7, mkdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2671
|
+
import { join as join7 } from "path";
|
|
2672
|
+
function githubWorkflow() {
|
|
2673
|
+
return `name: instrlint
|
|
2674
|
+
|
|
2675
|
+
on:
|
|
2676
|
+
push:
|
|
2677
|
+
paths:
|
|
2678
|
+
- 'CLAUDE.md'
|
|
2679
|
+
- '.claude/**'
|
|
2680
|
+
- 'AGENTS.md'
|
|
2681
|
+
- '.agents/**'
|
|
2682
|
+
- '.cursorrules'
|
|
2683
|
+
- '.cursor/**'
|
|
2684
|
+
pull_request:
|
|
2685
|
+
paths:
|
|
2686
|
+
- 'CLAUDE.md'
|
|
2687
|
+
- '.claude/**'
|
|
2688
|
+
- 'AGENTS.md'
|
|
2689
|
+
- '.agents/**'
|
|
2690
|
+
- '.cursorrules'
|
|
2691
|
+
- '.cursor/**'
|
|
2692
|
+
|
|
2693
|
+
jobs:
|
|
2694
|
+
instrlint:
|
|
2695
|
+
name: Lint instruction files
|
|
2696
|
+
runs-on: ubuntu-latest
|
|
2697
|
+
permissions:
|
|
2698
|
+
contents: read
|
|
2699
|
+
security-events: write
|
|
2700
|
+
|
|
2701
|
+
steps:
|
|
2702
|
+
- uses: actions/checkout@v4
|
|
2703
|
+
|
|
2704
|
+
- uses: actions/setup-node@v4
|
|
2705
|
+
with:
|
|
2706
|
+
node-version: '20'
|
|
2707
|
+
|
|
2708
|
+
- name: Run instrlint
|
|
2709
|
+
run: npx instrlint@latest ci --fail-on warning --format sarif --output instrlint.sarif
|
|
2710
|
+
|
|
2711
|
+
- name: Upload SARIF
|
|
2712
|
+
if: always()
|
|
2713
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
2714
|
+
with:
|
|
2715
|
+
sarif_file: instrlint.sarif
|
|
2716
|
+
category: instrlint
|
|
2717
|
+
`;
|
|
2718
|
+
}
|
|
2719
|
+
function gitlabSnippet() {
|
|
2720
|
+
return `# Add this to your .gitlab-ci.yml
|
|
2721
|
+
instrlint:
|
|
2722
|
+
image: node:20
|
|
2723
|
+
stage: test
|
|
2724
|
+
rules:
|
|
2725
|
+
- changes:
|
|
2726
|
+
- CLAUDE.md
|
|
2727
|
+
- .claude/**/*
|
|
2728
|
+
- AGENTS.md
|
|
2729
|
+
- .agents/**/*
|
|
2730
|
+
- .cursorrules
|
|
2731
|
+
- .cursor/**/*
|
|
2732
|
+
script:
|
|
2733
|
+
- npx instrlint@latest ci --fail-on warning --format json
|
|
2734
|
+
allow_failure: false
|
|
2735
|
+
`;
|
|
2736
|
+
}
|
|
2737
|
+
function runInitCi(opts, output = console) {
|
|
2738
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
2739
|
+
if (opts.github) {
|
|
2740
|
+
const workflowDir = join7(projectRoot, ".github", "workflows");
|
|
2741
|
+
const workflowPath = join7(workflowDir, "instrlint.yml");
|
|
2742
|
+
if (existsSync7(workflowPath) && !opts.force) {
|
|
2743
|
+
output.error(t("initCi.alreadyExists", { path: workflowPath }));
|
|
2744
|
+
return { exitCode: 1, errorMessage: "file already exists" };
|
|
2745
|
+
}
|
|
2746
|
+
mkdirSync(workflowDir, { recursive: true });
|
|
2747
|
+
writeFileSync3(workflowPath, githubWorkflow(), "utf8");
|
|
2748
|
+
output.log(t("initCi.created", { path: workflowPath }));
|
|
2749
|
+
return { exitCode: 0 };
|
|
2750
|
+
}
|
|
2751
|
+
if (opts.gitlab) {
|
|
2752
|
+
output.log(gitlabSnippet());
|
|
2753
|
+
return { exitCode: 0 };
|
|
2754
|
+
}
|
|
2755
|
+
output.error("init-ci: specify --github or --gitlab");
|
|
2756
|
+
return { exitCode: 1, errorMessage: "no target specified" };
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// src/commands/install-command.ts
|
|
2760
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
|
|
2761
|
+
import { join as join8 } from "path";
|
|
2762
|
+
import { homedir } from "os";
|
|
2763
|
+
import { fileURLToPath } from "url";
|
|
2764
|
+
function resolveSkillFile(target) {
|
|
2765
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
2766
|
+
const packageRoot = join8(thisFile, "..", "..", "..");
|
|
2767
|
+
const skillPath = join8(
|
|
2768
|
+
packageRoot,
|
|
2769
|
+
"skills",
|
|
2770
|
+
target === "claude-code" ? "claude-code" : "codex",
|
|
2771
|
+
"SKILL.md"
|
|
2772
|
+
);
|
|
2773
|
+
return skillPath;
|
|
2774
|
+
}
|
|
2775
|
+
function readSkillContent(target) {
|
|
2776
|
+
const skillPath = resolveSkillFile(target);
|
|
2777
|
+
try {
|
|
2778
|
+
return readFileSync8(skillPath, "utf8");
|
|
2779
|
+
} catch {
|
|
2780
|
+
throw new Error(
|
|
2781
|
+
`Could not read skill file at ${skillPath}. Make sure the package is properly installed.`
|
|
2782
|
+
);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
function installClaudeCode(content, projectRoot, isProject, force, output) {
|
|
2786
|
+
const targetDir = isProject ? join8(projectRoot, ".claude", "skills", "instrlint") : join8(homedir(), ".claude", "skills", "instrlint");
|
|
2787
|
+
const targetPath = join8(targetDir, "SKILL.md");
|
|
2788
|
+
if (existsSync8(targetPath) && !force) {
|
|
2789
|
+
output.error(t("install.alreadyExists", { path: targetPath }));
|
|
2790
|
+
return { exitCode: 1, errorMessage: "file already exists" };
|
|
2791
|
+
}
|
|
2792
|
+
mkdirSync2(targetDir, { recursive: true });
|
|
2793
|
+
writeFileSync4(targetPath, content, "utf8");
|
|
2794
|
+
output.log(t("install.installed", { path: targetPath }));
|
|
2795
|
+
return { exitCode: 0 };
|
|
2796
|
+
}
|
|
2797
|
+
function installCodex(content, projectRoot, force, output) {
|
|
2798
|
+
const targetDir = join8(projectRoot, ".agents", "skills", "instrlint");
|
|
2799
|
+
const targetPath = join8(targetDir, "SKILL.md");
|
|
2800
|
+
if (existsSync8(targetPath) && !force) {
|
|
2801
|
+
output.error(t("install.alreadyExists", { path: targetPath }));
|
|
2802
|
+
return { exitCode: 1, errorMessage: "file already exists" };
|
|
2803
|
+
}
|
|
2804
|
+
mkdirSync2(targetDir, { recursive: true });
|
|
2805
|
+
writeFileSync4(targetPath, content, "utf8");
|
|
2806
|
+
output.log(t("install.installed", { path: targetPath }));
|
|
2807
|
+
return { exitCode: 0 };
|
|
2808
|
+
}
|
|
2809
|
+
function runInstall(opts, output = console) {
|
|
2810
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
2811
|
+
const force = opts.force ?? false;
|
|
2812
|
+
if (opts.claudeCode) {
|
|
2813
|
+
let content;
|
|
2814
|
+
try {
|
|
2815
|
+
content = readSkillContent("claude-code");
|
|
2816
|
+
} catch (err) {
|
|
2817
|
+
output.error(String(err));
|
|
2818
|
+
return { exitCode: 1, errorMessage: String(err) };
|
|
2819
|
+
}
|
|
2820
|
+
return installClaudeCode(
|
|
2821
|
+
content,
|
|
2822
|
+
projectRoot,
|
|
2823
|
+
opts.project ?? false,
|
|
2824
|
+
force,
|
|
2825
|
+
output
|
|
2826
|
+
);
|
|
2827
|
+
}
|
|
2828
|
+
if (opts.codex) {
|
|
2829
|
+
let content;
|
|
2830
|
+
try {
|
|
2831
|
+
content = readSkillContent("codex");
|
|
2832
|
+
} catch (err) {
|
|
2833
|
+
output.error(String(err));
|
|
2834
|
+
return { exitCode: 1, errorMessage: String(err) };
|
|
2835
|
+
}
|
|
2836
|
+
return installCodex(content, projectRoot, force, output);
|
|
2837
|
+
}
|
|
2838
|
+
output.error(t("install.unknownTarget"));
|
|
2839
|
+
return { exitCode: 1, errorMessage: "no target specified" };
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
// src/cli.ts
|
|
2843
|
+
var program = new Command();
|
|
2844
|
+
program.enablePositionalOptions().name("instrlint").description(
|
|
2845
|
+
"Lint and optimize your CLAUDE.md / AGENTS.md \u2014 find dead rules, token waste, and structural issues"
|
|
2846
|
+
).version("0.1.0").option(
|
|
2847
|
+
"--format <type>",
|
|
2848
|
+
"output format (terminal|json|markdown)",
|
|
2849
|
+
"terminal"
|
|
2850
|
+
).option("--lang <locale>", "output language (en|zh-TW)", "en").option("--tool <name>", "force tool detection (claude-code|codex|cursor)").option("--fix", "auto-fix safe issues (dead rules, stale refs, dupes)").option("--force", "skip git clean check when using --fix").action(async function() {
|
|
2851
|
+
const opts = this.opts();
|
|
2852
|
+
const result = await runAll(opts);
|
|
2853
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2854
|
+
});
|
|
2855
|
+
program.command("budget").description("Token budget analysis only").option(
|
|
2856
|
+
"--format <type>",
|
|
2857
|
+
"output format (terminal|json|markdown)",
|
|
2858
|
+
"terminal"
|
|
2859
|
+
).option("--tool <name>", "force tool detection (claude-code|codex|cursor)").action(async function() {
|
|
2860
|
+
const opts = this.opts();
|
|
2861
|
+
const lang = this.parent?.opts()?.lang;
|
|
2862
|
+
const result = await runBudget({
|
|
2863
|
+
...opts,
|
|
2864
|
+
...lang !== void 0 && { lang }
|
|
2865
|
+
});
|
|
2866
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2867
|
+
});
|
|
2868
|
+
program.command("deadrules").description("Dead rule detection only").option("--format <type>", "output format (terminal|json)", "terminal").option("--tool <name>", "force tool detection (claude-code|codex|cursor)").action(async function() {
|
|
2869
|
+
const opts = this.opts();
|
|
2870
|
+
const lang = this.parent?.opts()?.lang;
|
|
2871
|
+
const result = await runDeadRules({
|
|
2872
|
+
...opts,
|
|
2873
|
+
...lang !== void 0 && { lang }
|
|
2874
|
+
});
|
|
2875
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2876
|
+
});
|
|
2877
|
+
program.command("structure").description("Structural analysis only").option("--format <type>", "output format (terminal|json)", "terminal").option("--tool <name>", "force tool detection (claude-code|codex|cursor)").action(async function() {
|
|
2878
|
+
const opts = this.opts();
|
|
2879
|
+
const lang = this.parent?.opts()?.lang;
|
|
2880
|
+
const result = await runStructure({
|
|
2881
|
+
...opts,
|
|
2882
|
+
...lang !== void 0 && { lang }
|
|
2883
|
+
});
|
|
2884
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2885
|
+
});
|
|
2886
|
+
program.command("ci").description(
|
|
2887
|
+
"CI mode: run full analysis and exit 1 if findings exceed threshold"
|
|
2888
|
+
).option(
|
|
2889
|
+
"--fail-on <level>",
|
|
2890
|
+
"failure threshold (critical|warning|info)",
|
|
2891
|
+
"critical"
|
|
2892
|
+
).option("--format <type>", "output format (json|markdown|sarif)", "json").option("--output <file>", "write output to file instead of stdout").option("--tool <name>", "force tool detection (claude-code|codex|cursor)").action(async function() {
|
|
2893
|
+
const opts = this.opts();
|
|
2894
|
+
const lang = this.parent?.opts()?.lang;
|
|
2895
|
+
initLocale(lang);
|
|
2896
|
+
const result = await runCi({
|
|
2897
|
+
failOn: opts.failOn,
|
|
2898
|
+
format: opts.format,
|
|
2899
|
+
...opts.output !== void 0 && { output: opts.output },
|
|
2900
|
+
...opts.tool !== void 0 && { tool: opts.tool },
|
|
2901
|
+
...lang !== void 0 && { lang }
|
|
2902
|
+
});
|
|
2903
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2904
|
+
});
|
|
2905
|
+
program.command("init-ci").description("Generate CI configuration for instrlint").option("--github", "Generate GitHub Actions workflow").option("--gitlab", "Generate GitLab CI snippet (prints to stdout)").option("--force", "overwrite existing files").action(function() {
|
|
2906
|
+
const opts = this.opts();
|
|
2907
|
+
const result = runInitCi(opts);
|
|
2908
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2909
|
+
});
|
|
2910
|
+
program.command("install").description("Install instrlint as a skill").option("--claude-code", "Install as Claude Code skill").option("--codex", "Install as Codex skill").option("--project", "Install into current project (instead of global)").option("--force", "overwrite existing skill file").action(function() {
|
|
2911
|
+
const opts = this.opts();
|
|
2912
|
+
const result = runInstall(opts);
|
|
2913
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2914
|
+
});
|
|
2915
|
+
program.parse();
|
|
2916
|
+
//# sourceMappingURL=cli.js.map
|