weavatrix 0.1.3 → 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 (78) hide show
  1. package/README.md +168 -63
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +108 -39
  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 +13 -6
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +163 -0
  12. package/src/analysis/dep-check.js +69 -147
  13. package/src/analysis/dep-rules.js +47 -31
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints-rust.js +124 -0
  17. package/src/analysis/endpoints.js +18 -5
  18. package/src/analysis/findings.js +8 -1
  19. package/src/analysis/git-history.js +549 -0
  20. package/src/analysis/git-ref-graph.js +74 -0
  21. package/src/analysis/graph-analysis.aggregate.js +29 -28
  22. package/src/analysis/graph-analysis.edges.js +24 -0
  23. package/src/analysis/graph-analysis.js +1 -1
  24. package/src/analysis/graph-analysis.summaries.js +4 -1
  25. package/src/analysis/http-contracts.js +581 -0
  26. package/src/analysis/internal-audit.collect.js +43 -2
  27. package/src/analysis/internal-audit.reach.js +52 -1
  28. package/src/analysis/internal-audit.run.js +66 -13
  29. package/src/analysis/java-source.js +36 -0
  30. package/src/analysis/package-reachability.js +39 -0
  31. package/src/analysis/static-test-reachability.js +133 -0
  32. package/src/build-graph.js +72 -13
  33. package/src/graph/build-worker.js +3 -4
  34. package/src/graph/builder/lang-java.js +177 -14
  35. package/src/graph/builder/lang-js.js +71 -12
  36. package/src/graph/builder/lang-rust.js +129 -6
  37. package/src/graph/community.js +27 -0
  38. package/src/graph/file-lock.js +69 -0
  39. package/src/graph/freshness-probe.js +141 -0
  40. package/src/graph/graph-filter.js +28 -11
  41. package/src/graph/incremental-refresh.js +232 -0
  42. package/src/graph/internal-builder.barrels.js +169 -0
  43. package/src/graph/internal-builder.build.js +111 -16
  44. package/src/graph/internal-builder.java.js +33 -0
  45. package/src/graph/internal-builder.langs.js +2 -1
  46. package/src/graph/internal-builder.resolvers.js +109 -2
  47. package/src/graph/layout.js +21 -5
  48. package/src/graph/relations.js +4 -0
  49. package/src/graph/repo-registry.js +124 -0
  50. package/src/mcp/catalog.mjs +64 -21
  51. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  52. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  53. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  54. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  55. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  56. package/src/mcp/evidence-snapshot.mjs +50 -0
  57. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  58. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  59. package/src/mcp/graph-context.mjs +106 -181
  60. package/src/mcp/graph-diff.mjs +217 -0
  61. package/src/mcp/staleness-notice.mjs +20 -0
  62. package/src/mcp/sync-evidence.mjs +418 -0
  63. package/src/mcp/sync-payload.mjs +194 -8
  64. package/src/mcp/tool-result.mjs +51 -0
  65. package/src/mcp/tools-actions.mjs +114 -33
  66. package/src/mcp/tools-architecture.mjs +144 -0
  67. package/src/mcp/tools-company.mjs +273 -0
  68. package/src/mcp/tools-graph-hubs.mjs +58 -0
  69. package/src/mcp/tools-graph.mjs +36 -68
  70. package/src/mcp/tools-health.mjs +354 -20
  71. package/src/mcp/tools-history.mjs +22 -0
  72. package/src/mcp/tools-impact-change.mjs +261 -0
  73. package/src/mcp/tools-impact.mjs +35 -11
  74. package/src/mcp-server.mjs +100 -17
  75. package/src/mcp-source-tools.mjs +11 -3
  76. package/src/path-classification.js +201 -0
  77. package/src/path-ignore.js +69 -0
  78. package/src/security/typosquat.js +1 -1
package/skill/SKILL.md CHANGED
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: weavatrix
3
- description: Drive the weavatrix MCP — code graph, blast-radius (get_dependents/change_impact), health audit, duplicates, coverage, endpoints — over any local repo. Use when analyzing code structure, refactor risk, dead code, dependency health, or before opening a PR.
3
+ description: Drive the weavatrix MCP — code graph, blast-radius (get_dependents/change_impact), dead-code review, health audit, duplicates, coverage, endpoints — over any local repo. Use when analyzing code structure, refactor risk, dead code, dependency health, or before opening a PR.
4
4
  ---
5
5
 
6
6
  # weavatrix MCP
7
7
 
8
8
  Structure-analysis tools over a prebuilt code graph plus the weavatrix analysis engines. The default
9
- is offline and includes `open_repo` for one-call switching between local Git repositories. A custom
10
- capability list that omits `retarget` pins the registration to its initial repository.
9
+ `offline` profile has every HTTP tool disabled and permits repository switching only through an
10
+ explicit local `open_repo` call. Use `pinned` when a shared MCP process must never change or inspect
11
+ outside its startup repository.
11
12
 
12
13
  ## Step 0 — if the tools are missing
13
14
 
@@ -15,45 +16,71 @@ Tools are named `mcp__weavatrix__…`. If none are available, ask the user to re
15
16
  (`claude mcp add -s user weavatrix -- npx -y weavatrix <repoRoot>`; Codex:
16
17
  `codex mcp add weavatrix -- npx -y weavatrix <repoRoot>`), then retry.
17
18
 
