@yawlabs/ctxlint 0.9.6 → 0.9.7

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 +247 -77
  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));
@@ -40501,17 +40496,54 @@ async function getFileLastModified(projectRoot, filePath) {
40501
40496
  return null;
40502
40497
  }
40503
40498
  }
40504
- async function getCommitsSince(projectRoot, filePath, since) {
40499
+ async function getCommitsSinceBatch(projectRoot, paths, since) {
40500
+ const counts = /* @__PURE__ */ new Map();
40501
+ for (const p2 of paths) counts.set(p2, 0);
40502
+ if (paths.length === 0) return counts;
40505
40503
  try {
40506
40504
  const git = getGit(projectRoot);
40507
- const log = await git.log({
40508
- file: filePath,
40509
- "--since": since.toISOString()
40510
- });
40511
- return log.total;
40505
+ const SENTINEL = "___CTXLINT_COMMIT___";
40506
+ const raw = await git.raw([
40507
+ "log",
40508
+ `--since=${since.toISOString()}`,
40509
+ "--name-only",
40510
+ `--format=${SENTINEL}`
40511
+ ]);
40512
+ const normalize3 = (p2) => p2.replace(/\\/g, "/");
40513
+ const requested = new Set(paths.map(normalize3));
40514
+ const lines = raw.split("\n");
40515
+ let inCommit = false;
40516
+ const seenThisCommit = /* @__PURE__ */ new Set();
40517
+ const flush = () => {
40518
+ for (const p2 of seenThisCommit) {
40519
+ counts.set(p2, (counts.get(p2) ?? 0) + 1);
40520
+ }
40521
+ seenThisCommit.clear();
40522
+ };
40523
+ for (const line of lines) {
40524
+ if (line === SENTINEL) {
40525
+ if (inCommit) flush();
40526
+ inCommit = true;
40527
+ continue;
40528
+ }
40529
+ if (!inCommit) continue;
40530
+ const trimmed2 = line.trim();
40531
+ if (!trimmed2) continue;
40532
+ const changed = normalize3(trimmed2);
40533
+ for (const req of requested) {
40534
+ if (changed === req || changed.startsWith(req.endsWith("/") ? req : req + "/")) {
40535
+ seenThisCommit.add(req);
40536
+ }
40537
+ }
40538
+ }
40539
+ if (inCommit) flush();
40512
40540
  } catch {
40513
- return 0;
40514
40541
  }
40542
+ const out = /* @__PURE__ */ new Map();
40543
+ for (const p2 of paths) {
40544
+ out.set(p2, counts.get(p2.replace(/\\/g, "/")) ?? 0);
40545
+ }
40546
+ return out;
40515
40547
  }
40516
40548
  async function findRenames(projectRoot, filePath) {
40517
40549
  try {
@@ -40853,7 +40885,7 @@ var require_levenshtein = __commonJS({
40853
40885
  } catch (err) {
40854
40886
  console.log("Collator could not be initialized and wouldn't be used");
40855
40887
  }
40856
- var levenshtein2 = require_mod();
40888
+ var levenshtein3 = require_mod();
40857
40889
  var prevRow = [], str2Char = [];
40858
40890
  var Levenshtein = {
40859
40891
  /**
@@ -40898,7 +40930,7 @@ var require_levenshtein = __commonJS({
40898
40930
  }
40899
40931
  return nextCol;
40900
40932
  }
40901
- return levenshtein2.distance(str1, str2);
40933
+ return levenshtein3.distance(str1, str2);
40902
40934
  }
40903
40935
  };
40904
40936
  if (typeof define !== "undefined" && define !== null && define.amd) {
@@ -41193,11 +41225,14 @@ async function checkStaleness(file2, projectRoot) {
41193
41225
  for (const ref of file2.references.paths) {
41194
41226
  referencedPaths.add(ref.value);
41195
41227
  }
41228
+ if (referencedPaths.size === 0) {
41229
+ return issues;
41230
+ }
41231
+ const counts = await getCommitsSinceBatch(projectRoot, [...referencedPaths], lastModified);
41196
41232
  let totalCommits = 0;
41197
41233
  let mostActiveRef = "";
41198
41234
  let mostActiveCommits = 0;
41199
- for (const refPath of referencedPaths) {
41200
- const commits = await getCommitsSince(projectRoot, refPath, lastModified);
41235
+ for (const [refPath, commits] of counts) {
41201
41236
  totalCommits += commits;
41202
41237
  if (commits > mostActiveCommits) {
41203
41238
  mostActiveCommits = commits;
@@ -41688,54 +41723,88 @@ function detectDirectives(file2) {
41688
41723
  function checkContradictions(files) {
41689
41724
  if (files.length < 2) return [];
41690
41725
  const issues = [];
41691
- const allDirectives = [];
41692
- for (const file2 of files) {
41693
- allDirectives.push(...detectDirectives(file2));
41694
- }
41695
41726
  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);
41727
+ const directiveIndex = /* @__PURE__ */ new Map();
41728
+ for (const file2 of files) {
41729
+ for (const d of detectDirectives(file2)) {
41730
+ let list = byCategory.get(d.category);
41731
+ if (!list) {
41732
+ list = [];
41733
+ byCategory.set(d.category, list);
41734
+ }
41735
+ list.push(d);
41736
+ let idx = directiveIndex.get(d.category);
41737
+ if (!idx) {
41738
+ idx = /* @__PURE__ */ new Map();
41739
+ directiveIndex.set(d.category, idx);
41740
+ }
41741
+ const key = `${d.file}::${d.label}`;
41742
+ if (!idx.has(key)) idx.set(key, d);
41743
+ }
41700
41744
  }
41701
41745
  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
41746
  const labels = new Set(directives.map((d) => d.label));
41709
41747
  if (labels.size <= 1) continue;
41710
41748
  const fileLabels = /* @__PURE__ */ new Map();
41711
41749
  for (const d of directives) {
41712
- const existing = fileLabels.get(d.file) || /* @__PURE__ */ new Set();
41750
+ let existing = fileLabels.get(d.file);
41751
+ if (!existing) {
41752
+ existing = /* @__PURE__ */ new Set();
41753
+ fileLabels.set(d.file, existing);
41754
+ }
41713
41755
  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
- }
41756
+ }
41757
+ const conflictingFiles = [...fileLabels.keys()].filter((f) => {
41758
+ const myLabels = fileLabels.get(f);
41759
+ for (const [otherFile, otherLabels] of fileLabels) {
41760
+ if (otherFile === f) continue;
41761
+ for (const l of myLabels) {
41762
+ if (!otherLabels.has(l)) return true;
41763
+ }
41764
+ }
41765
+ return false;
41766
+ });
41767
+ if (conflictingFiles.length < 2) continue;
41768
+ const idx = directiveIndex.get(category);
41769
+ if (conflictingFiles.length === 2) {
41770
+ const [fileA, fileB] = conflictingFiles;
41771
+ const labelsA = fileLabels.get(fileA);
41772
+ const labelsB = fileLabels.get(fileB);
41773
+ for (const labelA of labelsA) {
41774
+ for (const labelB of labelsB) {
41775
+ if (labelA === labelB) continue;
41776
+ const directiveA = idx.get(`${fileA}::${labelA}`);
41777
+ const directiveB = idx.get(`${fileB}::${labelB}`);
41778
+ issues.push({
41779
+ severity: "warning",
41780
+ check: "contradictions",
41781
+ ruleId: "contradictions/conflict",
41782
+ line: directiveA.line,
41783
+ message: `${category} conflict: "${directiveA.label}" in ${fileA} vs "${directiveB.label}" in ${fileB}`,
41784
+ suggestion: `Align on one ${category} across all context files`,
41785
+ detail: `${fileA}:${directiveA.line} says "${directiveA.text}" but ${fileB}:${directiveB.line} says "${directiveB.text}"`
41786
+ });
41737
41787
  }
41738
41788
  }
41789
+ } else {
41790
+ const entries = [];
41791
+ for (const f of conflictingFiles) {
41792
+ for (const l of fileLabels.get(f)) {
41793
+ entries.push(idx.get(`${f}::${l}`));
41794
+ }
41795
+ }
41796
+ const firstEntry = entries[0];
41797
+ const summary = entries.map((e) => `"${e.label}" in ${e.file}`).join(", ");
41798
+ const detail = entries.map((e) => `${e.file}:${e.line} says "${e.text}"`).join("\n");
41799
+ issues.push({
41800
+ severity: "warning",
41801
+ check: "contradictions",
41802
+ ruleId: "contradictions/conflict",
41803
+ line: firstEntry.line,
41804
+ message: `${category} conflict across ${conflictingFiles.length} files: ${summary}`,
41805
+ suggestion: `Align on one ${category} across all context files`,
41806
+ detail
41807
+ });
41739
41808
  }
41740
41809
  }
41741
41810
  return issues;
@@ -42413,6 +42482,7 @@ async function checkMcpCommands(config2, projectRoot) {
42413
42482
  }
42414
42483
  if (server2.args) {
42415
42484
  for (const arg of server2.args) {
42485
+ if (URL_PREFIX.test(arg)) continue;
42416
42486
  if (LOCAL_PATH_PATTERN.test(arg) || FILE_PATH_PATTERN.test(arg)) {
42417
42487
  const resolved = path8.resolve(projectRoot, arg);
42418
42488
  if (!fileExistsSafe(resolved)) {
@@ -42438,12 +42508,13 @@ function fileExistsSafe(filePath) {
42438
42508
  return false;
42439
42509
  }
42440
42510
  }
42441
- var LOCAL_PATH_PATTERN, FILE_PATH_PATTERN;
42511
+ var LOCAL_PATH_PATTERN, FILE_PATH_PATTERN, URL_PREFIX;
42442
42512
  var init_commands2 = __esm({
42443
42513
  "src/core/checks/mcp/commands.ts"() {
42444
42514
  "use strict";
42445
42515
  LOCAL_PATH_PATTERN = /^\.\.?\//;
42446
42516
  FILE_PATH_PATTERN = /^[^-].*\/.*\.\w+$/;
42517
+ URL_PREFIX = /^(https?|file|s3|gs|ssh|git):\/\//i;
42447
42518
  }
42448
42519
  });
42449
42520
 
@@ -43766,7 +43837,7 @@ import { readFileSync as readFileSync4 } from "node:fs";
43766
43837
  import { resolve as resolve8, dirname as dirname3 } from "node:path";
43767
43838
  import { fileURLToPath } from "node:url";
43768
43839
  function loadVersion() {
43769
- if (true) return "0.9.6";
43840
+ if (true) return "0.9.7";
43770
43841
  const __dir = dirname3(fileURLToPath(import.meta.url));
43771
43842
  const pkgPath = resolve8(__dir, "../package.json");
43772
43843
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
@@ -44597,6 +44668,7 @@ function applyFixes(result, options = {}) {
44597
44668
  }
44598
44669
  }
44599
44670
  const fixesByFile = /* @__PURE__ */ new Map();
44671
+ const dedupeKeys = /* @__PURE__ */ new Map();
44600
44672
  const skippedSymlinks = /* @__PURE__ */ new Set();
44601
44673
  for (const file2 of result.files) {
44602
44674
  for (const issue2 of file2.issues) {
@@ -44605,6 +44677,14 @@ function applyFixes(result, options = {}) {
44605
44677
  skippedSymlinks.add(file2.path);
44606
44678
  continue;
44607
44679
  }
44680
+ const key = `${issue2.fix.line}:${issue2.fix.oldText}:${issue2.fix.newText}`;
44681
+ let seenInFile = dedupeKeys.get(issue2.fix.file);
44682
+ if (!seenInFile) {
44683
+ seenInFile = /* @__PURE__ */ new Set();
44684
+ dedupeKeys.set(issue2.fix.file, seenInFile);
44685
+ }
44686
+ if (seenInFile.has(key)) continue;
44687
+ seenInFile.add(key);
44608
44688
  const existing = fixesByFile.get(issue2.fix.file) || [];
44609
44689
  existing.push(issue2.fix);
44610
44690
  fixesByFile.set(issue2.fix.file, existing);
@@ -51751,6 +51831,76 @@ var init_reporter = __esm({
51751
51831
  // src/core/config.ts
51752
51832
  import * as fs8 from "node:fs";
51753
51833
  import * as path10 from "node:path";
51834
+ function formatJsonError(content, err) {
51835
+ const msg = err instanceof Error ? err.message : String(err);
51836
+ const posMatch = msg.match(/position (\d+)/);
51837
+ if (posMatch) {
51838
+ return `${msg} (${posToLineCol(content, Number(posMatch[1]))})`;
51839
+ }
51840
+ if (/line \d+/.test(msg)) return msg;
51841
+ const pos = findFirstErrorPos(content, msg);
51842
+ if (pos !== null) {
51843
+ return `${msg} (${posToLineCol(content, pos)})`;
51844
+ }
51845
+ return msg;
51846
+ }
51847
+ function posToLineCol(content, pos) {
51848
+ let line = 1;
51849
+ let col = 1;
51850
+ for (let i2 = 0; i2 < pos && i2 < content.length; i2++) {
51851
+ if (content[i2] === "\n") {
51852
+ line++;
51853
+ col = 1;
51854
+ } else {
51855
+ col++;
51856
+ }
51857
+ }
51858
+ return `line ${line}, column ${col}`;
51859
+ }
51860
+ function findFirstErrorPos(content, errMsg) {
51861
+ const snippetMatch = errMsg.match(/\.\.\."([^"]+(?:"[^"]*)*?)"\s+is not valid JSON/);
51862
+ if (snippetMatch) {
51863
+ const snippet = snippetMatch[1];
51864
+ const needle = snippet.split(/\s/).find((s) => s.length > 1);
51865
+ if (needle) {
51866
+ const idx = content.indexOf(needle);
51867
+ if (idx !== -1) return idx;
51868
+ }
51869
+ }
51870
+ const tokenMatch = errMsg.match(/Unexpected token\s+['"]?([^'",]+?)['"]?,/);
51871
+ if (tokenMatch) {
51872
+ const tok = tokenMatch[1];
51873
+ const idx = content.indexOf(tok);
51874
+ if (idx !== -1) return idx;
51875
+ }
51876
+ return null;
51877
+ }
51878
+ function suggestKey(unknown2) {
51879
+ let best = null;
51880
+ let bestDist = Infinity;
51881
+ for (const known of KNOWN_CONFIG_KEYS) {
51882
+ const d = levenshtein2(unknown2, known);
51883
+ if (d < bestDist) {
51884
+ bestDist = d;
51885
+ best = known;
51886
+ }
51887
+ }
51888
+ if (best && bestDist <= Math.max(2, Math.floor(unknown2.length / 3))) {
51889
+ return best;
51890
+ }
51891
+ return null;
51892
+ }
51893
+ function warnUnknownKeys(config2, source) {
51894
+ if (!config2 || typeof config2 !== "object" || Array.isArray(config2)) return;
51895
+ const keys = Object.keys(config2);
51896
+ const known = new Set(KNOWN_CONFIG_KEYS);
51897
+ for (const k3 of keys) {
51898
+ if (known.has(k3)) continue;
51899
+ const hint = suggestKey(k3);
51900
+ const suggestion = hint ? ` \u2014 did you mean "${hint}"?` : "";
51901
+ console.error(`Warning: unknown config key "${k3}" in ${source}${suggestion}`);
51902
+ }
51903
+ }
51754
51904
  function loadConfig(projectRoot) {
51755
51905
  for (const filename of CONFIG_FILENAMES) {
51756
51906
  const filePath = path10.join(projectRoot, filename);
@@ -51760,19 +51910,39 @@ function loadConfig(projectRoot) {
51760
51910
  } catch {
51761
51911
  continue;
51762
51912
  }
51913
+ let parsed;
51763
51914
  try {
51764
- return JSON.parse(content);
51915
+ parsed = JSON.parse(content);
51765
51916
  } catch (err) {
51766
- const msg = err instanceof Error ? err.message : String(err);
51767
- throw new Error(`Invalid JSON in ${filePath}: ${msg}`, { cause: err });
51917
+ throw new Error(`Invalid JSON in ${filePath}: ${formatJsonError(content, err)}`, {
51918
+ cause: err
51919
+ });
51768
51920
  }
51921
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
51922
+ throw new Error(
51923
+ `Invalid config in ${filePath}: expected a JSON object at the root, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`
51924
+ );
51925
+ }
51926
+ warnUnknownKeys(parsed, filePath);
51927
+ return parsed;
51769
51928
  }
51770
51929
  return null;
51771
51930
  }
51772
- var CONFIG_FILENAMES;
51931
+ var import_fast_levenshtein2, levenshtein2, KNOWN_CONFIG_KEYS, CONFIG_FILENAMES;
51773
51932
  var init_config2 = __esm({
51774
51933
  "src/core/config.ts"() {
51775
51934
  "use strict";
51935
+ import_fast_levenshtein2 = __toESM(require_levenshtein(), 1);
51936
+ levenshtein2 = import_fast_levenshtein2.default.get;
51937
+ KNOWN_CONFIG_KEYS = [
51938
+ "checks",
51939
+ "ignore",
51940
+ "strict",
51941
+ "tokenThresholds",
51942
+ "contextFiles",
51943
+ "mcp",
51944
+ "mcpGlobal"
51945
+ ];
51776
51946
  CONFIG_FILENAMES = [".ctxlintrc", ".ctxlintrc.json"];
51777
51947
  }
51778
51948
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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"