weavatrix 0.1.4 → 0.2.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.
Files changed (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. package/src/security/typosquat.js +1 -1
@@ -0,0 +1,201 @@
1
+ // Repository-semantic path classification. Unlike `.weavatrixignore`, this layer never removes a
2
+ // file from the graph: it labels non-product surfaces so health/clone/test-reachability tools can
3
+ // suppress noise while still explaining the exact rule that matched.
4
+ import { openSync, closeSync, readSync, readFileSync, statSync } from "node:fs";
5
+ import { createRepoBoundary } from "./repo-path.js";
6
+
7
+ export const PATH_CLASS_NAMES = Object.freeze(["test", "e2e", "generated", "mock", "story", "docs", "benchmark", "temp"]);
8
+ const PATH_CLASS_SET = new Set(PATH_CLASS_NAMES);
9
+ const MAX_CONFIG_BYTES = 64 * 1024;
10
+ const MAX_RULES_PER_CLASS = 64;
11
+ const MAX_RULE_LENGTH = 256;
12
+ const HEADER_BYTES = 8192;
13
+
14
+ const normalizePath = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
15
+ const escapeRegex = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
16
+
17
+ function compileGlob(input) {
18
+ let pattern = String(input || "").trim().replace(/\\/g, "/");
19
+ if (!pattern || pattern.length > MAX_RULE_LENGTH || pattern.includes("\0") || pattern.split("/").includes("..")) return null;
20
+ const anchored = pattern.startsWith("/");
21
+ const directory = pattern.endsWith("/");
22
+ pattern = pattern.replace(/^\/+|\/+$/g, "");
23
+ if (!pattern) return null;
24
+ let body = "";
25
+ for (let index = 0; index < pattern.length; index += 1) {
26
+ const char = pattern[index];
27
+ if (char === "*" && pattern[index + 1] === "*") {
28
+ const slash = pattern[index + 2] === "/";
29
+ body += slash ? "(?:.*/)?" : ".*";
30
+ index += slash ? 2 : 1;
31
+ } else if (char === "*") body += "[^/]*";
32
+ else if (char === "?") body += "[^/]";
33
+ else body += escapeRegex(char);
34
+ }
35
+ const prefix = anchored ? "^" : "(?:^|/)";
36
+ const suffix = directory ? "(?:/.*)?$" : "$";
37
+ return new RegExp(`${prefix}${body}${suffix}`, "i");
38
+ }
39
+
40
+ const DEFAULT_RULES = [
41
+ {
42
+ category: "e2e",
43
+ pattern: "test-e2e, e2e, Cypress, Playwright or acceptance/integration test roots",
44
+ regex: /(^|\/)(?:test-e2e|e2e|cypress|playwright|tests?[-_](?:e2e|integration|acceptance)|(?:e2e|integration|acceptance)[-_]tests?)(\/|$)|\.(?:e2e|cy)\.[^.\/]+$/i,
45
+ },
46
+ {
47
+ category: "test",
48
+ pattern: "test/spec roots and conventional test filenames",
49
+ regex: /(^|\/)(?:__tests?__|tests?|spec)(\/|$)|\.(?:test|itest|spec)\.[^.\/]+$|_test\.go$|(^|\/)test_[^/]*\.py$/i,
50
+ },
51
+ {
52
+ category: "generated",
53
+ pattern: "generated/OpenAPI output path",
54
+ regex: /(^|\/)(?:generated|gen|openapi[-_]?generated|generated[-_]?client|api[-_]?client[-_]?generated)(\/|$)/i,
55
+ },
56
+ {
57
+ category: "mock",
58
+ pattern: "mock/fixture path or mockData filename",
59
+ regex: /(^|\/)(?:__mocks__|mocks?|fixtures?|__fixtures__)(\/|$)|(^|\/)(?:mock[-_.]?data|test[-_.]?data|fake[-_.]?data)\.[^.\/]+$/i,
60
+ },
61
+ {
62
+ category: "story",
63
+ pattern: "Storybook story",
64
+ regex: /(^|\/)\.storybook(\/|$)|\.stories?\.[^.\/]+$/i,
65
+ },
66
+ {
67
+ category: "docs",
68
+ pattern: "documentation path or prose file",
69
+ regex: /(^|\/)(?:docs?|documentation)(\/|$)|\.(?:md|markdown|mdx|rst|adoc)$/i,
70
+ },
71
+ {
72
+ category: "benchmark",
73
+ pattern: "benchmark/benchmarks root",
74
+ regex: /(^|\/)benchmarks?(\/|$)/i,
75
+ },
76
+ {
77
+ category: "temp",
78
+ pattern: "__temp working/generated root",
79
+ regex: /(^|\/)__temp(\/|$)/i,
80
+ },
81
+ ];
82
+
83
+ const GENERATED_HEADER_RE = /(?:@generated\b|auto[- ]generated\b|generated\s+(?:code|file|client).*do not edit|do not edit.*generated|openapi generator)/i;
84
+
85
+ function boundedHeader(repoRoot, path) {
86
+ if (!repoRoot || !path) return "";
87
+ const resolved = createRepoBoundary(repoRoot).resolve(path);
88
+ if (!resolved.ok) return "";
89
+ let fd;
90
+ try {
91
+ fd = openSync(resolved.path, "r");
92
+ const buf = Buffer.alloc(HEADER_BYTES);
93
+ const count = readSync(fd, buf, 0, buf.length, 0);
94
+ return buf.subarray(0, count).toString("utf8");
95
+ } catch { return ""; }
96
+ finally { if (fd != null) try { closeSync(fd); } catch { /* already closed */ } }
97
+ }
98
+
99
+ export function loadPathClassificationConfig(repoRoot) {
100
+ if (!repoRoot) return { classify: {}, exclude: [], loaded: false };
101
+ const resolved = createRepoBoundary(repoRoot).resolve(".weavatrix.json");
102
+ if (!resolved.ok) return { classify: {}, exclude: [], loaded: false };
103
+ try {
104
+ if (statSync(resolved.path).size > MAX_CONFIG_BYTES) return { classify: {}, exclude: [], loaded: false, error: "config-too-large" };
105
+ const raw = JSON.parse(readFileSync(resolved.path, "utf8"));
106
+ const classify = {};
107
+ for (const category of PATH_CLASS_NAMES) {
108
+ const value = raw?.classify?.[category];
109
+ const patterns = (Array.isArray(value) ? value : typeof value === "string" ? [value] : [])
110
+ .slice(0, MAX_RULES_PER_CLASS)
111
+ .map((pattern) => String(pattern));
112
+ if (patterns.length) classify[category] = patterns;
113
+ }
114
+ // Explicit product opt-in removes only the default benchmark/temp classification. It does not
115
+ // override configured generated/test classes or a deliberate top-level exclude rule.
116
+ const productValue = raw?.classify?.product;
117
+ const productPatterns = (Array.isArray(productValue) ? productValue : typeof productValue === "string" ? [productValue] : [])
118
+ .slice(0, MAX_RULES_PER_CLASS)
119
+ .map((pattern) => String(pattern));
120
+ if (productPatterns.length) classify.product = productPatterns;
121
+ const exclude = (Array.isArray(raw?.exclude) ? raw.exclude : typeof raw?.exclude === "string" ? [raw.exclude] : [])
122
+ .slice(0, MAX_RULES_PER_CLASS)
123
+ .map((pattern) => String(pattern));
124
+ return { classify, exclude, loaded: true };
125
+ } catch { return { classify: {}, exclude: [], loaded: false, error: "invalid-config" }; }
126
+ }
127
+
128
+ function configuredRules(config) {
129
+ const rules = [];
130
+ for (const category of PATH_CLASS_NAMES) {
131
+ for (const pattern of config.classify?.[category] || []) {
132
+ const regex = compileGlob(pattern);
133
+ if (regex) rules.push({ category, pattern, regex, source: "config" });
134
+ }
135
+ }
136
+ const excludes = [];
137
+ for (const pattern of config.exclude || []) {
138
+ const regex = compileGlob(pattern);
139
+ if (regex) excludes.push({ pattern, regex, source: "config" });
140
+ }
141
+ const productRules = [];
142
+ for (const pattern of config.classify?.product || []) {
143
+ const regex = compileGlob(pattern);
144
+ if (regex) productRules.push({ category: "product", pattern, regex, source: "config" });
145
+ }
146
+ return { rules, excludes, productRules };
147
+ }
148
+
149
+ export function createPathClassifier(repoRoot) {
150
+ const config = loadPathClassificationConfig(repoRoot);
151
+ const compiled = configuredRules(config);
152
+ return {
153
+ config,
154
+ explain(path, options = {}) {
155
+ const normalized = normalizePath(path);
156
+ const matches = [];
157
+ const classes = new Set();
158
+ const productMatches = compiled.productRules
159
+ .filter((rule) => rule.regex.test(normalized))
160
+ .map((rule) => ({ category: "product", source: rule.source, pattern: rule.pattern }));
161
+ matches.push(...productMatches);
162
+ const productOverride = productMatches.length > 0;
163
+ const add = (rule) => {
164
+ classes.add(rule.category);
165
+ if (rule.category === "e2e") classes.add("test");
166
+ matches.push({ category: rule.category, source: rule.source || "default", pattern: rule.pattern });
167
+ };
168
+ for (const rule of compiled.rules) if (rule.regex.test(normalized)) add(rule);
169
+ for (const rule of DEFAULT_RULES) {
170
+ if (productOverride && (rule.category === "benchmark" || rule.category === "temp")) continue;
171
+ if (rule.regex.test(normalized)) add({ ...rule, source: "default" });
172
+ }
173
+ const header = options.content == null ? boundedHeader(repoRoot, normalized) : String(options.content).slice(0, HEADER_BYTES);
174
+ if (GENERATED_HEADER_RE.test(header) && !classes.has("generated")) {
175
+ add({ category: "generated", source: "default", pattern: "generated-file header" });
176
+ }
177
+ const excludeMatches = compiled.excludes
178
+ .filter((rule) => rule.regex.test(normalized))
179
+ .map((rule) => ({ category: "exclude", source: rule.source, pattern: rule.pattern }));
180
+ matches.push(...excludeMatches);
181
+ const orderedClasses = PATH_CLASS_NAMES.filter((category) => classes.has(category));
182
+ return {
183
+ path: normalized,
184
+ classes: orderedClasses,
185
+ excluded: excludeMatches.length > 0,
186
+ productOverride,
187
+ matchedRule: matches[0] || null,
188
+ matchedRules: matches,
189
+ };
190
+ },
191
+ };
192
+ }
193
+
194
+ export function explainPathClassification(repoRoot, path, options = {}) {
195
+ return createPathClassifier(repoRoot).explain(path, options);
196
+ }
197
+
198
+ export function hasPathClass(explanation, ...categories) {
199
+ const present = new Set(explanation?.classes || []);
200
+ return categories.some((category) => PATH_CLASS_SET.has(category) && present.has(category));
201
+ }
@@ -0,0 +1,69 @@
1
+ // Repository-local exclusions shared by graph building, audits and clone scanning.
2
+ // `.weavatrixignore` follows the useful Gitignore subset: comments, *, **, ?, root-anchored
3
+ // patterns, directory suffixes and ordered ! re-includes. It never expands paths or reads outside root.
4
+ import { readFileSync } from "node:fs";
5
+ import { isAbsolute, relative } from "node:path";
6
+ import { createRepoBoundary } from "./repo-path.js";
7
+
8
+ function escapeRegex(value) {
9
+ return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
10
+ }
11
+
12
+ function globRegex(pattern) {
13
+ let out = "";
14
+ for (let index = 0; index < pattern.length; index += 1) {
15
+ const char = pattern[index];
16
+ if (char === "*" && pattern[index + 1] === "*") {
17
+ const slash = pattern[index + 2] === "/";
18
+ out += slash ? "(?:.*/)?" : ".*";
19
+ index += slash ? 2 : 1;
20
+ } else if (char === "*") out += "[^/]*";
21
+ else if (char === "?") out += "[^/]";
22
+ else out += escapeRegex(char);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ export function parseWeavatrixIgnore(text) {
28
+ const rules = [];
29
+ for (const rawLine of String(text || "").split(/\r?\n/)) {
30
+ let line = rawLine.trim();
31
+ if (!line || line.startsWith("#")) continue;
32
+ const negated = line.startsWith("!");
33
+ if (negated) line = line.slice(1);
34
+ line = line.replace(/\\/g, "/");
35
+ const anchored = line.startsWith("/");
36
+ const directory = line.endsWith("/");
37
+ line = line.replace(/^\/+|\/+$/g, "");
38
+ if (!line || line.split("/").some((part) => part === "..")) continue;
39
+ const prefix = anchored ? "^" : "(?:^|/)";
40
+ const suffix = directory ? "(?:/.*)?$" : "$";
41
+ rules.push({ negated, regex: new RegExp(`${prefix}${globRegex(line)}${suffix}`) });
42
+ }
43
+ return rules;
44
+ }
45
+
46
+ export function loadWeavatrixIgnore(repoRoot) {
47
+ try {
48
+ const resolved = createRepoBoundary(repoRoot).resolve(".weavatrixignore");
49
+ if (!resolved.ok) return [];
50
+ return parseWeavatrixIgnore(readFileSync(resolved.path, "utf8"));
51
+ }
52
+ catch { return []; }
53
+ }
54
+
55
+ export function isWeavatrixIgnored(path, rules) {
56
+ const normalized = String(path || "").replace(/\\/g, "/").replace(/^\.\//, "");
57
+ let ignored = false;
58
+ for (const rule of rules || []) if (rule.regex.test(normalized)) ignored = !rule.negated;
59
+ return ignored;
60
+ }
61
+
62
+ export function filterWeavatrixIgnored(repoRoot, files) {
63
+ const rules = loadWeavatrixIgnore(repoRoot);
64
+ if (!rules.length) return files;
65
+ return files.filter((file) => {
66
+ const path = isAbsolute(file) ? relative(repoRoot, file) : file;
67
+ return !isWeavatrixIgnored(path, rules);
68
+ });
69
+ }
@@ -28,7 +28,7 @@ export const TOP_PACKAGES = new Set([
28
28
  // Legit close pairs to NOT flag (real distinct packages that happen to sit distance-1/2 apart).
29
29
  const KNOWN_LEGIT = new Set([
30
30
  "cross-spawn", "react-dom", "react-router", "bcryptjs", "mysql2", "colors", "underscore", "querystring",
31
- "markdown-it", "babel-loader", "css-loader", "style-loader",
31
+ "markdown-it", "babel-loader", "css-loader", "style-loader", "query-string",
32
32
  ]);
33
33
 
34
34
  const norm = (name) => String(name || "").toLowerCase().replace(/^@[^/]+\//, ""); // drop scope for the compare