18
- If only `refresh_advisories` or `sync_graph` is missing, do not diagnose a broken installation:
19
- `online` is intentionally absent from the default capability list.
19
+ If `refresh_advisories`, `pull_architecture_contract`, or `sync_graph` is missing, do not diagnose a
20
+ broken installation: network tools are intentionally absent from `offline` and `pinned`.
21
+
22
+ Profiles use the same npm package and binary:
23
+
24
+ - `offline` (default): all local analysis and explicit `open_repo`; no HTTP tools.
25
+ - `pinned`: local analysis with no `open_repo`, no global/cross-repository graph access, and no HTTP tools.
26
+ - `osv`: `offline` plus explicit `refresh_advisories`.
27
+ - `hosted` / `full`: `osv` plus `pull_architecture_contract` and `sync_graph`.
28
+
29
+ The legacy `online` capability remains a compatibility alias for `advisories,hosted`; prefer the
30
+ named profiles for new registrations.
20
31
 
21
32
  ## Ground rules
22
33
 
23
34
  - **Evidence, not verdicts**: treat audit, hub, orphan and duplicate output as hypotheses. Confirm a
24
35
  finding in source and check framework/runtime conventions before deleting, merging or redesigning
25
36
  code. A same-name/different-body pair is a divergence candidate, not proof of duplication.
26
- - **Freshness**: every graph tool appends a staleness warning when the repo has commits newer than
27
- the graph. Act on it: `rebuild_graph`. A normal `open_repo` builds missing graphs and upgrades
28
- pre-`0.1.3` graphs to typed import edges; `build:false` deliberately refuses that upgrade.
37
+ - **Freshness**: graph/health calls automatically reconcile the active graph, while cross-repository
38
+ tracing reconciles every selected registered graph. Read the structured `refresh` /
39
+ `graphReconciliation` status: `none`, `incremental`, `full`, or explicitly `PARTIAL`. Use
40
+ `rebuild_graph` only when automatic reconciliation reports a fallback/error or when intentionally
41
+ changing build mode. A normal `open_repo` builds missing graphs and upgrades legacy schemas;
42
+ `build:false` deliberately refuses that upgrade.
29
43
  - **Ambiguity**: `get_node`/`get_neighbors`/`get_dependents` disclose `matched N nodes; using the
30
44
  best-connected` — read that note before trusting the answer; pass an exact node id to pin it.
31
- - **Runtime versus compile time**: keep runtime cycles and TypeScript type-only coupling separate.
32
- `module_map` and `change_impact` label the distinction; `god_nodes` ranks unique connectivity and
33
- reports repeated references separately. Do not schedule a runtime-cycle refactor from type-only
34
- edges alone.
45
+ - **Runtime versus compile time**: keep runtime cycles separate from TypeScript type-only and
46
+ language compile-only coupling (Rust `mod`/`use`/`pub use`, Java imports). `module_map`,
47
+ `change_impact` and `graph_diff` label the distinction; `god_nodes` ranks unique connectivity and
48
+ reports repeated references separately. Do not schedule a runtime-cycle refactor from
49
+ compile-time-only edges.
35
50
  - **Repository universe**: in Git repositories, graph and duplicate scans include tracked plus
36
51
  non-ignored untracked files. If an old graph still contains packaged/generated output, rebuild it
37
- before interpreting the result.
52
+ before interpreting the result. Repository-root `.weavatrixignore` applies the same tracked-file
53
+ exclusions to graph, audit and duplicate passes; use it for generated/e2e fixtures that must stay
54
+ committed. `no-tests` also recognizes Cypress, Playwright, `test-e2e`, acceptance and integration
55
+ roots. Dead-code/clone/audit review also suppresses `benchmarks/**` and `**/__temp/**` as classified
56
+ non-production surfaces. A verified production benchmark can opt back in narrowly through
57
+ `.weavatrix.json` `classify.product`, for example `{"classify":{"product":["benchmarks/core/**"]}}`.
58
+ - **Architectural queries**: for bootstrap/routing/authentication questions, inspect the returned
59
+ seeds before trusting the traversal. Pass exact repository-relative `seed_files` to `query_graph`
60
+ when the intended entry points are already known.
38
61
  - **Coverage**: `coverage_map` reads an existing report. `unavailable` means no supported report was
39
62
  found, not 0% coverage; do not rank testing risk from that state.
40
63
  - **Audit completeness**: read `checks.osv.status` and `checks.malware.status`. For OSV, `OK` is
41
64
  complete for the recorded dependency fingerprint; `PARTIAL` is incomplete or stale,
42
65
  `NOT_CHECKED` has no repository-specific result, and `ERROR` means the local check failed. Treat
43
66
  the same non-`OK` states as incomplete for malware scanning. Refresh OSV only when the user has
44
- authorized adding the optional `online` capability group to the MCP registration and then
67
+ authorized selecting the optional `osv` profile (or `advisories` capability) and then
45
68
  invoking `refresh_advisories`; enabling the group alone sends nothing.
46
69
  - **Offline by design**: scans and graph queries run in-process against local files; coverage tools
