weavatrix 0.2.12 → 0.2.14

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 (132) hide show
  1. package/README.md +90 -21
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.14.md +93 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +108 -43
  6. package/src/analysis/allowed-test-runner.js +20 -9
  7. package/src/analysis/architecture/contract-graph.js +119 -0
  8. package/src/analysis/architecture/contract-schema.js +110 -0
  9. package/src/analysis/architecture/contract-storage.js +35 -0
  10. package/src/analysis/architecture/contract-verification.js +168 -0
  11. package/src/analysis/architecture-contract.js +16 -343
  12. package/src/analysis/change-classification/diff-parser.js +103 -0
  13. package/src/analysis/change-classification/options.js +40 -0
  14. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  15. package/src/analysis/change-classification.js +120 -519
  16. package/src/analysis/dead-code-review/policy.js +23 -0
  17. package/src/analysis/dead-code-review.js +9 -19
  18. package/src/analysis/dep-check.js +10 -57
  19. package/src/analysis/dep-rules.js +10 -303
  20. package/src/analysis/dependency/scoped-dependencies.js +40 -0
  21. package/src/analysis/endpoints/common.js +62 -0
  22. package/src/analysis/endpoints/extract.js +107 -0
  23. package/src/analysis/endpoints/inventory.js +84 -0
  24. package/src/analysis/endpoints/mounts.js +129 -0
  25. package/src/analysis/endpoints-java.js +80 -3
  26. package/src/analysis/endpoints.js +3 -448
  27. package/src/analysis/git-history/analytics.js +177 -0
  28. package/src/analysis/git-history/collector.js +109 -0
  29. package/src/analysis/git-history/options.js +68 -0
  30. package/src/analysis/git-history.js +128 -577
  31. package/src/analysis/health-capabilities.js +82 -0
  32. package/src/analysis/http-contracts/analysis.js +169 -0
  33. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  34. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  35. package/src/analysis/http-contracts/graph-context.js +143 -0
  36. package/src/analysis/http-contracts/matching.js +61 -0
  37. package/src/analysis/http-contracts/shared.js +86 -0
  38. package/src/analysis/http-contracts.js +6 -822
  39. package/src/analysis/internal-audit/dependency-health.js +111 -0
  40. package/src/analysis/internal-audit/python-manifests.js +65 -0
  41. package/src/analysis/internal-audit/repo-files.js +55 -0
  42. package/src/analysis/internal-audit/supply-chain.js +132 -0
  43. package/src/analysis/internal-audit.collect.js +5 -106
  44. package/src/analysis/internal-audit.run.js +113 -200
  45. package/src/analysis/jvm-dependency-evidence.js +69 -0
  46. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  47. package/src/analysis/source-correctness.js +225 -0
  48. package/src/analysis/structure/dependency-graph.js +156 -0
  49. package/src/analysis/structure/findings.js +142 -0
  50. package/src/graph/builder/go-receiver-resolution.js +112 -0
  51. package/src/graph/builder/js/queries.js +48 -0
  52. package/src/graph/builder/lang-go.js +89 -4
  53. package/src/graph/builder/lang-js.js +4 -44
  54. package/src/graph/builder/pass2-resolution.js +68 -0
  55. package/src/graph/freshness-probe.js +2 -1
  56. package/src/graph/incremental-refresh.js +3 -2
  57. package/src/graph/internal-builder.build.js +22 -183
  58. package/src/graph/internal-builder.pass2.js +161 -0
  59. package/src/graph/internal-builder.resolvers.js +2 -105
  60. package/src/graph/resolvers/rust.js +117 -0
  61. package/src/mcp/actions/advisories.mjs +18 -0
  62. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  63. package/src/mcp/actions/graph-sync.mjs +195 -0
  64. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  65. package/src/mcp/architecture-bootstrap.mjs +168 -0
  66. package/src/mcp/architecture-starter.mjs +234 -0
  67. package/src/mcp/catalog.mjs +22 -6
  68. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  69. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  70. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  71. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  72. package/src/mcp/graph/context-core.mjs +111 -0
  73. package/src/mcp/graph/context-seeds.mjs +208 -0
  74. package/src/mcp/graph/context-state.mjs +86 -0
  75. package/src/mcp/graph/tools-core.mjs +143 -0
  76. package/src/mcp/graph/tools-query.mjs +223 -0
  77. package/src/mcp/graph-context.mjs +4 -496
  78. package/src/mcp/health/audit-format.mjs +172 -0
  79. package/src/mcp/health/audit.mjs +171 -0
  80. package/src/mcp/health/dead-code.mjs +110 -0
  81. package/src/mcp/health/duplicates.mjs +83 -0
  82. package/src/mcp/health/endpoints.mjs +42 -0
  83. package/src/mcp/health/structure.mjs +219 -0
  84. package/src/mcp/runtime-version.mjs +32 -0
  85. package/src/mcp/server/auto-refresh.mjs +66 -0
  86. package/src/mcp/server/runtime-config.mjs +43 -0
  87. package/src/mcp/server/shutdown.mjs +40 -0
  88. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  89. package/src/mcp/sync/evidence-common.mjs +88 -0
  90. package/src/mcp/sync/evidence-duplicates.mjs +100 -0
  91. package/src/mcp/sync/evidence-health.mjs +56 -0
  92. package/src/mcp/sync/evidence-packages.mjs +95 -0
  93. package/src/mcp/sync/payload-common.mjs +97 -0
  94. package/src/mcp/sync/payload-v2.mjs +53 -0
  95. package/src/mcp/sync/payload-v3.mjs +79 -0
  96. package/src/mcp/sync-evidence.mjs +7 -402
  97. package/src/mcp/sync-payload.mjs +10 -244
  98. package/src/mcp/tools-actions.mjs +18 -414
  99. package/src/mcp/tools-architecture.mjs +18 -70
  100. package/src/mcp/tools-context.mjs +56 -15
  101. package/src/mcp/tools-endpoints.mjs +4 -1
  102. package/src/mcp/tools-graph.mjs +17 -330
  103. package/src/mcp/tools-health.mjs +24 -705
  104. package/src/mcp/tools-verified-change.mjs +12 -4
  105. package/src/mcp-server.mjs +49 -146
  106. package/src/precision/lsp-client/constants.js +12 -0
  107. package/src/precision/lsp-client/environment.js +20 -0
  108. package/src/precision/lsp-client/errors.js +15 -0
  109. package/src/precision/lsp-client/lifecycle.js +127 -0
  110. package/src/precision/lsp-client/message-parser.js +81 -0
  111. package/src/precision/lsp-client/protocol.js +212 -0
  112. package/src/precision/lsp-client/registry.js +58 -0
  113. package/src/precision/lsp-client/repo-uri.js +60 -0
  114. package/src/precision/lsp-client/stdio-client.js +151 -0
  115. package/src/precision/lsp-client.js +10 -682
  116. package/src/precision/lsp-overlay/build.js +238 -0
  117. package/src/precision/lsp-overlay/contract.js +61 -0
  118. package/src/precision/lsp-overlay/reference-results.js +108 -0
  119. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  120. package/src/precision/lsp-overlay/source-session.js +134 -0
  121. package/src/precision/lsp-overlay/store.js +150 -0
  122. package/src/precision/lsp-overlay/target-index.js +147 -0
  123. package/src/precision/lsp-overlay/target-query.js +55 -0
  124. package/src/precision/lsp-overlay.js +11 -868
  125. package/src/precision/typescript-lsp-provider.js +12 -682
  126. package/src/precision/typescript-provider/client.js +69 -0
  127. package/src/precision/typescript-provider/discovery.js +124 -0
  128. package/src/precision/typescript-provider/project-host.js +249 -0
  129. package/src/precision/typescript-provider/project-safety.js +161 -0
  130. package/src/precision/typescript-provider/reference-usage.js +53 -0
  131. package/src/version.js +7 -0
  132. package/docs/releases/v0.2.12.md +0 -45
