instrlint 0.1.9 → 0.2.2

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.cjs CHANGED
@@ -199,7 +199,8 @@ var KEYWORD_REGEX = new RegExp(
199
199
  "gi"
200
200
  );
201
201
  var PATH_REGEX = /(?<!\w)(?:\.{1,2}\/|(?:src|tests?|dist|lib|docs?|config|scripts?|packages?)\/)[^\s,;`'")\]>]+/g;
202
- var RULE_IMPERATIVE_WORDS = /\b(must|should|never|always|prefer|avoid|ensure|require|forbid|use|do not|don't)\b/i;
202
+ var AT_REF_REGEX = /(?<![a-zA-Z0-9_])@((?:\.\.?\/)?[\w./-]+\.(?:md|txt|json|yaml|yml))\b/g;
203
+ var RULE_IMPERATIVE_WORDS = /\b(must|should|never|always|prefer|avoid|ensure|require|forbid|use|do not|don't)\b|必須|應該|應當|永遠|總是|禁止|不要|不可|不得|避免|請使用|優先使用|請勿/i;
203
204
  var RULE_NEGATION_PATTERN = /\b(not|don't|do not)\s+\w+/i;
204
205
  var STRONG_IMPERATIVE = /\b(must|shall|always|never)\b/i;
205
206
  function parseYamlFrontmatter(content) {
@@ -255,6 +256,8 @@ function isRule(text) {
255
256
  if (STRONG_IMPERATIVE.test(body) && /^[A-Z]/.test(body)) return true;
256
257
  if (RULE_IMPERATIVE_WORDS.test(body) && /^[A-Z][a-z]/.test(body))
257
258
  return true;
259
+ if (RULE_IMPERATIVE_WORDS.test(body) && /^[\u4e00-\u9fff\u3400-\u4dbf]/.test(body))
260
+ return true;
258
261
  }
259
262
  return false;
260
263
  }
@@ -267,12 +270,15 @@ function extractKeywords(text) {
267
270
  return [...found];
268
271
  }
269
272
  function extractPaths(text) {
270
- const matches = text.matchAll(PATH_REGEX);
271
273
  const found = [];
272
- for (const m of matches) {
274
+ for (const m of text.matchAll(PATH_REGEX)) {
273
275
  const cleaned = m[0].replace(/[.,;)>\]'"]+$/, "");
274
276
  if (cleaned.length > 1) found.push(cleaned);
275
277
  }
278
+ for (const m of text.matchAll(AT_REF_REGEX)) {
279
+ const cleaned = (m[1] ?? "").replace(/[.,;)>\]'"]+$/, "");
280
+ if (cleaned.length > 0) found.push(cleaned);
281
+ }
276
282
  return [...new Set(found)];
277
283
  }
278
284
  function parseInstructionFile(filePath) {
@@ -453,7 +459,11 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
453
459
  ".git",
454
460
  ".claude",
455
461
  ".turbo",
456
- "coverage"
462
+ "coverage",
463
+ "tests",
464
+ "test",
465
+ "__tests__",
466
+ "spec"
457
467
  ]);
458
468
  function findSubClaudeFiles(dir, projectRoot, depth = 0) {
459
469
  if (depth > 10) return [];
@@ -949,6 +959,31 @@ function getPrettierField(projectRoot, field) {
949
959
  }
950
960
  return void 0;
951
961
  }
962
+ function readCsprojContent(projectRoot) {
963
+ const buildProps = readTextFile(projectRoot, "Directory.Build.props");
964
+ if (buildProps) return buildProps;
965
+ try {
966
+ const files = (0, import_fs6.readdirSync)(projectRoot);
967
+ for (const f of files) {
968
+ if (f.endsWith(".csproj") || f.endsWith(".fsproj")) {
969
+ const content = readTextFile(projectRoot, f);
970
+ if (content) return content;
971
+ }
972
+ }
973
+ } catch {
974
+ }
975
+ return null;
976
+ }
977
+ function checkCsprojProperty(projectRoot, tag, value) {
978
+ const content = readCsprojContent(projectRoot);
979
+ if (!content) return false;
980
+ return new RegExp(`<${tag}>\\s*${value}\\s*</${tag}>`, "i").test(content);
981
+ }
982
+ function checkEditorConfigPattern(projectRoot, pattern) {
983
+ const content = readTextFile(projectRoot, ".editorconfig");
984
+ if (!content) return false;
985
+ return pattern.test(content);
986
+ }
952
987
  function checkEslintRule(projectRoot, ruleName) {
953
988
  const parsed = readJsonFile(projectRoot, ".eslintrc.json");
954
989
  if (!parsed || typeof parsed !== "object") return false;
@@ -1114,6 +1149,52 @@ var PATTERNS = [
1114
1149
  rulePattern: /\b(no|avoid|prefer\s*named)\s*(default\s*export)/i,
1115
1150
  configCheck: (root) => checkEslintRule(root, "import/no-default-export") || checkEslintRule(root, "no-restricted-exports"),
1116
1151
  configName: "eslint-plugin-import (no-default-export)"
1152
+ },
1153
+ // ─── C# patterns ─────────────────────────────────────────────────────────
1154
+ {
1155
+ id: "csharp-nullable",
1156
+ rulePattern: /\b(nullable\s*reference\s*type|#nullable\s*enable|enable\s*nullable|null\s*reference\s*exception)\b/i,
1157
+ configCheck: (root) => checkCsprojProperty(root, "Nullable", "enable") || checkCsprojProperty(root, "Nullable", "annotations") || checkCsprojProperty(root, "Nullable", "warnings"),
1158
+ configName: ".csproj (<Nullable>enable</Nullable>)"
1159
+ },
1160
+ {
1161
+ id: "csharp-implicit-usings",
1162
+ rulePattern: /\b(global\s*usings?|implicit\s*usings?)\b/i,
1163
+ configCheck: (root) => checkCsprojProperty(root, "ImplicitUsings", "enable"),
1164
+ configName: ".csproj (<ImplicitUsings>enable</ImplicitUsings>)"
1165
+ },
1166
+ {
1167
+ id: "csharp-test-framework",
1168
+ rulePattern: /\b(xunit|nunit|mstest|microsoft\.testing\.framework)\b/i,
1169
+ configCheck: (root) => {
1170
+ const content = readCsprojContent(root);
1171
+ if (!content) return false;
1172
+ return /PackageReference\s+Include="(xunit|NUnit|MSTest|Microsoft\.Testing)/i.test(
1173
+ content
1174
+ );
1175
+ },
1176
+ configName: ".csproj (PackageReference xUnit / NUnit / MSTest)"
1177
+ },
1178
+ {
1179
+ id: "csharp-formatter",
1180
+ rulePattern: /\bdotnet\s*format\b/i,
1181
+ configCheck: (root) => checkEditorConfigPattern(root, /^\[.*\.cs\]/m) && checkEditorConfigPattern(root, /^csharp_/m),
1182
+ configName: ".editorconfig ([*.cs] csharp_style_* settings)"
1183
+ },
1184
+ {
1185
+ id: "csharp-naming",
1186
+ rulePattern: /\bdotnet_naming\b|PascalCase\s+for\s+(all\s+)?(public|class|method|property|type)\b/i,
1187
+ configCheck: (root) => checkEditorConfigPattern(root, /^dotnet_naming_rule\./m),
1188
+ configName: ".editorconfig (dotnet_naming_rule.*)"
1189
+ },
1190
+ {
1191
+ id: "csharp-unused",
1192
+ rulePattern: /\b(IDE0059|CS0168|CS0219|dead\s*code|unused.*IDE|roslyn.*unused)\b/i,
1193
+ configCheck: (root) => checkEditorConfigPattern(
1194
+ root,
1195
+ /^dotnet_diagnostic\.(IDE0059|CS0168|CS0219)/m
1196
+ ),
1197
+ configName: ".editorconfig (dotnet_diagnostic.IDE0059 / CS0168)"
1117
1198
  }
1118
1199
  ];
1119
1200
  function collectRuleLines(instructions) {
@@ -1164,6 +1245,7 @@ function detectConfigOverlaps(instructions, projectRoot) {
1164
1245
 
1165
1246
  // src/utils/text.ts
1166
1247
  var STOP_WORDS = /* @__PURE__ */ new Set([
1248
+ // English grammatical function words
1167
1249
  "the",
1168
1250
  "a",
1169
1251
  "an",
@@ -1177,10 +1259,30 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
1177
1259
  "of",
1178
1260
  "with",
1179
1261
  "that",
1180
- "this"
1262
+ "this",
1263
+ // Chinese filler bigrams (grammatical / no topic content)
1264
+ "\u7684\u6642",
1265
+ "\u6642\u5019",
1266
+ "\u4E00\u500B",
1267
+ "\u6240\u6709",
1268
+ "\u6BCF\u500B"
1181
1269
  ]);
1270
+ var CJK_RUN = /[\u4e00-\u9fff\u3400-\u4dbf]+/g;
1271
+ var ASCII_WORD = /[a-z0-9]+/g;
1182
1272
  function tokenizeWords(text) {
1183
- return text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
1273
+ const words = [];
1274
+ const lower = text.toLowerCase();
1275
+ for (const m of lower.matchAll(ASCII_WORD)) {
1276
+ if (m[0].length > 1) words.push(m[0]);
1277
+ }
1278
+ for (const m of text.matchAll(CJK_RUN)) {
1279
+ const run = m[0];
1280
+ if (run.length < 2) continue;
1281
+ for (let i = 0; i < run.length - 1; i++) {
1282
+ words.push(run.slice(i, i + 2));
1283
+ }
1284
+ }
1285
+ return words;
1184
1286
  }
1185
1287
  function removeStopWords(words) {
1186
1288
  return words.filter((w) => !STOP_WORDS.has(w));
@@ -1261,8 +1363,10 @@ function analyzeDeadRules(instructions, projectRoot) {
1261
1363
 
1262
1364
  // src/detectors/contradiction.ts
1263
1365
  var NEGATION_WORDS = ["never", "don't", "avoid", "forbid"];
1366
+ var CJK_NEGATIONS = ["\u7981\u6B62", "\u4E0D\u8981", "\u4E0D\u53EF", "\u4E0D\u5F97", "\u907F\u514D", "\u8ACB\u52FF", "\u52FF"];
1367
+ var CJK_ONLY_RE = /^[\u4e00-\u9fff\u3400-\u4dbf]+$/;
1264
1368
  function isNegated(text, word) {
1265
- const sentences = text.split(/[.!?]+\s+/);
1369
+ const sentences = text.split(/[.!?。!?]+\s*/);
1266
1370
  const escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1267
1371
  const wordPresent = new RegExp(`\\b${escapedWord}\\b`, "i");
1268
1372
  for (const sentence of sentences) {
@@ -1281,6 +1385,14 @@ function isNegated(text, word) {
1281
1385
  );
1282
1386
  if (notPattern.test(lower)) return true;
1283
1387
  }
1388
+ if (CJK_ONLY_RE.test(word)) {
1389
+ for (const sentence of sentences) {
1390
+ if (!sentence.includes(word)) continue;
1391
+ for (const neg of CJK_NEGATIONS) {
1392
+ if (sentence.includes(neg)) return true;
1393
+ }
1394
+ }
1395
+ }
1284
1396
  return false;
1285
1397
  }
1286
1398
  var POLARITY_STOP_WORDS = /* @__PURE__ */ new Set([
@@ -1296,6 +1408,9 @@ var POLARITY_STOP_WORDS = /* @__PURE__ */ new Set([
1296
1408
  "prefer",
1297
1409
  "follow",
1298
1410
  "keep",
1411
+ "calls",
1412
+ "run",
1413
+ "runs",
1299
1414
  // Common modals and auxiliaries
1300
1415
  "must",
1301
1416
  "should",
@@ -1314,7 +1429,26 @@ var POLARITY_STOP_WORDS = /* @__PURE__ */ new Set([
1314
1429
  "be",
1315
1430
  "by",
1316
1431
  "own",
1317
- "on"
1432
+ "on",
1433
+ // Chinese polarity / imperative bigrams (analogues of English never/always/use/must)
1434
+ "\u6C38\u9060",
1435
+ "\u7E3D\u662F",
1436
+ "\u7981\u6B62",
1437
+ "\u4E0D\u8981",
1438
+ "\u4E0D\u53EF",
1439
+ "\u4E0D\u5F97",
1440
+ "\u907F\u514D",
1441
+ "\u8ACB\u52FF",
1442
+ "\u5FC5\u9808",
1443
+ "\u61C9\u8A72",
1444
+ "\u61C9\u7576",
1445
+ // Chinese generic verb bigrams (HOW to comply, not WHAT topic)
1446
+ "\u4F7F\u7528",
1447
+ "\u63A1\u7528",
1448
+ "\u57F7\u884C",
1449
+ "\u9032\u884C",
1450
+ "\u53EF\u4EE5",
1451
+ "\u9700\u8981"
1318
1452
  ]);
1319
1453
  function collectRuleLines3(instructions) {
1320
1454
  const sources = [
@@ -1419,7 +1553,7 @@ function detectStaleRefs(instructions, projectRoot) {
1419
1553
 
1420
1554
  // src/detectors/scope-classifier.ts
1421
1555
  var PATH_REF_PATTERN = /\b(?:src|tests?|lib|dist)\//i;
1422
- var HOOK_PATTERN = /\b(?:never|don't|do\s+not|forbid)\b.*\b(?:commit|push|merge|build|run)\b/i;
1556
+ var HOOK_PATTERN = /\b(?:never|don't|do\s+not|forbid)\b(?:\s+\w+){0,3}\s+\b(?:commit|push|merge|rebase|tag|checkout|amend)\b/i;
1423
1557
  function classifyScope(instructions) {
1424
1558
  const findings = [];
1425
1559
  const rootFile = instructions.rootFile;
@@ -2456,14 +2590,15 @@ function readInstalledVersion(path) {
2456
2590
  return null;
2457
2591
  }
2458
2592
  }
2459
- function checkSkillUpdate(projectRoot) {
2593
+ function checkSkillUpdate(projectRoot, globalRoot) {
2594
+ const resolvedGlobal = globalRoot ?? (0, import_os.homedir)();
2460
2595
  const candidates = [
2461
2596
  {
2462
2597
  path: (0, import_path8.join)(projectRoot, ".claude", "commands", "instrlint.md"),
2463
2598
  isProject: true
2464
2599
  },
2465
2600
  {
2466
- path: (0, import_path8.join)((0, import_os.homedir)(), ".claude", "commands", "instrlint.md"),
2601
+ path: (0, import_path8.join)(resolvedGlobal, ".claude", "commands", "instrlint.md"),
2467
2602
  isProject: false
2468
2603
  }
2469
2604
  ];
@@ -2506,7 +2641,7 @@ var import_path9 = require("path");
2506
2641
  function shouldVerify(finding) {
2507
2642
  if (finding.category === "stale-ref") return false;
2508
2643
  if (finding.category === "budget") return false;
2509
- if (finding.category === "structure") return false;
2644
+ if (finding.category === "structure") return true;
2510
2645
  if (finding.category === "dead-rule") return !finding.autoFixable;
2511
2646
  if (finding.category === "contradiction") return true;
2512
2647
  if (finding.category === "duplicate") {
@@ -2553,6 +2688,13 @@ function buildContext(finding, parsed, projectRoot) {
2553
2688
  ruleB: ruleRef(parsed, finding.file, finding.line ?? 0, projectRoot)
2554
2689
  };
2555
2690
  }
2691
+ if (finding.category === "structure") {
2692
+ return {
2693
+ type: "structure",
2694
+ rule: ruleRef(parsed, finding.file, finding.line ?? 0, projectRoot),
2695
+ suggestion: finding.suggestion
2696
+ };
2697
+ }
2556
2698
  return null;
2557
2699
  }
2558
2700
  function hashFinding(finding) {
@@ -2567,6 +2709,10 @@ var QUESTIONS = {
2567
2709
  duplicate: {
2568
2710
  en: 'Are rules A and B true semantic duplicates \u2014 do they say the same thing in different words, such that keeping both adds no value? Respond with JSON only: {"verdict":"confirmed"|"rejected"|"uncertain","reason":"<\u226420 words>"}',
2569
2711
  "zh-TW": '\u898F\u5247 A \u548C\u898F\u5247 B \u5728\u8A9E\u610F\u4E0A\u771F\u7684\u662F\u91CD\u8907\u7684\u55CE\u2014\u2014\u7528\u4E0D\u540C\u7684\u63AA\u8FAD\u8AAA\u540C\u4E00\u4EF6\u4E8B\uFF0C\u4FDD\u7559\u5169\u689D\u6BEB\u7121\u984D\u5916\u50F9\u503C\uFF1F\u50C5\u7528 JSON \u56DE\u7B54\uFF1A{"verdict":"confirmed"|"rejected"|"uncertain","reason":"<20 \u5B57\u4EE5\u5167>"}'
2712
+ },
2713
+ structure: {
2714
+ en: 'Is this structural suggestion accurate? Should this rule truly move to a git hook or path-scoped rule file, or is it an architectural principle / general guidance that belongs in CLAUDE.md? Respond with JSON only: {"verdict":"confirmed"|"rejected"|"uncertain","reason":"<\u226420 words>"}',
2715
+ "zh-TW": '\u9019\u689D\u7D50\u69CB\u5EFA\u8B70\u662F\u5426\u6E96\u78BA\uFF1F\u9019\u689D\u898F\u5247\u771F\u7684\u66F4\u9069\u5408\u79FB\u5230 git hook \u6216 path-scoped rule file \u4E2D\uFF0C\u9084\u662F\u5B83\u662F\u67B6\u69CB\u8AAA\u660E / \u4E00\u822C\u539F\u5247\uFF0C\u61C9\u7559\u5728 CLAUDE.md \u4E2D\uFF1F\u50C5\u7528 JSON \u56DE\u7B54\uFF1A{"verdict":"confirmed"|"rejected"|"uncertain","reason":"<20 \u5B57\u4EE5\u5167>"}'
2570
2716
  }
2571
2717
  };
2572
2718
  function questionFor(category, locale) {