47
- read existing reports and never run tests. The ONLY network-touching tools live in the `online`
48
- capability group and run solely when explicitly called: `refresh_advisories` (queries OSV.dev with
49
- package names + versions so `run_audit` has fresh vulnerability data) and `sync_graph` (pushes a
50
- versioned allowlist of graph metadata, discarding unknown fields and never reading source file
51
- bodies for sync, to a user-configured endpoint; disabled until
52
- `WEAVATRIX_SYNC_URL` is set). `online` is
53
- absent from the default capability set and must be enabled explicitly.
70
+ read existing reports and never run tests. The ONLY network-touching tools live in the optional
71
+ `advisories` / `hosted` capabilities and run solely when explicitly called:
72
+ `refresh_advisories` (queries OSV.dev with
73
+ package names + versions so `run_audit` has fresh vulnerability data) and `sync_graph` (derives a
74
+ bounded evidence snapshot locally, then pushes only an allowlisted graph/evidence contract to a
75
+ user-configured endpoint; analyzers may read local source, but the wire contract has no body,
76
+ snippet, absolute-host-path or environment fields, and unknown fields are discarded; disabled until
77
+ `WEAVATRIX_SYNC_URL` is set). `pull_architecture_contract` sends only the active repository's opaque
78
+ stable UUID, downloads the owner-approved contract, validates it, and caches it locally; it requires
79
+ `WEAVATRIX_SYNC_URL` and `WEAVATRIX_SYNC_TOKEN`. All three are absent from the default profile.
54
80
  - **Repository boundary**: source reads and graph-derived paths are realpath-contained. `open_repo`
55
- intentionally changes the active boundary through an explicit offline tool call. It is available
56
- by default; omit `retarget` from a custom capability list to hide it.
81
+ intentionally changes the active boundary through an explicit offline tool call. It is included in
82
+ `offline`, absent from `pinned`, and available in a custom registration only when `retarget` is
83
+ named. Concurrent non-mutating calls retain the graph/root snapshot with which they started.
57
84
 
58
85
  ## Recipes
59
86
 
@@ -64,12 +91,37 @@ If only `refresh_advisories` or `sync_graph` is missing, do not diagnose a broke
64
91
  and untracked work, coverage attached, untested hotspots called out) → drill with `get_dependents`.
65
92
  - **Impact of a PR that is NOT checked out**: pass its changed-file list explicitly —
66
93
  `change_impact files=[…]` — same blast-radius + coverage view, no checkout.
67
- - **Validate a refactor structurally**: edit `rebuild_graph` it reports the structural delta
68
- (cycle broken? new module dependency introduced? symbols orphaned?); re-query later with
69
- `graph_diff` (optionally scoped by `path`). The semantic complement to the textual git diff.
70
- - **Health sweep**: `run_audit` (filter by `category`/`min_severity`) `find_duplicates`
71
- `coverage_map`. Inspect the source behind shortlisted findings before proposing edits.
72
- - **API inventory**: `list_endpoints`.
94
+ - **Validate a refactor structurally**: call `graph_diff base_ref=HEAD~1` (or `main` / `origin/main`)
95
+ to build an immutable baseline without checkout and compare it with the current graph. Alternatively,
96
+ edit → `rebuild_graph`, then use `graph_diff` without `base_ref` to compare `graph.prev.json` with
97
+ the rebuilt graph. Either route can be scoped by `path`; look for cycle, module-dependency and
98
+ orphan drift rather than raw edge counts.
99
+ - **Health sweep**: for a branch/PR review prefer
100
+ `run_audit base_ref=<merge-base> debt=new changed_files=[…]`; for repository maintenance use
101
+ `run_audit debt=all`. Then call `find_dead_code`, `find_duplicates` and `coverage_map`. A changed-file-only audit is
102
+ scope, not proof of new debt. Inspect the source behind shortlisted findings before proposing edits.
103
+ - **Dead-code and dead-method review**: call `find_dead_code` for a bounded production-code queue of
104
+ files, functions, methods and symbols. Narrow with `path` or `kinds=["method"]`; keep the default
105
+ `min_confidence=medium` for actionable internal candidates. Use `min_confidence=low` only when the
106
+ task explicitly needs public/exported, framework, dynamic-loading or reflection-sensitive review,
107
+ and carry each returned caveat into the recommendation. Pair it with
108
+ `run_audit category=unused debt=all` (or `base_ref=<merge-base> debt=new`) for unused exports and
109
+ dependencies. Before deletion, run `read_source`, `get_dependents`, exact `search_code`, inspect
110
+ framework discovery/annotations, package scripts, CLI/Docker/manifests, generated consumers and
111
+ test-only use, then run the repository tests. `REVIEW_REQUIRED` and `autoDelete:false` are hard
112
+ safety semantics, not boilerplate. Use `.weavatrix-deps.json` entrypoints/nonRuntimeRoots only for
113
+ verified conventions.
114
+ - **API inventory**: `list_endpoints` (including Next.js App Router, Rust axum and actix-web).
115
+ - **Cross-repository API impact**: ensure both repositories are in `list_known_repos`, then call
116
+ `trace_api_contract backend=<uuid-or-label> clients=[<uuid-or-label>]`; narrow with `method`, `path`,
117
+ or backend `changed_files`. Treat unresolved/dynamic URLs as incomplete evidence, not a clean result.
118
+ - **Target architecture before editing**: `get_architecture_contract` → `prepare_change` with the
119
+ intended files → edit and rebuild → `verify_architecture`. A missing contract returns a starter
120
+ proposal, not an automatically approved architecture. Pull an owner-approved hosted contract only
121
+ when the user selected `hosted` and explicitly asks for it.
122
+ - **Behavioral architecture**: `git_history` ranks churn × connectivity hotspots and hidden
123
+ co-change coupling from bounded local numstat history. Use it as review evidence, not proof that two
124
+ files must be merged.
73
125
  - **Find code**: `search_code` (regex + glob) → `get_node` → `read_source`.
74
126
  - **Another repo**: `list_known_repos` → `open_repo <path>`
75
127
  (builds or upgrades the graph when needed; `build:false` probes without building).
@@ -77,13 +129,15 @@ If only `refresh_advisories` or `sync_graph` is missing, do not diagnose a broke
77
129
  ## Repository-specific conventions
78
130
 