@@ -0,0 +1,23 @@
1
+ import {hasPathClass} from '../../path-classification.js'
2
+
3
+ export const DEAD_CODE_CONFIDENCE_RANK = Object.freeze({high: 0, medium: 1, low: 2})
4
+ export const DYNAMIC_CODE_RE = /(?:\brequire\s*\(\s*(?!["'])|\bcreateRequire\s*\(|\b__import__\s*\(|\bimportlib\.|(?:^|[^\w.$])(?:eval|exec)\s*\()/m
5
+ const JS_DYNAMIC_IMPORT_RE = /\bimport\s*\(/
6
+ const JS_LIKE_PATH_RE = /\.(?:[cm]?js|jsx|[cm]?ts|tsx)$/i
7
+ export const REFLECTION_CODE_RE = /(?:\b(?:Class\.forName|get(?:Declared)?Method|getattr|setattr|hasattr|Method\.Invoke|GetMethod|GetProcAddress|dlsym)\s*\(|\b(?:globals|locals)\s*\(\s*\)\s*\[|\breflect\.[A-Za-z_$][\w$]*\s*\()/i
8
+ export const normalizedReviewPath = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '')
9
+
10
+ export function hasDynamicCode(source, file = '') {
11
+ const text = String(source || '')
12
+ return DYNAMIC_CODE_RE.test(text) || (JS_LIKE_PATH_RE.test(String(file)) && JS_DYNAMIC_IMPORT_RE.test(text))
13
+ }
14
+
15
+ const NON_PRODUCT_CLASSES = Object.freeze(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
16
+
17
+ export function deadCodePathAllowed(info, {includeTests, includeClassified}) {
18
+ if (!includeTests && hasPathClass(info, 'test', 'e2e')) return {ok: false, bucket: 'tests'}
19
+ if (!includeClassified && (info?.excluded || hasPathClass(info, ...NON_PRODUCT_CLASSES))) {
20
+ return {ok: false, bucket: 'classified'}
21
+ }
22
+ return {ok: true}
23
+ }
@@ -3,14 +3,12 @@
3
3
  // never deletion instructions: framework entry, dynamic loading, reflection and public API surfaces
4
4
  // lower confidence and remain explicit in the returned record.
5
5
  import { computeDead, isFrameworkEntryFile } from "./dead-check.js";
6
- import { createPathClassifier, hasPathClass } from "../path-classification.js";
7
-
8
- const CONFIDENCE_RANK = Object.freeze({ high: 0, medium: 1, low: 2 });
9
- const CLASSIFIED_NON_PRODUCT = Object.freeze(["generated", "mock", "story", "docs", "benchmark", "temp"]);
10
- const DYNAMIC_RE = /(?:\bimport\s*\(|\brequire\s*\(\s*(?!["'])|\bcreateRequire\s*\(|\b__import__\s*\(|\bimportlib\.|(?:^|[^\w.$])(?:eval|exec)\s*\()/m;
11
- const REFLECTION_RE = /(?:\b(?:Class\.forName|get(?:Declared)?Method|getattr|setattr|hasattr|Method\.Invoke|GetMethod|GetProcAddress|dlsym)\s*\(|\b(?:globals|locals)\s*\(\s*\)\s*\[|\breflect\.[A-Za-z_$][\w$]*\s*\()/i;
12
-
13
- const normalizedPath = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
6
+ import { createPathClassifier } from "../path-classification.js";
7
+ import {
8
+ DEAD_CODE_CONFIDENCE_RANK as CONFIDENCE_RANK, hasDynamicCode,
9
+ REFLECTION_CODE_RE as REFLECTION_RE, deadCodePathAllowed as pathAllowed,
10
+ normalizedReviewPath as normalizedPath,
11
+ } from './dead-code-review/policy.js'
14
12
  const lineOf = (node) => {
15
13
  const match = /@(\d+)$/.exec(String(node?.id || "")) || /L(\d+)/.exec(String(node?.source_location || ""));
16
14
  return match ? Number(match[1]) : 0;
@@ -32,14 +30,6 @@ function isPublicSurface(node) {
32
30
  return node?.exported === true || visibility === "public" || visibility === "protected";
33
31
  }
34
32
 
35
- function pathAllowed(info, { includeTests, includeClassified }) {
36
- if (!includeTests && hasPathClass(info, "test", "e2e")) return { ok: false, bucket: "tests" };
37
- if (!includeClassified && (info?.excluded || hasPathClass(info, ...CLASSIFIED_NON_PRODUCT))) {
38
- return { ok: false, bucket: "classified" };
39
- }
40
- return { ok: true };
41
- }
42
-
43
33
  function symbolCandidate(item, node, context) {
44
34
  const file = normalizedPath(item.file);
45
35
  const source = String(context.sources.get(file) || "");
@@ -48,7 +38,7 @@ function symbolCandidate(item, node, context) {
48
38
  const testOnly = item.testOnly === true;
49
39
  const externalEntry = context.entrySet.has(file) || isFrameworkEntryFile(file);
50
40
  const framework = context.frameworkByFile.get(file) || null;
51
- const dynamicFile = context.dynamicTargets.has(file) || DYNAMIC_RE.test(source);
41
+ const dynamicFile = context.dynamicTargets.has(file) || hasDynamicCode(source, file);
52
42
  const reflectionFile = REFLECTION_RE.test(source);
53
43
  const kind = kindOf(node);
54
44
  const exactNoReference = context.exactNoReferenceIds.has(String(item.id));
@@ -143,7 +133,7 @@ function fileCandidate(item, symbols, context) {
143
133
  const source = String(context.sources.get(file) || "");
144
134
  const pathInfo = context.classify(file, source);
145
135
  const publicSymbols = symbols.filter((symbol) => symbol.publicApi);
146
- const dynamicFile = context.dynamicTargets.has(file) || DYNAMIC_RE.test(source);
136
+ const dynamicFile = context.dynamicTargets.has(file) || hasDynamicCode(source, file);
147
137
  const reflectionFile = REFLECTION_RE.test(source);
148
138
  // Whole-file liveness always remains at most medium: external launchers/manifests can exist outside
149
139
  // the indexed import graph even when every internal symbol signal is otherwise strong.
@@ -219,7 +209,7 @@ export function computeDeadCodeReview(graph, sources, options = {}) {
219
209
  return classificationCache.get(file);
220
210
  };
221
211
  const repoSignals = {
222
- dynamicLoading: (graph.externalImports || []).some((entry) => entry?.dynamic) || [...sources.values()].some((text) => DYNAMIC_RE.test(String(text || ""))),
212
+ dynamicLoading: (graph.externalImports || []).some((entry) => entry?.dynamic) || [...sources].some(([file, text]) => hasDynamicCode(text, file)),
223
213
  reflection: [...sources.values()].some((text) => REFLECTION_RE.test(String(text || ""))),
224
214
  };
225
215
  const includeTests = options.includeTests === true;
@@ -7,47 +7,37 @@
7
7
  // script/config-text mention scanning + a config-ecosystem prefix rule, and we NEVER say "safe to
8
8
  // auto-remove", only "review".
9
9
  import { makeFinding } from "./findings.js";
10
+ import {createScopedDepFindings} from './dependency/scoped-dependencies.js'
10
11
 
11
12
  // Packages referenced by config CONVENTION, not imports (eslint extends "airbnb" → eslint-config-airbnb).
12
13
  // Flagged only at low confidence when nothing mentions them anywhere.
13
- const CONFIG_ECOSYSTEM_RE =
14
- /^(eslint-(config|plugin)-|@typescript-eslint\/|@eslint\/|prettier-plugin-|postcss-|autoprefixer$|tailwindcss$|babel-(plugin|preset)-|@babel\/(plugin|preset)-|stylelint-|@commitlint\/|commitlint-|remark-|rehype-|@semantic-release\/|karma-|grunt-|gulp-)/;
14
+ const CONFIG_ECOSYSTEM_RE = /^(eslint-(config|plugin)-|@typescript-eslint\/|@eslint\/|prettier-plugin-|postcss-|autoprefixer$|tailwindcss$|babel-(plugin|preset)-|@babel\/(plugin|preset)-|stylelint-|@commitlint\/|commitlint-|remark-|rehype-|@semantic-release\/|karma-|grunt-|gulp-)/;
15
15
 
16
16
  // CLI name → package name, for script commands whose binary doesn't equal the package
17
17
  // (`tsc` comes from typescript, `depcruise` from dependency-cruiser, …).
18
18
  const BIN_PKG = {
19
- tsc: "typescript",
20
- depcruise: "dependency-cruiser",
21
- "vue-cli-service": "@vue/cli-service",
22
- ng: "@angular/cli",
23
- nest: "@nestjs/cli",
24
- sb: "storybook",
25
- "electron-rebuild": "@electron/rebuild",
26
- playwright: "@playwright/test",
19
+ tsc: "typescript", depcruise: "dependency-cruiser", "vue-cli-service": "@vue/cli-service",
20
+ ng: "@angular/cli", nest: "@nestjs/cli", sb: "storybook",
21
+ "electron-rebuild": "@electron/rebuild", playwright: "@playwright/test",
27
22
  };
28
23
 
29
24
  // Required peers consumed inside a framework/build tool rather than imported by application source.
30
25
  // These contracts are package-scope local and deliberately narrow; declaring the provider is required
31
26
  // for suppression, so an unrelated app still gets the normal unused-dependency finding.
32
27
  const FRAMEWORK_RUNTIME_PEERS = new Map([
33
- ["next", ["react-dom"]],
34
- ["vinext", ["@vitejs/plugin-react", "@vitejs/plugin-rsc", "react-server-dom-webpack", "vite"]],
35
- ["electron-vite", ["vite"]],
36
- ["@cloudflare/vite-plugin", ["vite", "wrangler"]],
28
+ ["next", ["react-dom"]], ["vinext", ["@vitejs/plugin-react", "@vitejs/plugin-rsc", "react-server-dom-webpack", "vite"]],
29
+ ["electron-vite", ["vite"]], ["@cloudflare/vite-plugin", ["vite", "wrangler"]],
37
30
  ]);
38
31
 
39
32
  // Style preprocessors are compiler inputs rather than JavaScript imports. Their presence is proven by
40
33
  // source extensions in the same package scope, so do not report them as unused merely because Vite,
41
34
  // webpack, etc. load them internally. Keep this list to exact compiler/package contracts.
42
35
  const IMPLICIT_STYLE_COMPILERS = new Map([
43
- ["sass", /\.(?:scss|sass)$/i],
44
- ["sass-embedded", /\.(?:scss|sass)$/i],
45
- ["less", /\.less$/i],
46
- ["stylus", /\.styl(?:us)?$/i],
36
+ ["sass", /\.(?:scss|sass)$/i], ["sass-embedded", /\.(?:scss|sass)$/i],
37
+ ["less", /\.less$/i], ["stylus", /\.styl(?:us)?$/i],
47
38
  ]);
48
39
 
49
40
  const isStylesheetSpecifier = (spec) => /\.(?:css|scss|sass|less|styl(?:us)?)(?:[?#].*)?$/i.test(String(spec || ""));
50
-
51
41
  const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
42
  // word-ish mention: the name not embedded inside a longer identifier/path segment
53
43
  const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\w.-])`).test(blob);
@@ -305,43 +295,6 @@ export function computeDepFindings({
305
295
  return { findings, usedPackages, declared: allDeclared };
306
296
  }
307
297
 
308
- const normScope = (root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
309
- const ownsFile = (scope, file) => !scope || file === scope || String(file || "").startsWith(`${scope}/`);
310
-
311
- // Judge every import against its nearest ancestor package.json. This is the dependency equivalent of
312
- // Node's package scope and prevents nested Next/Vite apps from inheriting the root manifest by accident.
313
- export function computeScopedDepFindings({
314
- externalImports = [], packageScopes = [], workspacePkgNames = new Set(), configTexts = new Map(),
315
- nonRuntimeRoots = [], sourceFiles = [],
316
- } = {}) {
317
- const scopes = packageScopes.length
318
- ? packageScopes.map((s) => ({ ...s, root: normScope(s.root) })).sort((a, b) => b.root.length - a.root.length)
319
- : [{ root: "", manifest: "package.json", pkg: {}, aliases: [] }];
320
- const importsByScope = new Map(scopes.map((s) => [s, []]));
321
- const sourceFilesByScope = new Map(scopes.map((s) => [s, []]));
322
- for (const e of externalImports) {
323
- const owner = scopes.find((s) => ownsFile(s.root, e.file)) || scopes[scopes.length - 1];
324
- importsByScope.get(owner).push(e);
325
- }
326
- for (const file of sourceFiles) {
327
- const owner = scopes.find((s) => ownsFile(s.root, file)) || scopes[scopes.length - 1];
328
- sourceFilesByScope.get(owner).push(file);
329
- }
330
- const configOwner = new Map();
331
- for (const [file] of configTexts) configOwner.set(file, scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1]);
332
- const findings = [], usedPackages = new Map(), declared = new Set();
333
- for (const s of scopes) {
334
- const scopeConfig = new Map([...configTexts].filter(([f]) => configOwner.get(f) === s));
335
- const r = computeDepFindings({
336
- externalImports: importsByScope.get(s), pkg: s.pkg || {}, workspacePkgNames, configTexts: scopeConfig,
337
- aliases: s.aliases || [], scope: s.root, manifest: s.manifest || (s.root ? `${s.root}/package.json` : "package.json"),
338
- nonRuntimeRoots, sourceFiles: sourceFilesByScope.get(s),
339
- });
340
- findings.push(...r.findings);
341
- for (const [name, use] of r.usedPackages) usedPackages.set(`${s.root || "."}:${name}`, use);
342
- for (const name of r.declared) declared.add(`${s.root || "."}:${name}`);
343
- }
344
- return { findings, usedPackages, declared };
345
- }
298
+ export const computeScopedDepFindings = createScopedDepFindings(computeDepFindings)
346
299
 
347
300
  export { computeGoDepFindings, computePyDepFindings } from "./dep-check-ecosystems.js";
@@ -1,303 +1,10 @@
1
- // Pure structure checker (replaces dependency-cruiser's core): circular dependencies (iterative Tarjan
2
- // SCC + one representative cycle each), orphan files, and a small glob boundary-rule DSL evaluated over
3
- // the graph's file-level import edges. NO filesystem — internal-audit.js feeds it. DEPS_SECURITY_PLAN P2.
4
- //
5
- // Honest gaps vs depcruise: we don't follow imports INTO node_modules and don't run enhanced-resolve
6
- // (package.json#exports / webpack resolution), so some cycles it sees we can't. Cycles we DO report are
7
- // built from EXTRACTED import edges → high confidence.
8
- import { makeFinding } from "./findings.js";
9
- import { ENTRY_FILE } from "./dead-check.js";
10
- import { formatRepresentativeCycle } from "./cycle-route.js";
11
- const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i;
12
- // config/data/docs: never "orphans" — nothing imports them by design
13
- const NON_CODE_RE = /\.(json|ya?ml|sh|ps1|md|txt|html?|css|scss|less)$|(^|[/])(dockerfile|containerfile)/i;
14
- const ep = (v) => String(v && typeof v === "object" ? v.id : v);
15
- const fileOf = (v) => { const s = ep(v); const h = s.indexOf("#"); return h < 0 ? s : s.slice(0, h); };
16
- const dirOf = (f) => (f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
17
- // File-level import adjacency. Go same-directory edges are excluded: a Go package IS the directory, the
18
- // compiler forbids real cross-package cycles, and our suffix-fallback Go resolution could fake them.
19
- export function buildFileImportGraph(graph, { includeTypeOnly = false, includeCompileOnly = false } = {}) {
20
- const fileIds = new Set();
21
- for (const n of graph.nodes || []) if (!String(n.id).includes("#")) fileIds.add(String(n.id));
22
- const runtimeAdj = new Map(); // runtime/value imports only
23
- const allAdj = new Map(); // runtime + compile-time-only, used to describe architectural coupling
24
- const edges = [];
25
- const allEdges = [];
26
- const typeOnlyEdges = [];
27
- const compileOnlyEdges = [];
28
- const compileTimeEdges = [];
29
- const runtimeSeen = new Set(), allSeen = new Set(), typeSeen = new Set(), compileSeen = new Set(), compileTimeSeen = new Set();
30
- const add = (map, a, b) => {
31
- let set = map.get(a);
32
- if (!set) map.set(a, (set = new Set()));
33
- set.add(b);
34
- };
35
- for (const l of graph.links || []) {
36
- if (l.relation !== "imports" && l.relation !== "re_exports") continue;
37
- const a = ep(l.source), b = ep(l.target);
38
- if (!fileIds.has(a) || !fileIds.has(b) || a === b) continue;
39
- if (a.endsWith(".go") && b.endsWith(".go") && dirOf(a) === dirOf(b)) continue;
40
- const key = `${a}\0${b}`;
41
- if (!allSeen.has(key)) { allSeen.add(key); add(allAdj, a, b); allEdges.push([a, b]); }
42
- if (l.typeOnly === true || l.compileOnly === true) {
43
- if (l.typeOnly === true && !typeSeen.has(key)) { typeSeen.add(key); typeOnlyEdges.push([a, b]); }
44
- if (l.compileOnly === true && !compileSeen.has(key)) { compileSeen.add(key); compileOnlyEdges.push([a, b]); }
45
- if (!compileTimeSeen.has(key)) { compileTimeSeen.add(key); compileTimeEdges.push([a, b]); }
46
- continue;
47
- }
48
- if (!runtimeSeen.has(key)) { runtimeSeen.add(key); add(runtimeAdj, a, b); edges.push([a, b]); }
49
- }
50
- const pureTypeOnlyEdges = typeOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
51
- const pureCompileOnlyEdges = compileOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
52
- const pureCompileTimeEdges = compileTimeEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
53
- return {
54
- fileIds,
55
- adj: includeTypeOnly || includeCompileOnly ? allAdj : runtimeAdj,
56
- edges: includeTypeOnly || includeCompileOnly ? allEdges : edges,
57
- runtimeAdj,
58
- runtimeEdges: edges,
59
- allAdj,
60
- allEdges,
61
- typeOnlyEdges: pureTypeOnlyEdges,
62
- compileOnlyEdges: pureCompileOnlyEdges,
63
- compileTimeEdges: pureCompileTimeEdges,
64
- };
65
- }
66
- // Iterative Tarjan (deep graphs must not blow the JS stack). Returns only non-trivial SCCs (size > 1).
67
- export function findSccs(adj) {
68
- const index = new Map(), low = new Map(), onStack = new Set(), S = [];
69
- let counter = 0;
70
- const sccs = [];
71
- for (const root of adj.keys()) {
72
- if (index.has(root)) continue;
73
- index.set(root, counter); low.set(root, counter); counter++;
74
- S.push(root); onStack.add(root);
75
- const stack = [{ v: root, ci: 0, neigh: [...(adj.get(root) || [])] }];
76
- while (stack.length) {
77
- const fr = stack[stack.length - 1];
78
- if (fr.ci < fr.neigh.length) {
79
- const w = fr.neigh[fr.ci++];
80
- if (!index.has(w)) {
81
- index.set(w, counter); low.set(w, counter); counter++;
82
- S.push(w); onStack.add(w);
83
- stack.push({ v: w, ci: 0, neigh: [...(adj.get(w) || [])] });
84
- } else if (onStack.has(w)) {
85
- low.set(fr.v, Math.min(low.get(fr.v), index.get(w)));
86
- }
87
- } else {
88
- stack.pop();
89
- if (stack.length) { const p = stack[stack.length - 1]; low.set(p.v, Math.min(low.get(p.v), low.get(fr.v))); }
90
- if (low.get(fr.v) === index.get(fr.v)) {
91
- const comp = [];
92
- let w;
93
- do { w = S.pop(); onStack.delete(w); comp.push(w); } while (w !== fr.v);
94
- if (comp.length > 1) sccs.push(comp);
95
- }
96
- }
97
- }
98
- }
99
- return sccs;
100
- }
101
- // One representative (shortest) cycle through an SCC's lexicographically-first file — a readable path,
102
- // not the full Johnson enumeration (which explodes combinatorially on big tangles).
103
- export function representativeCycle(adj, scc) {
104
- const inScc = new Set(scc);
105
- const start = [...scc].sort()[0];
106
- const prev = new Map([[start, null]]);
107
- const q = [start];
108
- while (q.length) {
109
- const cur = q.shift();
110
- for (const nxt of [...(adj.get(cur) || [])].sort()) {
111
- if (nxt === start) {
112
- const path = [];
113
- for (let c = cur; c != null; c = prev.get(c)) path.push(c);
114
- return [...path.reverse(), start];
115
- }
116
- if (!inScc.has(nxt) || prev.has(nxt)) continue;
117
- prev.set(nxt, cur);
118
- q.push(nxt);
119
- }
120
- }
121
- const fallback = [...scc].sort();
122
- return [...fallback, fallback[0]]; // unreachable in a true SCC; deterministic safe fallback
123
- }
124
- // Orphans: file nodes with ZERO non-contains graph degree (nothing in, nothing out — imports, calls,
125
- // references all collapsed to files). Entries/tests/config-data are exempt. A file that DOES import
126
- // npm packages (externalImports) is a working script, not an island — confidence drops, not the verdict.
127
- export function findOrphans(graph, { entrySet = new Set(), externalImportFiles = new Set() } = {}) {
128
- const deg = new Map();
129
- for (const l of graph.links || []) {
130
- if (l.relation === "contains") continue;
131
- const a = fileOf(l.source), b = fileOf(l.target);
132
- if (a === b) continue;
133
- deg.set(a, (deg.get(a) || 0) + 1);
134
- deg.set(b, (deg.get(b) || 0) + 1);
135
- }
136
- const out = [];
137
- for (const n of graph.nodes || []) {
138
- const id = String(n.id);
139
- if (id.includes("#")) continue;
140
- if ((deg.get(id) || 0) > 0) continue;
141
- const f = n.source_file;
142
- if (entrySet.has(f) || ENTRY_FILE.test(f) || TEST_FILE_RE.test(f) || NON_CODE_RE.test(f)) continue;
143
- out.push({ file: f, importsExternals: externalImportFiles.has(f) });
144
- }
145
- return out;
146
- }
147
- // Boundary DSL — the useful subset of depcruise's forbidden rules, as plain JSON globs:
148
- // { "forbidden": [{ "name", "comment"?, "severity"?, "from": "main/**", "to": "renderer/**" }],
149
- // "allowedOnly":[{ "name", "comment"?, "severity"?, "from": "src/ui/**", "to": ["src/ui/**", "src/shared/**"] }] }
150
- // forbidden fires when an edge matches from AND to; allowedOnly fires when from matches and to matches NONE.
151
- export function globToRe(glob) {
152
- // split on ** first so single-* expansion cannot touch the multi-segment wildcards
153
- const parts = String(glob).split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]"));
154
- let s = parts.join(".*");
155
- s = s.replace(/\/\.\*\//g, "/(?:.*/)?"); // a/**/b also matches a/b (zero middle dirs)
156
- s = s.replace(/^\.\*\//, "(?:.*/)?"); // **/b also matches root-level b
157
- return new RegExp(`^${s}$`);
158
- }
159
-
160
- export function checkBoundaries(edges, rules = {}) {
161
- const violations = [];
162
- const forbidden = (rules.forbidden || []).map((r) => ({ ...r, fromRe: globToRe(r.from), toRe: globToRe(r.to) }));
163
- const allowedOnly = (rules.allowedOnly || []).map((r) => ({ ...r, fromRe: globToRe(r.from), toRes: (Array.isArray(r.to) ? r.to : [r.to]).map(globToRe) }));
164
- for (const [a, b] of edges) {
165
- for (const r of forbidden) if (r.fromRe.test(a) && r.toRe.test(b)) violations.push({ name: r.name, comment: r.comment || "", severity: r.severity, from: a, to: b, kind: "forbidden" });
166
- for (const r of allowedOnly) if (r.fromRe.test(a) && !r.toRes.some((re) => re.test(b))) violations.push({ name: r.name, comment: r.comment || "", severity: r.severity, from: a, to: b, kind: "allowedOnly" });
167
- }
168
- return violations;
169
- }
170
-
171
- const MAX_CYCLE_FINDINGS = 50;
172
- const MAX_BOUNDARY_FINDINGS = 100;
173
- // Assemble everything into unified Findings. rules comes from the repo's .weavatrix-deps.json (optional).
174
- export function computeStructureFindings(graph, { rules = {}, entrySet = new Set(), externalImportFiles = new Set() } = {}) {
175
- const { adj, edges, allAdj, allEdges, typeOnlyEdges, compileOnlyEdges, compileTimeEdges } = buildFileImportGraph(graph);
176
- const findings = [];
177
- const sccs = findSccs(adj).sort((a, b) => b.length - a.length);
178
- for (const scc of sccs.slice(0, MAX_CYCLE_FINDINGS)) {
179
- const cycle = representativeCycle(adj, scc);
180
- const cycleRoute = formatRepresentativeCycle(cycle);
181
- findings.push(makeFinding({
182
- category: "structure",
183
- rule: "circular-dep",
184
- severity: scc.length > 4 ? "high" : "medium",
185
- confidence: "high",
186
- title: `Circular dependency: ${scc.length} files`,
187
- detail: `${cycleRoute}${scc.length + 1 > cycle.length ? ` (representative loop; the tangle spans ${scc.length} files)` : ""}. Break the cycle by extracting the shared piece or inverting one import.`,
188
- cycleRoute,
189
- cycleMembers: [...scc].sort(),
190
- file: cycle[0],
191
- graphNodeId: cycle[0],
192
- evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
193
- source: "internal",
194
- fixHint: "extract the shared code into a module both sides import, or invert the weaker dependency",
195
- }));
196
- }
197
-
198
- // TypeScript `import type` and Rust module/use edges are compile-time coupling. They can reveal real
199
- // architecture, but cannot create an initialization-order/runtime cycle. Report SCCs that require
200
- // either classification separately so agents do not churn working code for a phantom runtime hazard.
201
- const runtimeKeys = new Set(sccs.map((s) => [...s].sort().join("\0")));
202
- const allSccs = findSccs(allAdj).sort((a, b) => b.length - a.length);
203
- const compileTimeCouplings = allSccs.filter((s) => !runtimeKeys.has([...s].sort().join("\0")));
204
- const edgeCountIn = (scc, list) => {
205
- const inside = new Set(scc);
206
- return list.reduce((n, [a, b]) => n + (inside.has(a) && inside.has(b) ? 1 : 0), 0);
207
- };
208
- for (const scc of compileTimeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
209
- const cycle = representativeCycle(allAdj, scc);
210
- const cycleRoute = formatRepresentativeCycle(cycle);
211
- const runtimeInside = edgeCountIn(scc, edges);
212
- const typeInside = edgeCountIn(scc, typeOnlyEdges);
213
- const compileInside = edgeCountIn(scc, compileOnlyEdges);
214
- const containsRuntimeCycle = sccs.some((runtime) => runtime.every((f) => scc.includes(f)));
215
- const typeSpecific = compileInside === 0;
216
- findings.push(makeFinding({
217
- category: "structure",
218
- rule: typeSpecific ? "type-coupling" : "compile-time-coupling",
219
- severity: "info",
220
- confidence: "high",
221
- title: `${containsRuntimeCycle
222
- ? (typeSpecific ? "Type imports expand dependency coupling" : "Compile-time edges expand dependency coupling")
223
- : (typeSpecific ? "Type-induced dependency cycle (no runtime cycle)" : "Compile-time dependency cycle (no runtime cycle)")}: ${scc.length} files`,
224
- detail: `${cycleRoute}. This strongly-connected group needs compile-time-only edges to close; it contains ${runtimeInside} runtime edge(s), ${typeInside} type-only edge(s), and ${compileInside} compile-only edge(s)${containsRuntimeCycle ? ", with a smaller runtime cycle reported separately" : ", while its runtime import graph is acyclic"}. Treat this as design coupling, not an initialization-order failure.`,
225
- cycleRoute,
226
- cycleMembers: [...scc].sort(),
227
- file: cycle[0],
228
- graphNodeId: cycle[0],
229
- evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
230
- source: "internal",
231
- fixHint: "review the compile-time ownership only if the coupling impedes changes; no runtime-cycle fix is required",
232
- }));
233
- }
234
- if (sccs.length > MAX_CYCLE_FINDINGS) {
235
- findings.push(makeFinding({
236
- category: "structure", rule: "circular-dep", severity: "info", confidence: "high",
237
- title: `…and ${sccs.length - MAX_CYCLE_FINDINGS} more dependency cycles`,
238
- detail: `Cycle findings are capped at ${MAX_CYCLE_FINDINGS}; ${sccs.length} strongly-connected groups exist in total.`,
239
- source: "internal",
240
- }));
241
- }
242
-
243
- for (const o of findOrphans(graph, { entrySet, externalImportFiles })) {
244
- findings.push(makeFinding({
245
- category: "structure",
246
- rule: "orphan-file",
247
- severity: "info",
248
- confidence: o.importsExternals ? "low" : "medium",
249
- title: `Orphan file: ${o.file}`,
250
- detail: `No repo file imports it and it imports/calls nothing in the repo${o.importsExternals ? " (it does use npm packages — possibly a standalone script or tool)" : ""}. Possibly dead, possibly an undeclared entry point.`,
251
- file: o.file,
252
- graphNodeId: o.file,
253
- source: "internal",
254
- }));
255
- }
256
-
257
- // Architecture boundaries describe executable dependencies by default. Type-only contract sharing is
258
- // visible in typeCouplings above, but must not be presented as a runtime layer violation.
259
- const violations = checkBoundaries(edges, rules);
260
- for (const v of violations.slice(0, MAX_BOUNDARY_FINDINGS)) {
261
- findings.push(makeFinding({
262
- category: "structure",
263
- rule: "boundary-violation",
264
- severity: ["critical", "high", "medium", "low", "info"].includes(v.severity) ? v.severity : "medium",
265
- confidence: "high",
266
- title: `Boundary violation (${v.name}): ${v.from} → ${v.to}`,
267
- detail: `${v.kind === "allowedOnly" ? "Import leaves the allowed set" : "Forbidden import"}${v.comment ? `: ${v.comment}` : ""}.`,
268
- file: v.from,
269
- graphNodeId: v.from,
270
- evidence: [{ file: v.from, line: 0, snippet: `imports ${v.to}` }],
271
- source: "internal",
272
- }));
273
- }
274
- if (violations.length > MAX_BOUNDARY_FINDINGS) {
275
- findings.push(makeFinding({
276
- category: "structure", rule: "boundary-violation", severity: "info", confidence: "high",
277
- title: `…and ${violations.length - MAX_BOUNDARY_FINDINGS} more boundary violations`,
278
- detail: `Boundary findings are capped at ${MAX_BOUNDARY_FINDINGS}; ${violations.length} edges violate the rules in total.`,
279
- source: "internal",
280
- }));
281
- }
282
-
283
- return {
284
- findings,
285
- stats: {
286
- importEdges: allEdges.length,
287
- runtimeImportEdges: edges.length,
288
- typeOnlyImportEdges: typeOnlyEdges.length,
289
- compileOnlyImportEdges: compileOnlyEdges.length,
290
- compileTimeImportEdges: compileTimeEdges.length,
291
- cycles: sccs.length,
292
- runtimeCycles: sccs.length,
293
- largestCycle: sccs[0]?.length || 0,
294
- // Backward-compatible aliases remain for edgeTypesV 1 consumers.
295
- typeCouplings: compileTimeCouplings.length,
296
- largestTypeCoupling: compileTimeCouplings[0]?.length || 0,
297
- compileTimeCouplings: compileTimeCouplings.length,
298
- largestCompileTimeCoupling: compileTimeCouplings[0]?.length || 0,
299
- orphans: findings.filter((f) => f.rule === "orphan-file").length,
300
- boundaryViolations: violations.length,
301
- },
302
- };
303
- }
1
+ // Stable facade for structural dependency analysis.
2
+ export {
3
+ buildFileImportGraph,
4
+ checkBoundaries,
5
+ findOrphans,
6
+ findSccs,
7
+ globToRe,
8
+ representativeCycle,
9
+ } from './structure/dependency-graph.js'
10
+ export {computeStructureFindings} from './structure/findings.js'
@@ -0,0 +1,40 @@
1
+ const normalizeScope = (root) => String(root || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
2
+ const ownsFile = (scope, file) => !scope || file === scope || String(file || '').startsWith(`${scope}/`)
3
+
4
+ // Bind the ecosystem-specific core without introducing a facade/helper import cycle.
5
+ export function createScopedDepFindings(computeDepFindings) {
6
+ return function computeScopedDepFindings({
7
+ externalImports = [], packageScopes = [], workspacePkgNames = new Set(), configTexts = new Map(),
8
+ nonRuntimeRoots = [], sourceFiles = [],
9
+ } = {}) {
10
+ const scopes = packageScopes.length
11
+ ? packageScopes.map((scope) => ({...scope, root: normalizeScope(scope.root)})).sort((a, b) => b.root.length - a.root.length)
12
+ : [{root: '', manifest: 'package.json', pkg: {}, aliases: []}]
13
+ const importsByScope = new Map(scopes.map((scope) => [scope, []]))
14
+ const sourceFilesByScope = new Map(scopes.map((scope) => [scope, []]))
15
+ for (const item of externalImports) {
16
+ const owner = scopes.find((scope) => ownsFile(scope.root, item.file)) || scopes[scopes.length - 1]
17
+ importsByScope.get(owner).push(item)
18
+ }
19
+ for (const file of sourceFiles) {
20
+ const owner = scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1]
21
+ sourceFilesByScope.get(owner).push(file)
22
+ }
23
+ const configOwner = new Map()
24
+ for (const [file] of configTexts) configOwner.set(file, scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1])
25
+ const findings = [], usedPackages = new Map(), declared = new Set()
26
+ for (const scope of scopes) {
27
+ const scopeConfig = new Map([...configTexts].filter(([file]) => configOwner.get(file) === scope))
28
+ const result = computeDepFindings({
29
+ externalImports: importsByScope.get(scope), pkg: scope.pkg || {}, workspacePkgNames,
30
+ configTexts: scopeConfig, aliases: scope.aliases || [], scope: scope.root,
31
+ manifest: scope.manifest || (scope.root ? `${scope.root}/package.json` : 'package.json'),
32
+ nonRuntimeRoots, sourceFiles: sourceFilesByScope.get(scope),
33
+ })
34
+ findings.push(...result.findings)
35
+ for (const [name, use] of result.usedPackages) usedPackages.set(`${scope.root || '.'}:${name}`, use)
36
+ for (const name of result.declared) declared.add(`${scope.root || '.'}:${name}`)
37
+ }
38
+ return {findings, usedPackages, declared}
39
+ }
40
+ }
@@ -0,0 +1,62 @@
1
+ export const MAX_ENDPOINT_FILES = 3_000;
2
+ export const MAX_ENDPOINTS = 2_000;
3
+ export const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "ALL", "ANY"]);
4
+
5
+ export function lineAt(text, index) {
6
+ let line = 1;
7
+ for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
8
+ return line;
9
+ }
10
+
11
+ // Preserve offsets and literals while hiding comments from regex extractors.
12
+ export function maskComments(text, { hashComments = false } = {}) {
13
+ const chars = String(text || "").split("");
14
+ let quote = "", escaped = false, lineComment = false, blockComment = false;
15
+ for (let i = 0; i < chars.length; i++) {
16
+ const ch = chars[i], next = chars[i + 1];
17
+ if (lineComment) {
18
+ if (ch === "\n" || ch === "\r") lineComment = false;
19
+ else chars[i] = " ";
20
+ continue;
21
+ }
22
+ if (blockComment) {
23
+ if (ch === "*" && next === "/") { chars[i] = chars[i + 1] = " "; i++; blockComment = false; }
24
+ else if (ch !== "\n" && ch !== "\r") chars[i] = " ";
25
+ continue;
26
+ }
27
+ if (quote) {
28
+ if (escaped) escaped = false;
29
+ else if (ch === "\\") escaped = true;
30
+ else if (ch === quote) quote = "";
31
+ continue;
32
+ }
33
+ if (ch === '"' || ch === "'" || ch === "`") { quote = ch; continue; }
34
+ if (ch === "/" && next === "/") { chars[i] = chars[i + 1] = " "; i++; lineComment = true; continue; }
35
+ if (ch === "/" && next === "*") { chars[i] = chars[i + 1] = " "; i++; blockComment = true; continue; }
36
+ if (hashComments && ch === "#") { chars[i] = " "; lineComment = true; }
37
+ }
38
+ return chars.join("");
39
+ }
40
+
41
+ export function handlerName(expr) {
42
+ const source = String(expr || "").trim();
43
+ if (!source || /=>/.test(source) || /^\s*(async\s+)?function\b/.test(source) || /^\s*(?:async\s+)?(?:move\s+)?\|[^|]*\|/.test(source)) return "";
44
+ const turbofish = /(?:^|::)([A-Za-z_][\w]*)\s*::<[\s\S]*>\s*$/.exec(source);
45
+ if (turbofish) return turbofish[1];
46
+ const identifiers = source.match(/[A-Za-z_$][\w$]*/g);
47
+ if (!identifiers) return "";
48
+ const skipped = new Set(["async", "function", "await", "req", "res", "ctx", "request", "response", "next", "return"]);
49
+ for (let i = identifiers.length - 1; i >= 0; i--) if (!skipped.has(identifiers[i])) return identifiers[i];
50
+ return "";
51
+ }
52
+
53
+ export function handlerReference(expr) {
54
+ const source = String(expr || "").trim();
55
+ if (!source || /=>/.test(source) || /^\s*(async\s+)?function\b/.test(source)) return "";
56
+ const refs = [...source.matchAll(/([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)+)/g)];
57
+ return refs.length ? refs.at(-1)[1].replace(/\s+/g, "") : "";
58
+ }
59
+
60
+ export const looksLikePath = (path) => typeof path === "string" && /^\/[\w\-./:{}*$?]*$/.test(path) && !path.includes("://");
61
+ export const cleanPath = (path) => String(path || "").replace(/\/+$/, "") || "/";
62
+ export const normalizedFile = (file) => String(file || "").replace(/\\/g, "/").replace(/^\.\//, "");