instrlint 0.1.10 → 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.js CHANGED
@@ -172,7 +172,8 @@ var KEYWORD_REGEX = new RegExp(
172
172
  "gi"
173
173
  );
174
174
  var PATH_REGEX = /(?<!\w)(?:\.{1,2}\/|(?:src|tests?|dist|lib|docs?|config|scripts?|packages?)\/)[^\s,;`'")\]>]+/g;
175
- var RULE_IMPERATIVE_WORDS = /\b(must|should|never|always|prefer|avoid|ensure|require|forbid|use|do not|don't)\b/i;
175
+ var AT_REF_REGEX = /(?<![a-zA-Z0-9_])@((?:\.\.?\/)?[\w./-]+\.(?:md|txt|json|yaml|yml))\b/g;
176
+ var RULE_IMPERATIVE_WORDS = /\b(must|should|never|always|prefer|avoid|ensure|require|forbid|use|do not|don't)\b|必須|應該|應當|永遠|總是|禁止|不要|不可|不得|避免|請使用|優先使用|請勿/i;
176
177
  var RULE_NEGATION_PATTERN = /\b(not|don't|do not)\s+\w+/i;
177
178
  var STRONG_IMPERATIVE = /\b(must|shall|always|never)\b/i;
178
179
  function parseYamlFrontmatter(content) {
@@ -228,6 +229,8 @@ function isRule(text) {
228
229
  if (STRONG_IMPERATIVE.test(body) && /^[A-Z]/.test(body)) return true;
229
230
  if (RULE_IMPERATIVE_WORDS.test(body) && /^[A-Z][a-z]/.test(body))
230
231
  return true;
232
+ if (RULE_IMPERATIVE_WORDS.test(body) && /^[\u4e00-\u9fff\u3400-\u4dbf]/.test(body))
233
+ return true;
231
234
  }
232
235
  return false;
233
236
  }
@@ -240,12 +243,15 @@ function extractKeywords(text) {
240
243
  return [...found];
241
244
  }
242
245
  function extractPaths(text) {
243
- const matches = text.matchAll(PATH_REGEX);
244
246
  const found = [];
245
- for (const m of matches) {
247
+ for (const m of text.matchAll(PATH_REGEX)) {
246
248
  const cleaned = m[0].replace(/[.,;)>\]'"]+$/, "");
247
249
  if (cleaned.length > 1) found.push(cleaned);
248
250
  }
251
+ for (const m of text.matchAll(AT_REF_REGEX)) {
252
+ const cleaned = (m[1] ?? "").replace(/[.,;)>\]'"]+$/, "");
253
+ if (cleaned.length > 0) found.push(cleaned);
254
+ }
249
255
  return [...new Set(found)];
250
256
  }
251
257
  function parseInstructionFile(filePath) {
@@ -885,7 +891,7 @@ function analyzeBudget(instructions) {
885
891
  }
886
892
 
887
893
  // src/detectors/config-overlap.ts
888
- import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
894
+ import { readFileSync as readFileSync5, existsSync as existsSync5, readdirSync as readdirSync4 } from "fs";
889
895
  import { join as join5 } from "path";
890
896
  function readJsonFile(projectRoot, filename) {
891
897
  try {
@@ -926,6 +932,31 @@ function getPrettierField(projectRoot, field) {
926
932
  }
927
933
  return void 0;
928
934
  }
935
+ function readCsprojContent(projectRoot) {
936
+ const buildProps = readTextFile(projectRoot, "Directory.Build.props");
937
+ if (buildProps) return buildProps;
938
+ try {
939
+ const files = readdirSync4(projectRoot);
940
+ for (const f of files) {
941
+ if (f.endsWith(".csproj") || f.endsWith(".fsproj")) {
942
+ const content = readTextFile(projectRoot, f);
943
+ if (content) return content;
944
+ }
945
+ }
946
+ } catch {
947
+ }
948
+ return null;
949
+ }
950
+ function checkCsprojProperty(projectRoot, tag, value) {
951
+ const content = readCsprojContent(projectRoot);
952
+ if (!content) return false;
953
+ return new RegExp(`<${tag}>\\s*${value}\\s*</${tag}>`, "i").test(content);
954
+ }
955
+ function checkEditorConfigPattern(projectRoot, pattern) {
956
+ const content = readTextFile(projectRoot, ".editorconfig");
957
+ if (!content) return false;
958
+ return pattern.test(content);
959
+ }
929
960
  function checkEslintRule(projectRoot, ruleName) {
930
961
  const parsed = readJsonFile(projectRoot, ".eslintrc.json");
931
962
  if (!parsed || typeof parsed !== "object") return false;
@@ -1091,6 +1122,52 @@ var PATTERNS = [
1091
1122
  rulePattern: /\b(no|avoid|prefer\s*named)\s*(default\s*export)/i,
1092
1123
  configCheck: (root) => checkEslintRule(root, "import/no-default-export") || checkEslintRule(root, "no-restricted-exports"),
1093
1124
  configName: "eslint-plugin-import (no-default-export)"
1125
+ },
1126
+ // ─── C# patterns ─────────────────────────────────────────────────────────
1127
+ {
1128
+ id: "csharp-nullable",
1129
+ rulePattern: /\b(nullable\s*reference\s*type|#nullable\s*enable|enable\s*nullable|null\s*reference\s*exception)\b/i,
1130
+ configCheck: (root) => checkCsprojProperty(root, "Nullable", "enable") || checkCsprojProperty(root, "Nullable", "annotations") || checkCsprojProperty(root, "Nullable", "warnings"),
1131
+ configName: ".csproj (<Nullable>enable</Nullable>)"
1132
+ },
1133
+ {
1134
+ id: "csharp-implicit-usings",
1135
+ rulePattern: /\b(global\s*usings?|implicit\s*usings?)\b/i,
1136
+ configCheck: (root) => checkCsprojProperty(root, "ImplicitUsings", "enable"),
1137
+ configName: ".csproj (<ImplicitUsings>enable</ImplicitUsings>)"
1138
+ },
1139
+ {
1140
+ id: "csharp-test-framework",
1141
+ rulePattern: /\b(xunit|nunit|mstest|microsoft\.testing\.framework)\b/i,
1142
+ configCheck: (root) => {
1143
+ const content = readCsprojContent(root);
1144
+ if (!content) return false;
1145
+ return /PackageReference\s+Include="(xunit|NUnit|MSTest|Microsoft\.Testing)/i.test(
1146
+ content
1147
+ );
1148
+ },
1149
+ configName: ".csproj (PackageReference xUnit / NUnit / MSTest)"
1150
+ },
1151
+ {
1152
+ id: "csharp-formatter",
1153
+ rulePattern: /\bdotnet\s*format\b/i,
1154
+ configCheck: (root) => checkEditorConfigPattern(root, /^\[.*\.cs\]/m) && checkEditorConfigPattern(root, /^csharp_/m),
1155
+ configName: ".editorconfig ([*.cs] csharp_style_* settings)"
1156
+ },
1157
+ {
1158
+ id: "csharp-naming",
1159
+ rulePattern: /\bdotnet_naming\b|PascalCase\s+for\s+(all\s+)?(public|class|method|property|type)\b/i,
1160
+ configCheck: (root) => checkEditorConfigPattern(root, /^dotnet_naming_rule\./m),
1161
+ configName: ".editorconfig (dotnet_naming_rule.*)"
1162
+ },
1163
+ {
1164
+ id: "csharp-unused",
1165
+ rulePattern: /\b(IDE0059|CS0168|CS0219|dead\s*code|unused.*IDE|roslyn.*unused)\b/i,
1166
+ configCheck: (root) => checkEditorConfigPattern(
1167
+ root,
1168
+ /^dotnet_diagnostic\.(IDE0059|CS0168|CS0219)/m
1169
+ ),
1170
+ configName: ".editorconfig (dotnet_diagnostic.IDE0059 / CS0168)"
1094
1171
  }
1095
1172
  ];
1096
1173
  function collectRuleLines(instructions) {
@@ -1141,6 +1218,7 @@ function detectConfigOverlaps(instructions, projectRoot) {
1141
1218
 
1142
1219
  // src/utils/text.ts
1143
1220
  var STOP_WORDS = /* @__PURE__ */ new Set([
1221
+ // English grammatical function words
1144
1222
  "the",
1145
1223
  "a",
1146
1224
  "an",
@@ -1154,10 +1232,30 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
1154
1232
  "of",
1155
1233
  "with",
1156
1234
  "that",
1157
- "this"
1235
+ "this",
1236
+ // Chinese filler bigrams (grammatical / no topic content)
1237
+ "\u7684\u6642",
1238
+ "\u6642\u5019",
1239
+ "\u4E00\u500B",
1240
+ "\u6240\u6709",
1241
+ "\u6BCF\u500B"
1158
1242
  ]);
1243
+ var CJK_RUN = /[\u4e00-\u9fff\u3400-\u4dbf]+/g;
1244
+ var ASCII_WORD = /[a-z0-9]+/g;
1159
1245
  function tokenizeWords(text) {
1160
- return text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
1246
+ const words = [];
1247
+ const lower = text.toLowerCase();
1248
+ for (const m of lower.matchAll(ASCII_WORD)) {
1249
+ if (m[0].length > 1) words.push(m[0]);
1250
+ }
1251
+ for (const m of text.matchAll(CJK_RUN)) {
1252
+ const run = m[0];
1253
+ if (run.length < 2) continue;
1254
+ for (let i = 0; i < run.length - 1; i++) {
1255
+ words.push(run.slice(i, i + 2));
1256
+ }
1257
+ }
1258
+ return words;
1161
1259
  }
1162
1260
  function removeStopWords(words) {
1163
1261
  return words.filter((w) => !STOP_WORDS.has(w));
@@ -1238,8 +1336,10 @@ function analyzeDeadRules(instructions, projectRoot) {
1238
1336
 
1239
1337
  // src/detectors/contradiction.ts
1240
1338
  var NEGATION_WORDS = ["never", "don't", "avoid", "forbid"];
1339
+ var CJK_NEGATIONS = ["\u7981\u6B62", "\u4E0D\u8981", "\u4E0D\u53EF", "\u4E0D\u5F97", "\u907F\u514D", "\u8ACB\u52FF", "\u52FF"];
1340
+ var CJK_ONLY_RE = /^[\u4e00-\u9fff\u3400-\u4dbf]+$/;
1241
1341
  function isNegated(text, word) {
1242
- const sentences = text.split(/[.!?]+\s+/);
1342
+ const sentences = text.split(/[.!?。!?]+\s*/);
1243
1343
  const escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1244
1344
  const wordPresent = new RegExp(`\\b${escapedWord}\\b`, "i");
1245
1345
  for (const sentence of sentences) {
@@ -1258,6 +1358,14 @@ function isNegated(text, word) {
1258
1358
  );
1259
1359
  if (notPattern.test(lower)) return true;
1260
1360
  }
1361
+ if (CJK_ONLY_RE.test(word)) {
1362
+ for (const sentence of sentences) {
1363
+ if (!sentence.includes(word)) continue;
1364
+ for (const neg of CJK_NEGATIONS) {
1365
+ if (sentence.includes(neg)) return true;
1366
+ }
1367
+ }
1368
+ }
1261
1369
  return false;
1262
1370
  }
1263
1371
  var POLARITY_STOP_WORDS = /* @__PURE__ */ new Set([
@@ -1294,7 +1402,26 @@ var POLARITY_STOP_WORDS = /* @__PURE__ */ new Set([
1294
1402
  "be",
1295
1403
  "by",
1296
1404
  "own",
1297
- "on"
1405
+ "on",
1406
+ // Chinese polarity / imperative bigrams (analogues of English never/always/use/must)
1407
+ "\u6C38\u9060",
1408
+ "\u7E3D\u662F",
1409
+ "\u7981\u6B62",
1410
+ "\u4E0D\u8981",
1411
+ "\u4E0D\u53EF",
1412
+ "\u4E0D\u5F97",
1413
+ "\u907F\u514D",
1414
+ "\u8ACB\u52FF",
1415
+ "\u5FC5\u9808",
1416
+ "\u61C9\u8A72",
1417
+ "\u61C9\u7576",
1418
+ // Chinese generic verb bigrams (HOW to comply, not WHAT topic)
1419
+ "\u4F7F\u7528",
1420
+ "\u63A1\u7528",
1421
+ "\u57F7\u884C",
1422
+ "\u9032\u884C",
1423
+ "\u53EF\u4EE5",
1424
+ "\u9700\u8981"
1298
1425
  ]);
1299
1426
  function collectRuleLines3(instructions) {
1300
1427
  const sources = [
@@ -1399,7 +1526,7 @@ function detectStaleRefs(instructions, projectRoot) {
1399
1526
 
1400
1527
  // src/detectors/scope-classifier.ts
1401
1528
  var PATH_REF_PATTERN = /\b(?:src|tests?|lib|dist)\//i;
1402
- var HOOK_PATTERN = /\b(?:never|don't|do\s+not|forbid)\b.*\b(?:commit|push|merge|build|run)\b/i;
1529
+ 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;
1403
1530
  function classifyScope(instructions) {
1404
1531
  const findings = [];
1405
1532
  const rootFile = instructions.rootFile;
@@ -2436,14 +2563,15 @@ function readInstalledVersion(path) {
2436
2563
  return null;
2437
2564
  }
2438
2565
  }
2439
- function checkSkillUpdate(projectRoot) {
2566
+ function checkSkillUpdate(projectRoot, globalRoot) {
2567
+ const resolvedGlobal = globalRoot ?? homedir();
2440
2568
  const candidates = [
2441
2569
  {
2442
2570
  path: join7(projectRoot, ".claude", "commands", "instrlint.md"),
2443
2571
  isProject: true
2444
2572
  },
2445
2573
  {
2446
- path: join7(homedir(), ".claude", "commands", "instrlint.md"),
2574
+ path: join7(resolvedGlobal, ".claude", "commands", "instrlint.md"),
2447
2575
  isProject: false
2448
2576
  }
2449
2577
  ];
@@ -2486,7 +2614,7 @@ import { relative as relative3 } from "path";
2486
2614
  function shouldVerify(finding) {
2487
2615
  if (finding.category === "stale-ref") return false;
2488
2616
  if (finding.category === "budget") return false;
2489
- if (finding.category === "structure") return false;
2617
+ if (finding.category === "structure") return true;
2490
2618
  if (finding.category === "dead-rule") return !finding.autoFixable;
2491
2619
  if (finding.category === "contradiction") return true;
2492
2620
  if (finding.category === "duplicate") {
@@ -2533,6 +2661,13 @@ function buildContext(finding, parsed, projectRoot) {
2533
2661
  ruleB: ruleRef(parsed, finding.file, finding.line ?? 0, projectRoot)
2534
2662
  };
2535
2663
  }
2664
+ if (finding.category === "structure") {
2665
+ return {
2666
+ type: "structure",
2667
+ rule: ruleRef(parsed, finding.file, finding.line ?? 0, projectRoot),
2668
+ suggestion: finding.suggestion
2669
+ };
2670
+ }
2536
2671
  return null;
2537
2672
  }
2538
2673
  function hashFinding(finding) {
@@ -2547,6 +2682,10 @@ var QUESTIONS = {
2547
2682
  duplicate: {
2548
2683
  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>"}',
2549
2684
  "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>"}'
2685
+ },
2686
+ structure: {
2687
+ 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>"}',
2688
+ "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>"}'
2550
2689
  }
2551
2690
  };
2552
2691
  function questionFor(category, locale) {