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/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