79
131
  Weavatrix understands nearest workspace manifests, nested `tsconfig`/`jsconfig` aliases,
80
- framework-owned runtime peers such as Next.js + `react-dom`, and Next.js App Router route exports.
81
- For project-specific entry points or Python dependencies supplied by an external runtime, add
82
- `.weavatrix-deps.json` at the repository root:
132
+ framework-owned runtime peers such as Next.js + `react-dom`, generated NAPI-RS platform loaders,
133
+ and Next.js App Router route exports. For project-specific entry points, reusable template catalogs,
134
+ or Python dependencies supplied by an external runtime, add `.weavatrix-deps.json` at the repository
135
+ root:
83
136
 
84
137
  ```json
85
138
  {
86
139
  "entrypoints": ["scripts/publish-release.mjs"],
140
+ "nonRuntimeRoots": ["library", "catalogs/examples"],
87
141
  "python": {
88
142
  "managedDependencies": ["numpy", "openvino-genai"],
89
143
  "ignoreDependencies": ["vendor-sdk"]
@@ -91,22 +145,37 @@ For project-specific entry points or Python dependencies supplied by an external
91
145
  }
92
146
  ```
93
147
 
94
- Keep exceptions narrow. `managedDependencies` documents modules provided outside the repo's Python
95
- manifest; `ignoreDependencies` suppresses intentionally unresolved imports.
148
+ Keep exceptions narrow. `nonRuntimeRoots` (alias `templateRoots`) marks reusable examples that are
149
+ not one deployed application. It suppresses orphan/dead/unused-export noise and missing/unresolved
150
+ dependency findings when every use is inside those roots; graph edges, cycles and boundaries remain
151
+ visible. `managedDependencies` documents modules provided outside the repo's Python manifest;
152
+ `ignoreDependencies` suppresses intentionally unresolved imports.
96
153
 
97
154
  ## Sync
98
155
 
99
- `sync_graph` uses allowlisted payload v2, including typed-edge metadata but no source bodies. A graph
100
- built before `0.1.3` must be rebuilt first; normal `open_repo` does this automatically. Sync remains
101
- unavailable until the user opts into `online` and configures `WEAVATRIX_SYNC_URL`.
156
+ `sync_graph` defaults to payload v3: graph metadata plus deterministic architecture, health, stack,
157
+ package-dependency and clone-review evidence. Read each section's `state`, `verdict` and completeness counts; `PARTIAL`,
158
+ `NOT_CHECKED` and `ERROR` are unknown/incomplete, never a clean result. Architecture evidence
159
+ contains concrete runtime versus compile-time cycles, declared boundary violations and separated
160
+ runtime/type-only/compile-only module dependencies. Package evidence contains a bounded lockfile
161
+ graph with direct/transitive runtime, dev, optional and peer edges plus explicit resolution counts.
162
+ Duplicate evidence contains stable, source-free clone/divergence candidates; it never sends method
163
+ bodies or snippets. Use `payload_version: 2` only when the user explicitly wants graph-only
164
+ compatibility—Weavatrix never silently downgrades. A graph built before `0.1.4`, or one stale against
165
+ the working tree, must be rebuilt first. Sync remains unavailable until the user selects `hosted`
166
+ (or the exact `hosted` capability) and configures `WEAVATRIX_SYNC_URL`.
102
167
 
103
168
  ## Troubleshooting
104
169
 
105
- - `Graph unavailable` → `rebuild_graph`; `open_repo` can select another valid repository path unless
170
+ - `Graph unavailable` → `rebuild_graph`; normal graph/health calls automatically refresh an existing
171
+ graph and report `none`, `incremental`, or `full`. `open_repo` can select another valid repository path unless
106
172
  the registration deliberately omitted `retarget`.
107
173
  - `refresh_advisories` is unavailable → with the user's approval, re-register/reconfigure the MCP
108
- capability list to include `online`, reconnect it, and then invoke the tool. Do not enable network
174
+ with the `osv` profile, reconnect it, and then invoke the tool. Do not enable network
109
175
  access merely to turn `NOT_CHECKED` into a cosmetic green state.
176
+ - `pull_architecture_contract` / `sync_graph` is unavailable → use the `hosted` profile only after
177
+ the user chooses hosted integration; configure `WEAVATRIX_SYNC_URL` and a bearer token for contract
178
+ pull. Profile selection alone never performs a request.
110
179
  - `No coverage report` → run the repo's own tests with coverage (`vitest run --coverage`,
111
180
  `jest --coverage`, `pytest --cov --cov-report=json`, `go test -coverprofile=coverage.out`),
112
181
  then re-call.
