@yawlabs/ctxlint 0.9.6 → 0.9.8

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.
Files changed (2) hide show
  1. package/dist/index.js +253 -78
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -34322,29 +34322,24 @@ async function scanForContextFiles(projectRoot, options = {}) {
34322
34322
  }
34323
34323
  }
34324
34324
  collectDirs(projectRoot, 0);
34325
- for (const dir of dirsToScan) {
34326
- for (const pattern of patterns) {
34327
- const matches = await Ze(pattern, {
34328
- cwd: dir,
34329
- absolute: true,
34330
- nodir: true,
34331
- dot: true
34325
+ const perDirMatches = await Promise.all(
34326
+ dirsToScan.map((dir) => Ze(patterns, { cwd: dir, absolute: true, nodir: true, dot: true }))
34327
+ );
34328
+ for (const matches of perDirMatches) {
34329
+ for (const match of matches) {
34330
+ const normalized = path2.normalize(match);
34331
+ if (seen.has(normalized)) continue;
34332
+ seen.add(normalized);
34333
+ const relativePath = path2.relative(projectRoot, normalized);
34334
+ const symlink = isSymlink(normalized);
34335
+ const target = symlink ? readSymlinkTarget(normalized) : void 0;
34336
+ found.push({
34337
+ absolutePath: normalized,
34338
+ relativePath: relativePath.replace(/\\/g, "/"),
34339
+ isSymlink: symlink,
34340
+ symlinkTarget: target,
34341
+ type: "context"
34332
34342
  });
34333
- for (const match of matches) {
34334
- const normalized = path2.normalize(match);
34335
- if (seen.has(normalized)) continue;
34336
- seen.add(normalized);
34337
- const relativePath = path2.relative(projectRoot, normalized);
34338
- const symlink = isSymlink(normalized);
34339
- const target = symlink ? readSymlinkTarget(normalized) : void 0;
34340
- found.push({
34341
- absolutePath: normalized,
34342
- relativePath: relativePath.replace(/\\/g, "/"),
34343
- isSymlink: symlink,
34344
- symlinkTarget: target,
34345
- type: "context"
34346
- });
34347
- }
34348
34343
  }
34349
34344
  }
34350
34345
  return found.sort((a, b2) => a.relativePath.localeCompare(b2.relativePath));
@@ -34622,9 +34617,14 @@ function extractPathReferences(lines, sections) {
34622
34617
  if (/^[A-Z][\w.-]*\/[A-Z][\w.-]*$/.test(value) && !value.includes(".")) {
34623
34618
  continue;
34624
34619
  }
34620
+ let cleanValue = value;
34621
+ while (/[.,;:]$/.test(cleanValue) && cleanValue.includes("/")) {
34622
+ cleanValue = cleanValue.slice(0, -1);
34623
+ }
34624
+ if (!cleanValue.includes("/")) continue;
34625
34625
  const column = match.index + match[0].length - match[1].length + 1;
34626
34626
  paths.push({
34627
- value,
34627
+ value: cleanValue,
34628
34628
  line: i2 + 1,
34629
34629
  // 1-indexed
34630
34630
  column,
@@ -40501,17 +40501,54 @@ async function getFileLastModified(projectRoot, filePath) {
40501
40501
  return null;
40502
40502
  }
40503
40503
  }
40504
- async function getCommitsSince(projectRoot, filePath, since) {
40504
+ async function getCommitsSinceBatch(projectRoot, paths, since) {
40505
+ const counts = /* @__PURE__ */ new Map();
40506
+ for (const p2 of paths) counts.set(p2, 0);
40507
+ if (paths.length === 0) return counts;
40505
40508
  try {
40506
40509
  const git = getGit(projectRoot);
40507
- const log = await git.log({
40508
- file: filePath,
40509
- "--since": since.toISOString()
40510
- });
40511
- return log.total;
40510
+ const SENTINEL = "___CTXLINT_COMMIT___";
40511
+ const raw = await git.raw([
40512
+ "log",
40513
+ `--since=${since.toISOString()}`,
40514
+ "--name-only",
40515
+ `--format=${SENTINEL}`
40516
+ ]);
40517
+ const normalize3 = (p2) => p2.replace(/\\/g, "/");
40518
+ const requested = new Set(paths.map(normalize3));
40519
+ const lines = raw.split("\n");
40520
+ let inCommit = false;
40521
+ const seenThisCommit = /* @__PURE__ */ new Set();
40522
+ const flush = () => {
40523
+ for (const p2 of seenThisCommit) {
40524
+ counts.set(p2, (counts.get(p2) ?? 0) + 1);
40525
+ }
40526
+ seenThisCommit.clear();
40527
+ };
40528
+ for (const line of lines) {
40529
+ if (line === SENTINEL) {
40530
+ if (inCommit) flush();
40531
+ inCommit = true;
40532
+ continue;
40533
+ }
40534
+ if (!inCommit) continue;
40535
+ const trimmed2 = line.trim();
40536
+ if (!trimmed2) continue;
40537
+ const changed = normalize3(trimmed2);
40538
+ for (const req of requested) {
40539
+ if (changed === req || changed.startsWith(req.endsWith("/") ? req : req + "/")) {
40540
+ seenThisCommit.add(req);
40541
+ }
40542
+ }
40543
+ }
40544
+ if (inCommit) flush();
40512
40545
  } catch {
40513
- return 0;
40514
40546
  }
40547
+ const out = /* @__PURE__ */ new Map();
40548
+ for (const p2 of paths) {
40549
+ out.set(p2, counts.get(p2.replace(/\\/g, "/")) ?? 0);
40550
+ }
40551
+ return out;
40515
40552
  }
40516
40553
  async function findRenames(projectRoot, filePath) {
40517
40554
  try {
@@ -40853,7 +40890,7 @@ var require_levenshtein = __commonJS({
40853
40890
  } catch (err) {
40854
40891
  console.log("Collator could not be initialized and wouldn't be used");
40855
40892
  }
40856
- var levenshtein2 = require_mod();
40893
+ var levenshtein3 = require_mod();
40857
40894
  var prevRow = [], str2Char = [];
40858
40895
  var Levenshtein = {
40859
40896
  /**
@@ -40898,7 +40935,7 @@ var require_levenshtein = __commonJS({
40898
40935
  }
40899
40936
  return nextCol;
40900
40937
  }
40901
- return levenshtein2.distance(str1, str2);
40938
+ return levenshtein3.distance(str1, str2);
40902
40939
  }
40903
40940
  };
40904
40941
  if (typeof define !== "undefined" && define !== null && define.amd) {
@@ -41193,11 +41230,14 @@ async function checkStaleness(file2, projectRoot) {
41193
41230
  for (const ref of file2.references.paths) {
41194
41231
  referencedPaths.add(ref.value);
41195
41232
  }
41233
+ if (referencedPaths.size === 0) {
41234
+ return issues;
41235
+ }
41236
+ const counts = await getCommitsSinceBatch(projectRoot, [...referencedPaths], lastModified);
41196
41237
  let totalCommits = 0;
41197
41238
  let mostActiveRef = "";
41198
41239
  let mostActiveCommits = 0;
41199
- for (const refPath of referencedPaths) {
41200
- const commits = await getCommitsSince(projectRoot, refPath, lastModified);
41240
+ for (const [refPath, commits] of counts) {
41201
41241
  totalCommits += commits;
41202
41242
  if (commits > mostActiveCommits) {
41203
41243
  mostActiveCommits = commits;
@@ -41688,54 +41728,88 @@ function detectDirectives(file2) {
41688
41728
  function checkContradictions(files) {
41689
41729
  if (files.length < 2) return [];
41690
41730
  const issues = [];
41691
- const allDirectives = [];
41692
- for (const file2 of files) {
41693
- allDirectives.push(...detectDirectives(file2));
41694
- }
41695
41731
  const byCategory = /* @__PURE__ */ new Map();
41696
- for (const d of allDirectives) {
41697
- const existing = byCategory.get(d.category) || [];
41698
- existing.push(d);
41699
- byCategory.set(d.category, existing);
41732
+ const directiveIndex = /* @__PURE__ */ new Map();
41733
+ for (const file2 of files) {
41734
+ for (const d of detectDirectives(file2)) {
41735
+ let list = byCategory.get(d.category);
41736
+ if (!list) {
41737
+ list = [];
41738
+ byCategory.set(d.category, list);
41739
+ }
41740
+ list.push(d);
41741
+ let idx = directiveIndex.get(d.category);
41742
+ if (!idx) {
41743
+ idx = /* @__PURE__ */ new Map();
41744
+ directiveIndex.set(d.category, idx);
41745
+ }
41746
+ const key = `${d.file}::${d.label}`;
41747
+ if (!idx.has(key)) idx.set(key, d);
41748
+ }
41700
41749
  }
41701
41750
  for (const [category, directives] of byCategory) {
41702
- const byFile = /* @__PURE__ */ new Map();
41703
- for (const d of directives) {
41704
- const existing = byFile.get(d.file) || [];
41705
- existing.push(d);
41706
- byFile.set(d.file, existing);
41707
- }
41708
41751
  const labels = new Set(directives.map((d) => d.label));
41709
41752
  if (labels.size <= 1) continue;
41710
41753
  const fileLabels = /* @__PURE__ */ new Map();
41711
41754
  for (const d of directives) {
41712
- const existing = fileLabels.get(d.file) || /* @__PURE__ */ new Set();
41755
+ let existing = fileLabels.get(d.file);
41756
+ if (!existing) {
41757
+ existing = /* @__PURE__ */ new Set();
41758
+ fileLabels.set(d.file, existing);
41759
+ }
41713
41760
  existing.add(d.label);
41714
- fileLabels.set(d.file, existing);
41715
- }
41716
- const fileEntries = [...fileLabels.entries()];
41717
- for (let i2 = 0; i2 < fileEntries.length; i2++) {
41718
- for (let j3 = i2 + 1; j3 < fileEntries.length; j3++) {
41719
- const [fileA, labelsA] = fileEntries[i2];
41720
- const [fileB, labelsB] = fileEntries[j3];
41721
- for (const labelA of labelsA) {
41722
- for (const labelB of labelsB) {
41723
- if (labelA !== labelB) {
41724
- const directiveA = directives.find((d) => d.file === fileA && d.label === labelA);
41725
- const directiveB = directives.find((d) => d.file === fileB && d.label === labelB);
41726
- issues.push({
41727
- severity: "warning",
41728
- check: "contradictions",
41729
- ruleId: "contradictions/conflict",
41730
- line: directiveA.line,
41731
- message: `${category} conflict: "${directiveA.label}" in ${fileA} vs "${directiveB.label}" in ${fileB}`,
41732
- suggestion: `Align on one ${category} across all context files`,
41733
- detail: `${fileA}:${directiveA.line} says "${directiveA.text}" but ${fileB}:${directiveB.line} says "${directiveB.text}"`
41734
- });
41735
- }
41736
- }
41761
+ }
41762
+ const conflictingFiles = [...fileLabels.keys()].filter((f) => {
41763
+ const myLabels = fileLabels.get(f);
41764
+ for (const [otherFile, otherLabels] of fileLabels) {
41765
+ if (otherFile === f) continue;
41766
+ for (const l of myLabels) {
41767
+ if (!otherLabels.has(l)) return true;
41768
+ }
41769
+ }
41770
+ return false;
41771
+ });
41772
+ if (conflictingFiles.length < 2) continue;
41773
+ const idx = directiveIndex.get(category);
41774
+ if (conflictingFiles.length === 2) {
41775
+ const [fileA, fileB] = conflictingFiles;
41776
+ const labelsA = fileLabels.get(fileA);
41777
+ const labelsB = fileLabels.get(fileB);
41778
+ for (const labelA of labelsA) {
41779
+ for (const labelB of labelsB) {
41780
+ if (labelA === labelB) continue;
41781
+ const directiveA = idx.get(`${fileA}::${labelA}`);
41782
+ const directiveB = idx.get(`${fileB}::${labelB}`);
41783
+ issues.push({
41784
+ severity: "warning",
41785
+ check: "contradictions",
41786
+ ruleId: "contradictions/conflict",
41787
+ line: directiveA.line,
41788
+ message: `${category} conflict: "${directiveA.label}" in ${fileA} vs "${directiveB.label}" in ${fileB}`,
41789
+ suggestion: `Align on one ${category} across all context files`,
41790
+ detail: `${fileA}:${directiveA.line} says "${directiveA.text}" but ${fileB}:${directiveB.line} says "${directiveB.text}"`
41791
+ });
41737
41792
  }
41738
41793
  }
41794
+ } else {
41795
+ const entries = [];
41796
+ for (const f of conflictingFiles) {
41797
+ for (const l of fileLabels.get(f)) {
41798
+ entries.push(idx.get(`${f}::${l}`));
41799
+ }
41800
+ }
41801
+ const firstEntry = entries[0];
41802
+ const summary = entries.map((e) => `"${e.label}" in ${e.file}`).join(", ");
41803
+ const detail = entries.map((e) => `${e.file}:${e.line} says "${e.text}"`).join("\n");
41804
+ issues.push({
41805
+ severity: "warning",
41806
+ check: "contradictions",
41807
+ ruleId: "contradictions/conflict",
41808
+ line: firstEntry.line,
41809
+ message: `${category} conflict across ${conflictingFiles.length} files: ${summary}`,
41810
+ suggestion: `Align on one ${category} across all context files`,
41811
+ detail
41812
+ });
41739
41813
  }
41740
41814
  }
41741
41815
  return issues;
@@ -42413,6 +42487,7 @@ async function checkMcpCommands(config2, projectRoot) {
42413
42487
  }
42414
42488
  if (server2.args) {
42415
42489
  for (const arg of server2.args) {
42490
+ if (URL_PREFIX.test(arg)) continue;
42416
42491
  if (LOCAL_PATH_PATTERN.test(arg) || FILE_PATH_PATTERN.test(arg)) {
42417
42492
  const resolved = path8.resolve(projectRoot, arg);
42418
42493
  if (!fileExistsSafe(resolved)) {
@@ -42438,12 +42513,13 @@ function fileExistsSafe(filePath) {
42438
42513
  return false;
42439
42514
  }
42440
42515
  }
42441
- var LOCAL_PATH_PATTERN, FILE_PATH_PATTERN;
42516
+ var LOCAL_PATH_PATTERN, FILE_PATH_PATTERN, URL_PREFIX;
42442
42517
  var init_commands2 = __esm({
42443
42518
  "src/core/checks/mcp/commands.ts"() {
42444
42519
  "use strict";
42445
42520
  LOCAL_PATH_PATTERN = /^\.\.?\//;
42446
42521
  FILE_PATH_PATTERN = /^[^-].*\/.*\.\w+$/;
42522
+ URL_PREFIX = /^(https?|file|s3|gs|ssh|git):\/\//i;
42447
42523
  }
42448
42524
  });
42449
42525
 
@@ -43766,7 +43842,7 @@ import { readFileSync as readFileSync4 } from "node:fs";
43766
43842
  import { resolve as resolve8, dirname as dirname3 } from "node:path";
43767
43843
  import { fileURLToPath } from "node:url";
43768
43844
  function loadVersion() {
43769
- if (true) return "0.9.6";
43845
+ if (true) return "0.9.8";
43770
43846
  const __dir = dirname3(fileURLToPath(import.meta.url));
43771
43847
  const pkgPath = resolve8(__dir, "../package.json");
43772
43848
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
@@ -44597,6 +44673,7 @@ function applyFixes(result, options = {}) {
44597
44673
  }
44598
44674
  }
44599
44675
  const fixesByFile = /* @__PURE__ */ new Map();
44676
+ const dedupeKeys = /* @__PURE__ */ new Map();
44600
44677
  const skippedSymlinks = /* @__PURE__ */ new Set();
44601
44678
  for (const file2 of result.files) {
44602
44679
  for (const issue2 of file2.issues) {
@@ -44605,6 +44682,14 @@ function applyFixes(result, options = {}) {
44605
44682
  skippedSymlinks.add(file2.path);
44606
44683
  continue;
44607
44684
  }
44685
+ const key = `${issue2.fix.line}:${issue2.fix.oldText}:${issue2.fix.newText}`;
44686
+ let seenInFile = dedupeKeys.get(issue2.fix.file);
44687
+ if (!seenInFile) {
44688
+ seenInFile = /* @__PURE__ */ new Set();
44689
+ dedupeKeys.set(issue2.fix.file, seenInFile);
44690
+ }
44691
+ if (seenInFile.has(key)) continue;
44692
+ seenInFile.add(key);
44608
44693
  const existing = fixesByFile.get(issue2.fix.file) || [];
44609
44694
  existing.push(issue2.fix);
44610
44695
  fixesByFile.set(issue2.fix.file, existing);
@@ -51751,6 +51836,76 @@ var init_reporter = __esm({
51751
51836
  // src/core/config.ts
51752
51837
  import * as fs8 from "node:fs";
51753
51838
  import * as path10 from "node:path";
51839
+ function formatJsonError(content, err) {
51840
+ const msg = err instanceof Error ? err.message : String(err);
51841
+ const posMatch = msg.match(/position (\d+)/);
51842
+ if (posMatch) {
51843
+ return `${msg} (${posToLineCol(content, Number(posMatch[1]))})`;
51844
+ }
51845
+ if (/line \d+/.test(msg)) return msg;
51846
+ const pos = findFirstErrorPos(content, msg);
51847
+ if (pos !== null) {
51848
+ return `${msg} (${posToLineCol(content, pos)})`;
51849
+ }
51850
+ return msg;
51851
+ }
51852
+ function posToLineCol(content, pos) {
51853
+ let line = 1;
51854
+ let col = 1;
51855
+ for (let i2 = 0; i2 < pos && i2 < content.length; i2++) {
51856
+ if (content[i2] === "\n") {
51857
+ line++;
51858
+ col = 1;
51859
+ } else {
51860
+ col++;
51861
+ }
51862
+ }
51863
+ return `line ${line}, column ${col}`;
51864
+ }
51865
+ function findFirstErrorPos(content, errMsg) {
51866
+ const snippetMatch = errMsg.match(/\.\.\."([^"]+(?:"[^"]*)*?)"\s+is not valid JSON/);
51867
+ if (snippetMatch) {
51868
+ const snippet = snippetMatch[1];
51869
+ const needle = snippet.split(/\s/).find((s) => s.length > 1);
51870
+ if (needle) {
51871
+ const idx = content.indexOf(needle);
51872
+ if (idx !== -1) return idx;
51873
+ }
51874
+ }
51875
+ const tokenMatch = errMsg.match(/Unexpected token\s+['"]?([^'",]+?)['"]?,/);
51876
+ if (tokenMatch) {
51877
+ const tok = tokenMatch[1];
51878
+ const idx = content.indexOf(tok);
51879
+ if (idx !== -1) return idx;
51880
+ }
51881
+ return null;
51882
+ }
51883
+ function suggestKey(unknown2) {
51884
+ let best = null;
51885
+ let bestDist = Infinity;
51886
+ for (const known of KNOWN_CONFIG_KEYS) {
51887
+ const d = levenshtein2(unknown2, known);
51888
+ if (d < bestDist) {
51889
+ bestDist = d;
51890
+ best = known;
51891
+ }
51892
+ }
51893
+ if (best && bestDist <= Math.max(2, Math.floor(unknown2.length / 3))) {
51894
+ return best;
51895
+ }
51896
+ return null;
51897
+ }
51898
+ function warnUnknownKeys(config2, source) {
51899
+ if (!config2 || typeof config2 !== "object" || Array.isArray(config2)) return;
51900
+ const keys = Object.keys(config2);
51901
+ const known = new Set(KNOWN_CONFIG_KEYS);
51902
+ for (const k3 of keys) {
51903
+ if (known.has(k3)) continue;
51904
+ const hint = suggestKey(k3);
51905
+ const suggestion = hint ? ` \u2014 did you mean "${hint}"?` : "";
51906
+ console.error(`Warning: unknown config key "${k3}" in ${source}${suggestion}`);
51907
+ }
51908
+ }
51754
51909
  function loadConfig(projectRoot) {
51755
51910
  for (const filename of CONFIG_FILENAMES) {
51756
51911
  const filePath = path10.join(projectRoot, filename);
@@ -51760,19 +51915,39 @@ function loadConfig(projectRoot) {
51760
51915
  } catch {
51761
51916
  continue;
51762
51917
  }
51918
+ let parsed;
51763
51919
  try {
51764
- return JSON.parse(content);
51920
+ parsed = JSON.parse(content);
51765
51921
  } catch (err) {
51766
- const msg = err instanceof Error ? err.message : String(err);
51767
- throw new Error(`Invalid JSON in ${filePath}: ${msg}`, { cause: err });
51922
+ throw new Error(`Invalid JSON in ${filePath}: ${formatJsonError(content, err)}`, {
51923
+ cause: err
51924
+ });
51768
51925
  }
51926
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
51927
+ throw new Error(
51928
+ `Invalid config in ${filePath}: expected a JSON object at the root, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`
51929
+ );
51930
+ }
51931
+ warnUnknownKeys(parsed, filePath);
51932
+ return parsed;
51769
51933
  }
51770
51934
  return null;
51771
51935
  }
51772
- var CONFIG_FILENAMES;
51936
+ var import_fast_levenshtein2, levenshtein2, KNOWN_CONFIG_KEYS, CONFIG_FILENAMES;
51773
51937
  var init_config2 = __esm({
51774
51938
  "src/core/config.ts"() {
51775
51939
  "use strict";
51940
+ import_fast_levenshtein2 = __toESM(require_levenshtein(), 1);
51941
+ levenshtein2 = import_fast_levenshtein2.default.get;
51942
+ KNOWN_CONFIG_KEYS = [
51943
+ "checks",
51944
+ "ignore",
51945
+ "strict",
51946
+ "tokenThresholds",
51947
+ "contextFiles",
51948
+ "mcp",
51949
+ "mcpGlobal"
51950
+ ];
51776
51951
  CONFIG_FILENAMES = [".ctxlintrc", ".ctxlintrc.json"];
51777
51952
  }
51778
51953
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Lint your AI agent context files, MCP server configs, and session data against your actual codebase",
5
5
  "bin": {
6
6
  "ctxlint": "dist/index.js"