@@ -0,0 +1,343 @@
1
+ // Executable target-architecture contract and no-regressions ratchet.
2
+ // The contract contains selectors and budgets only; verification is pure over graph metadata and never
3
+ // needs source bodies. Fingerprints deliberately exclude line numbers so edits do not churn baselines.
4
+ import {createHash} from 'node:crypto'
5
+ import {existsSync, readFileSync, writeFileSync, mkdirSync} from 'node:fs'
6
+ import {dirname, join} from 'node:path'
7
+ import {createRepoBoundary} from '../repo-path.js'
8
+
9
+ export const ARCHITECTURE_CONTRACT_V = 1
10
+ export const CONTRACT_PATHS = ['.weavatrix/architecture.json', '.weavatrix-architecture.json']
11
+
12
+ const RELATION_KIND = new Set(['runtime', 'type-only', 'compile-only', 'any'])
13
+ const ACTION = new Set(['allow', 'forbid'])
14
+ const ENFORCEMENT = new Set(['ratchet', 'strict', 'advisory'])
15
+ const safeId = (value, fallback = '') => {
16
+ const text = String(value ?? '').trim()
17
+ return /^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(text) ? text : fallback
18
+ }
19
+ const pathPrefix = (value) => {
20
+ const text = String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
21
+ return text && !text.split('/').some((part) => !part || part === '.' || part === '..') ? text : null
22
+ }
23
+ const stringList = (value, sanitize, cap = 100) => [...new Set((Array.isArray(value) ? value : [])
24
+ .slice(0, cap).map(sanitize).filter(Boolean))]
25
+ const finiteBudget = (value, fallback = null) => {
26
+ const number = Number(value)
27
+ return Number.isFinite(number) && number >= 0 ? number : fallback
28
+ }
29
+ const endpoint = (value) => value && typeof value === 'object' ? String(value.id) : String(value ?? '')
30
+ const isSymbol = (id) => String(id).includes('#')
31
+ const fileOf = (id, byId) => {
32
+ const node = byId.get(String(id))
33
+ if (node?.source_file) return String(node.source_file).replace(/\\/g, '/')
34
+ return String(id).split('#')[0].replace(/\\/g, '/')
35
+ }
36
+ const stable = (value) => Array.isArray(value)
37
+ ? `[${value.map(stable).join(',')}]`
38
+ : value && typeof value === 'object'
39
+ ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`
40
+ : JSON.stringify(value)
41
+ const hash = (value) => createHash('sha256').update(stable(value)).digest('hex')
42
+
43
+ function sanitizeComponent(value, index) {
44
+ const id = safeId(value?.id, `component-${index + 1}`)
45
+ const paths = stringList(value?.paths ?? [value?.path], pathPrefix, 64)
46
+ if (!paths.length) return null
47
+ return {id, name: String(value?.name || id).slice(0, 128), paths}
48
+ }
49
+
50
+ function sanitizeRule(value, index) {
51
+ const from = stringList(value?.from, (item) => item === '*' ? '*' : safeId(item), 64)
52
+ const to = stringList(value?.to, (item) => item === '*' ? '*' : safeId(item), 64)
53
+ if (!from.length || !to.length) return null
54
+ return {
55
+ id: safeId(value?.id, `dependency-${index + 1}`),
56
+ action: ACTION.has(value?.action) ? value.action : 'forbid',
57
+ kinds: stringList(value?.kinds ?? ['runtime'], (kind) => RELATION_KIND.has(kind) ? kind : '', 4),
58
+ from,
59
+ to,
60
+ ...(value?.reason ? {reason: String(value.reason).slice(0, 300)} : {}),
61
+ }
62
+ }
63
+
64
+ function sanitizeException(value) {
65
+ const fingerprint = /^[a-f0-9]{16,64}$/i.test(String(value?.fingerprint || '')) ? String(value.fingerprint) : null
66
+ if (!fingerprint) return null
67
+ const expires = /^\d{4}-\d{2}-\d{2}$/.test(String(value?.expires || '')) ? String(value.expires) : null
68
+ return {fingerprint, reason: String(value?.reason || '').slice(0, 300), ...(expires ? {expires} : {})}
69
+ }
70
+
71
+ export function normalizeArchitectureContract(input) {
72
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('architecture contract must be an object')
73
+ const components = (Array.isArray(input.components) ? input.components : [])
74
+ .slice(0, 200).map(sanitizeComponent).filter(Boolean)
75
+ const componentIds = new Set(components.map((item) => item.id))
76
+ const dependencyRules = (Array.isArray(input.dependencyRules) ? input.dependencyRules : [])
77
+ .slice(0, 500).map(sanitizeRule).filter(Boolean)
78
+ .filter((rule) => [...rule.from, ...rule.to].every((id) => id === '*' || componentIds.has(id)))
79
+ const rawBudgets = input.budgets && typeof input.budgets === 'object' ? input.budgets : {}
80
+ const budgets = {
81
+ runtimeCycles: finiteBudget(rawBudgets.runtimeCycles),
82
+ maxFunctionLoc: finiteBudget(rawBudgets.maxFunctionLoc),
83
+ maxFileLoc: finiteBudget(rawBudgets.maxFileLoc),
84
+ maxCyclomatic: finiteBudget(rawBudgets.maxCyclomatic),
85
+ maxParams: finiteBudget(rawBudgets.maxParams),
86
+ maxRuntimeDependenciesPerComponent: finiteBudget(rawBudgets.maxRuntimeDependenciesPerComponent),
87
+ maxModuleFiles: finiteBudget(rawBudgets.maxModuleFiles),
88
+ minModuleCohesion: finiteBudget(rawBudgets.minModuleCohesion),
89
+ maxModuleBoundaryRatio: finiteBudget(rawBudgets.maxModuleBoundaryRatio),
90
+ }
91
+ for (const key of Object.keys(budgets)) if (budgets[key] == null) delete budgets[key]
92
+ const technologies = {
93
+ required: stringList(input.technologies?.required, (item) => safeId(item), 100),
94
+ forbidden: stringList(input.technologies?.forbidden, (item) => safeId(item), 100),
95
+ }
96
+ const baseline = input.ratchet?.baseline && typeof input.ratchet.baseline === 'object'
97
+ ? {
98
+ fingerprints: stringList(input.ratchet.baseline.fingerprints, (item) => /^[a-f0-9]{16,64}$/i.test(String(item)) ? String(item) : '', 5_000),
99
+ metrics: Object.fromEntries(Object.entries(input.ratchet.baseline.metrics || {}).slice(0, 500)
100
+ .map(([key, value]) => [safeId(key), finiteBudget(value)]).filter(([key, value]) => key && value != null)),
101
+ }
102
+ : {fingerprints: [], metrics: {}}
103
+ const contract = {
104
+ architectureContractV: ARCHITECTURE_CONTRACT_V,
105
+ name: String(input.name || 'Target architecture').slice(0, 128),
106
+ style: safeId(input.style, 'custom'),
107
+ enforcement: ENFORCEMENT.has(input.enforcement) ? input.enforcement : 'ratchet',
108
+ components,
109
+ dependencyRules,
110
+ budgets,
111
+ technologies,
112
+ exceptions: (Array.isArray(input.exceptions) ? input.exceptions : []).slice(0, 500).map(sanitizeException).filter(Boolean),
113
+ ratchet: {baseline},
114
+ }
115
+ return {...contract, contractHash: hash(contract)}
116
+ }
117
+
118
+ export function loadArchitectureContract(repoRoot, graphPath) {
119
+ const boundary = createRepoBoundary(repoRoot)
120
+ for (const relative of CONTRACT_PATHS) {
121
+ const resolved = boundary.resolve(relative)
122
+ if (!resolved.ok || !existsSync(resolved.path)) continue
123
+ try { return {contract: normalizeArchitectureContract(JSON.parse(readFileSync(resolved.path, 'utf8'))), source: relative} }
124
+ catch (error) { return {contract: null, source: relative, error: error.message} }
125
+ }
126
+ const cached = graphPath ? join(dirname(graphPath), 'architecture.contract.json') : null
127
+ if (cached && existsSync(cached)) {
128
+ try { return {contract: normalizeArchitectureContract(JSON.parse(readFileSync(cached, 'utf8'))), source: 'hosted-cache'} }
129
+ catch (error) { return {contract: null, source: 'hosted-cache', error: error.message} }
130
+ }
131
+ return {contract: null, source: null, error: null}
132
+ }
133
+
134
+ export function writeCachedArchitectureContract(graphPath, input) {
135
+ if (!graphPath) throw new Error('graph path is required for hosted contract cache')
136
+ const contract = normalizeArchitectureContract(input)
137
+ const path = join(dirname(graphPath), 'architecture.contract.json')
138
+ mkdirSync(dirname(path), {recursive: true})
139
+ writeFileSync(path, JSON.stringify(contract, null, 2), 'utf8')
140
+ return {path, contract}
141
+ }
142
+
143
+ function componentFor(file, components) {
144
+ const normalized = String(file || '').replace(/\\/g, '/')
145
+ let best = null
146
+ for (const component of components) for (const prefix of component.paths) {
147
+ if (normalized !== prefix && !normalized.startsWith(`${prefix}/`)) continue
148
+ if (!best || prefix.length > best.prefix.length) best = {id: component.id, prefix}
149
+ }
150
+ return best?.id || '(unmapped)'
151
+ }
152
+
153
+ function relationKind(link) {
154
+ if (link.typeOnly === true) return 'type-only'
155
+ if (link.compileOnly === true) return 'compile-only'
156
+ return 'runtime'
157
+ }
158
+
159
+ function runtimeFileGraph(graph) {
160
+ const byId = new Map((graph.nodes || []).map((node) => [String(node.id), node]))
161
+ const files = new Set((graph.nodes || []).filter((node) => !isSymbol(node.id)).map((node) => String(node.id)))
162
+ const adjacency = new Map([...files].map((file) => [file, new Set()]))
163
+ for (const link of graph.links || []) {
164
+ if (relationKind(link) !== 'runtime' || !['imports', 're_exports'].includes(link.relation) || link.barrelProxy === true) continue
165
+ const source = fileOf(endpoint(link.source), byId)
166
+ const target = fileOf(endpoint(link.target), byId)
167
+ if (source && target && source !== target && files.has(source) && files.has(target)) adjacency.get(source)?.add(target)
168
+ }
169
+ return adjacency
170
+ }
171
+
172
+ function stronglyConnected(adjacency) {
173
+ let index = 0
174
+ const indexes = new Map(), low = new Map(), stack = [], onStack = new Set(), out = []
175
+ const visit = (node) => {
176
+ indexes.set(node, index); low.set(node, index); index++; stack.push(node); onStack.add(node)
177
+ for (const target of adjacency.get(node) || []) {
178
+ if (!indexes.has(target)) { visit(target); low.set(node, Math.min(low.get(node), low.get(target))) }
179
+ else if (onStack.has(target)) low.set(node, Math.min(low.get(node), indexes.get(target)))
180
+ }
181
+ if (low.get(node) !== indexes.get(node)) return
182
+ const component = []
183
+ while (stack.length) { const value = stack.pop(); onStack.delete(value); component.push(value); if (value === node) break }
184
+ if (component.length > 1 || adjacency.get(node)?.has(node)) out.push(component.sort())
185
+ }
186
+ for (const node of [...adjacency.keys()].sort()) if (!indexes.has(node)) visit(node)
187
+ return out.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]))
188
+ }
189
+
190
+ function violation(ruleId, kind, evidence, current, target) {
191
+ const normalizedEvidence = String(evidence).replace(/:\d+(?=\b|$)/g, '')
192
+ const fingerprint = hash({ruleId, kind, evidence: normalizedEvidence}).slice(0, 32)
193
+ return {fingerprint, ruleId, kind, evidence, ...(current != null ? {current} : {}), ...(target != null ? {target} : {})}
194
+ }
195
+
196
+ function matchSelector(selector, value) { return selector.includes('*') || selector.includes(value) }
197
+
198
+ export function verifyArchitecture({graph, contract: rawContract, technologies = []}) {
199
+ const contract = rawContract?.contractHash ? rawContract : normalizeArchitectureContract(rawContract)
200
+ const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
201
+ const links = Array.isArray(graph?.links) ? graph.links : []
202
+ const byId = new Map(nodes.map((node) => [String(node.id), node]))
203
+ const exceptions = new Set(contract.exceptions
204
+ .filter((item) => !item.expires || item.expires >= new Date().toISOString().slice(0, 10))
205
+ .map((item) => item.fingerprint))
206
+ const violations = []
207
+ const componentEdges = new Map()
208
+ for (const link of links) {
209
+ if (!['imports', 're_exports', 'calls', 'references'].includes(link.relation) || link.barrelProxy === true) continue
210
+ const fromFile = fileOf(endpoint(link.source), byId), toFile = fileOf(endpoint(link.target), byId)
211
+ if (!fromFile || !toFile || fromFile === toFile) continue
212
+ const from = componentFor(fromFile, contract.components), to = componentFor(toFile, contract.components)
213
+ if (from === to) continue
214
+ const kind = relationKind(link)
215
+ const key = `${from}\0${to}\0${kind}`
216
+ const record = componentEdges.get(key) || {from, to, kind, count: 0, samples: []}
217
+ record.count++
218
+ if (record.samples.length < 5) record.samples.push(`${fromFile} -> ${toFile}`)
219
+ componentEdges.set(key, record)
220
+ }
221
+ for (const edge of componentEdges.values()) {
222
+ const applicable = contract.dependencyRules.filter((rule) =>
223
+ rule.kinds.some((kind) => kind === 'any' || kind === edge.kind) && matchSelector(rule.from, edge.from))
224
+ for (const rule of applicable) {
225
+ if (rule.action === 'forbid' && matchSelector(rule.to, edge.to)) {
226
+ violations.push(violation(rule.id, 'dependency', `${edge.from} -> ${edge.to} (${edge.kind})`, edge.count, 0))
227
+ }
228
+ }
229
+ const allowRules = applicable.filter((rule) => rule.action === 'allow')
230
+ if (allowRules.length && !allowRules.some((rule) => matchSelector(rule.to, edge.to))) {
231
+ const ruleId = allowRules.map((rule) => rule.id).sort().join('+')
232
+ violations.push(violation(ruleId, 'dependency', `${edge.from} -> ${edge.to} (${edge.kind}; outside allow-list)`, edge.count, 0))
233
+ }
234
+ }
235
+ const runtimeCycles = stronglyConnected(runtimeFileGraph(graph))
236
+ if (contract.budgets.runtimeCycles != null && runtimeCycles.length > contract.budgets.runtimeCycles) {
237
+ violations.push(violation('budget.runtimeCycles', 'budget', `runtime cycles: ${runtimeCycles.length}`, runtimeCycles.length, contract.budgets.runtimeCycles))
238
+ }
239
+ const componentRuntimeTargets = new Map()
240
+ for (const edge of componentEdges.values()) if (edge.kind === 'runtime') {
241
+ const targets = componentRuntimeTargets.get(edge.from) || new Set(); targets.add(edge.to); componentRuntimeTargets.set(edge.from, targets)
242
+ }
243
+ if (contract.budgets.maxRuntimeDependenciesPerComponent != null) for (const [component, targets] of componentRuntimeTargets) {
244
+ if (targets.size > contract.budgets.maxRuntimeDependenciesPerComponent) violations.push(violation(
245
+ 'budget.maxRuntimeDependenciesPerComponent', 'budget', `${component} runtime dependencies`, targets.size,
246
+ contract.budgets.maxRuntimeDependenciesPerComponent,
247
+ ))
248
+ }
249
+ const componentStats = new Map(contract.components.map((component) => [component.id, {
250
+ files: new Set(), internalPairs: new Set(), boundaryPairs: new Set(),
251
+ }]))
252
+ for (const node of nodes.filter((item) => !isSymbol(item.id))) {
253
+ const file = String(node.source_file || node.id).replace(/\\/g, '/')
254
+ const component = componentFor(file, contract.components)
255
+ componentStats.get(component)?.files.add(file)
256
+ }
257
+ const runtimePairs = new Set()
258
+ for (const link of links) {
259
+ if (relationKind(link) !== 'runtime' || !['imports', 're_exports', 'calls', 'references'].includes(link.relation) || link.barrelProxy === true) continue
260
+ const source = fileOf(endpoint(link.source), byId), target = fileOf(endpoint(link.target), byId)
261
+ if (!source || !target || source === target) continue
262
+ const pair = `${source}\0${target}`
263
+ if (runtimePairs.has(pair)) continue
264
+ runtimePairs.add(pair)
265
+ const from = componentFor(source, contract.components), to = componentFor(target, contract.components)
266
+ if (from === to) componentStats.get(from)?.internalPairs.add(pair)
267
+ else {
268
+ componentStats.get(from)?.boundaryPairs.add(pair)
269
+ componentStats.get(to)?.boundaryPairs.add(pair)
270
+ }
271
+ }
272
+ for (const [component, stats] of componentStats) {
273
+ const internal = stats.internalPairs.size, boundary = stats.boundaryPairs.size
274
+ const cohesion = internal + boundary ? internal / (internal + boundary) : 1
275
+ const boundaryRatio = internal + boundary ? boundary / (internal + boundary) : 0
276
+ if (contract.budgets.maxModuleFiles != null && stats.files.size > contract.budgets.maxModuleFiles) violations.push(violation('budget.maxModuleFiles', 'budget', `${component} files`, stats.files.size, contract.budgets.maxModuleFiles))
277
+ if (contract.budgets.minModuleCohesion != null && cohesion < contract.budgets.minModuleCohesion) violations.push(violation('budget.minModuleCohesion', 'budget', `${component} cohesion`, Number(cohesion.toFixed(4)), contract.budgets.minModuleCohesion))
278
+ if (contract.budgets.maxModuleBoundaryRatio != null && boundaryRatio > contract.budgets.maxModuleBoundaryRatio) violations.push(violation('budget.maxModuleBoundaryRatio', 'budget', `${component} boundary ratio`, Number(boundaryRatio.toFixed(4)), contract.budgets.maxModuleBoundaryRatio))
279
+ }
280
+ for (const node of nodes) {
281
+ const loc = finiteBudget(node?.complexity?.loc)
282
+ const cyclomatic = finiteBudget(node?.complexity?.cyclomatic)
283
+ const params = finiteBudget(node?.complexity?.params)
284
+ const evidence = `${node.source_file || fileOf(node.id, byId)}#${node.label || node.id}`
285
+ if (contract.budgets.maxFunctionLoc != null && loc != null && loc > contract.budgets.maxFunctionLoc) violations.push(violation('budget.maxFunctionLoc', 'budget', evidence, loc, contract.budgets.maxFunctionLoc))
286
+ if (contract.budgets.maxCyclomatic != null && cyclomatic != null && cyclomatic > contract.budgets.maxCyclomatic) violations.push(violation('budget.maxCyclomatic', 'budget', evidence, cyclomatic, contract.budgets.maxCyclomatic))
287
+ if (contract.budgets.maxParams != null && params != null && params > contract.budgets.maxParams) violations.push(violation('budget.maxParams', 'budget', evidence, params, contract.budgets.maxParams))
288
+ }
289
+ if (contract.budgets.maxFileLoc != null) for (const node of nodes.filter((item) => !isSymbol(item.id))) {
290
+ const file = String(node.source_file || node.id)
291
+ const maxEnd = nodes.filter((item) => item.source_file === file && isSymbol(item.id))
292
+ .reduce((max, item) => Math.max(max, Number(String(item.source_end || '').replace(/^L/, '')) || 0), 0)
293
+ if (maxEnd > contract.budgets.maxFileLoc) violations.push(violation('budget.maxFileLoc', 'budget', file, maxEnd, contract.budgets.maxFileLoc))
294
+ }
295
+ const techSet = new Set(technologies.map((item) => safeId(item)).filter(Boolean))
296
+ for (const required of contract.technologies.required) if (!techSet.has(required)) violations.push(violation('technology.required', 'technology', `missing ${required}`))
297
+ for (const forbidden of contract.technologies.forbidden) if (techSet.has(forbidden)) violations.push(violation('technology.forbidden', 'technology', `forbidden ${forbidden}`))
298
+
299
+ const active = violations.filter((item) => !exceptions.has(item.fingerprint))
300
+ const baseline = new Set(contract.ratchet.baseline.fingerprints)
301
+ const current = new Set(active.map((item) => item.fingerprint))
302
+ const fresh = active.filter((item) => !baseline.has(item.fingerprint))
303
+ const existing = active.filter((item) => baseline.has(item.fingerprint))
304
+ const fixed = [...baseline].filter((fingerprint) => !current.has(fingerprint))
305
+ const status = contract.enforcement === 'advisory'
306
+ ? 'ADVISORY'
307
+ : contract.enforcement === 'strict'
308
+ ? (active.length ? 'FAIL' : 'PASS')
309
+ : (fresh.length ? 'FAIL' : 'PASS')
310
+ const metrics = {
311
+ runtimeCycles: runtimeCycles.length,
312
+ violations: active.length,
313
+ componentDependencies: componentEdges.size,
314
+ mappedComponents: contract.components.length,
315
+ componentFitness: Object.fromEntries([...componentStats].map(([component, stats]) => {
316
+ const internal = stats.internalPairs.size, boundary = stats.boundaryPairs.size
317
+ return [component, {files: stats.files.size, cohesion: internal + boundary ? Number((internal / (internal + boundary)).toFixed(4)) : 1, boundaryRatio: internal + boundary ? Number((boundary / (internal + boundary)).toFixed(4)) : 0}]
318
+ })),
319
+ }
320
+ const result = {
321
+ architectureVerificationV: 1,
322
+ contractHash: contract.contractHash,
323
+ status,
324
+ enforcement: contract.enforcement,
325
+ metrics,
326
+ new: fresh,
327
+ existing,
328
+ fixed,
329
+ excepted: violations.filter((item) => exceptions.has(item.fingerprint)),
330
+ componentEdges: [...componentEdges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)),
331
+ runtimeCycles: runtimeCycles.slice(0, 100),
332
+ }
333
+ return {...result, verificationHash: hash(result)}
334
+ }
335
+
336
+ export function contractForChange(contract, files = []) {
337
+ const normalized = rawArray(files).map(pathPrefix).filter(Boolean)
338
+ const components = [...new Set(normalized.map((file) => componentFor(file, contract.components)))]
339
+ const rules = contract.dependencyRules.filter((rule) => components.some((component) => matchSelector(rule.from, component) || matchSelector(rule.to, component)))
340
+ return {files: normalized, components, rules, budgets: contract.budgets, technologies: contract.technologies}
341
+ }
342
+
343
+ function rawArray(value) { return Array.isArray(value) ? value.slice(0, 200) : [] }