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
@@ -26,14 +26,28 @@ const PROFILE_CAPS = Object.freeze({
26
26
 
27
27
  // The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
28
28
  // loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
29
- export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-endpoints.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'tools-verified-change.mjs', 'catalog.mjs']
29
+ const HOT_FACADES = [
30
+ 'tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs',
31
+ 'tools-context.mjs', 'tools-endpoints.mjs', 'tools-actions.mjs',
32
+ 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs',
33
+ 'tools-verified-change.mjs',
34
+ ]
35
+ const HOT_OWNERS = [
36
+ 'graph/tools-core.mjs', 'graph/tools-query.mjs', 'tools-graph-hubs.mjs',
37
+ 'health/duplicates.mjs', 'health/dead-code.mjs', 'health/audit-format.mjs',
38
+ 'health/audit.mjs', 'health/structure.mjs', 'health/endpoints.mjs',
39
+ 'actions/graph-lifecycle.mjs', 'actions/advisories.mjs',
40
+ 'actions/hosted-architecture.mjs', 'actions/graph-sync.mjs',
41
+ 'architecture-starter.mjs', 'architecture-bootstrap.mjs',
42
+ ]
43
+ export const HOT_FILES = [...HOT_FACADES, ...HOT_OWNERS, 'catalog.mjs']
30
44
 
31
45
  function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
32
46
  const tools = [
33
47
  {cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
34
48
  {cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
35
49
  {cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
36
- {cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned; a code-shaped identifier uses only exact same-name declarations. Classified paths and unreferenced constant/field leaves are suppressed unless the question or explicit flags request them. Reports policy and suppression instead of silently flooding the result.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
50
+ {cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept or exact symbols (BFS/DFS). Exact seed files/symbols stay pinned; relation_filter and flow_direction support bounded event/data-flow views without a separate tool. Classified paths and unreferenced constant/field leaves stay suppressed unless explicitly requested.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Optional natural-language question or keyword search when exact seeds are not sufficient'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact repo-relative file paths. Resolved exact seeds remain pinned unless augment_seeds is true'}, seed_symbols: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact node IDs or unambiguous symbol labels; enables focused flows without fuzzy query seeds'}, relation_filter: {oneOf: [{type: 'array', items: {type: 'string'}}, {type: 'string'}], description: 'Optional relation allow-list, e.g. calls,references,imports'}, flow_direction: {type: 'string', enum: ['forward', 'backward', 'both'], default: 'both', description: 'Traverse outgoing, incoming, or both directions'}, augment_seeds: {type: 'boolean', default: false, description: 'With exact seeds, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, anyOf: [{required: ['question']}, {required: ['seed_files']}, {required: ['seed_symbols']}]}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
37
51
  {cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
38
52
  {cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
39
53
  {cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
@@ -105,19 +119,19 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
105
119
  {cap: 'search', name: 'search_code', description: 'Full-text or regex search across the repo source (ripgrep-backed, Node fallback). The graph only stores structure — use this to find literal text/patterns, then get_node/get_neighbors for structure.', inputSchema: {type: 'object', properties: {query: {type: 'string', description: 'text or regex to search for'}, is_regex: {type: 'boolean', default: false}, glob: {type: 'string', description: 'optional path glob, e.g. "*.js" or "src/**"'}, max_results: {type: 'integer', default: 40}}, required: ['query']}, run: (g, a, ctx) => searchCode({repoRoot: ctx.repoRoot, resolveRg}, a)},
106
120
  {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
107
121
  {cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
108
- {cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, aggregated inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and a few focused source excerpts. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
122
+ {cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, production-first inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and diverse excerpts around call sites. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp callers after production callers'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
109
123
  {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, classified non-product paths, and all-router framework boilerplate clone groups are excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_boilerplate: {type: 'boolean', default: false, description: 'Include clone groups made entirely of conventional *.router.js/ts router symbols'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
110
124
  {cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
111
- {cap: 'health', name: 'run_audit', description: 'Core production-first repository Health review: structure, dependency declarations, unused surfaces and explicitly bounded supply-chain evidence. Findings whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded are suppressed by default; opt them in with include_classified. category=dependencies is a dedicated dependency-health projection across missing, unused and duplicate declarations while preserving each finding\'s native category. With base_ref, builds and audits an immutable Git checkout and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['dependencies', 'unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category; dependencies selects dependency manifest/import findings across native categories'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_classified: {type: 'boolean', default: false, description: 'Include findings whose evidence is entirely tests/e2e/generated/mocks/stories/docs/benchmarks/temp or explicitly excluded'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
125
+ {cap: 'health', name: 'run_audit', description: 'Core production-first repository Health review with an explicit capability/completeness matrix for structure, dependencies, bounded runtime-correctness/concurrency patterns, advisories, malware and coverage. Unsupported Maven/Gradle import verification is NOT_SUPPORTED/PARTIAL, never a clean zero. Findings whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded are suppressed by default; opt them in with include_classified. category=dependencies is a dedicated dependency-health projection across missing, unused and duplicate declarations while preserving each finding\'s native category. With base_ref, builds and audits an immutable Git checkout and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['dependencies', 'unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category; dependencies selects dependency manifest/import findings across native categories'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_classified: {type: 'boolean', default: false, description: 'Include findings whose evidence is entirely tests/e2e/generated/mocks/stories/docs/benchmarks/temp or explicitly excluded'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
112
126
  {cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
113
127
  {cap: 'health', name: 'hot_path_review', description: 'Rank a focused production-symbol hot-path queue from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. The default score gate is 85 with a narrow strong-local fallback; set min_score=0 for the full diagnostic queue. This is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 85, description: 'Focused default is 85; lower explicitly to broaden, or use 0 for every threshold-matching diagnostic candidate'}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
114
128
  {cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
115
129
  {cap: 'graph', name: 'module_map', description: 'First orientation view for understanding an unfamiliar application with little context: a production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
116
- {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): declared and reachable composed paths, static mount provenance, confidence, handler, and file:line.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
130
+ {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): declared and reachable composed paths, static mount provenance, confidence, handler, file:line, and Spring conditional/default-active state.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
117
131
  {cap: 'source', refreshGraph: true, name: 'trace_endpoint', description: 'Resolve one exact reachable HTTP endpoint, prove its router mount chain, bind its handler symbol, and return a bounded production-only multi-hop call graph with call-site excerpts. This is a focused projection of the repository graph, not text-search inference.', inputSchema: {type: 'object', properties: {path: {type: 'string', minLength: 1, maxLength: 2048, description: 'Exact composed path; a suffix is accepted only when unambiguous'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, max_depth: {type: 'integer', minimum: 1, maximum: 4, default: 3}, max_nodes: {type: 'integer', minimum: 2, maximum: 40, default: 20}, max_excerpts: {type: 'integer', minimum: 0, maximum: 12, default: 6}, context_lines: {type: 'integer', minimum: 0, maximum: 6, default: 2}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}, required: ['path']}, run: (g, a, ctx) => te.tTraceEndpoint(g, a, ctx)},
118
132
  {cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. Omitted mode/precision preserve the active graph; a first build uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). The local TypeScript/JavaScript LSP overlay validates bounded ambiguous edges; precision:off is an explicit fallback. With scope, build an isolated diagnostic graph without replacing or diffing the full graph.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Build mode; omit to preserve the active mode (or use full for a first build)'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Semantic precision; omit to preserve the active mode (or use the startup setting for a first build)'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
119
133
  {cap: 'graph', name: 'graph_diff', description: 'Structural graph diff: compare the current graph with an immutable Git-ref baseline (base_ref such as HEAD~1 or main), or with graph.prev.json from the last rebuild when base_ref is omitted. Reports architecture drift, cycle changes and symbols that lost their last caller.', inputSchema: {type: 'object', properties: {base_ref: {type: 'string', maxLength: 256, description: 'Optional immutable Git baseline to build in isolation, e.g. HEAD~1, main or origin/main; never checks out or mutates the working tree'}, path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
120
- {cap: 'graph', name: 'get_architecture_contract', description: 'Start the intended-architecture workflow: return the executable owner-approved target, quality budgets and ratchet mode that an agent must follow before editing. When absent, returns a starter proposal rather than silently inventing policy.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
134
+ {cap: 'graph', name: 'get_architecture_contract', description: 'Read the owner-approved architecture target or safely bootstrap one. With action=preview, returns an adaptive candidate, observed-but-not-enforced dependency directions, verification, exact file content/hash and a short-lived confirmation token. action=approve creates the local contract only after explicit token confirmation and never overwrites an active target.', inputSchema: {type: 'object', properties: {action: {type: 'string', enum: ['preview', 'approve'], description: 'Omit to read; preview is dry-run only; approve requires the preview token'}, candidate_contract: {type: 'object', description: 'Optional reviewed candidate to normalize and verify during preview'}, baseline_mode: {type: 'string', enum: ['none', 'accept-current'], default: 'none', description: 'Whether preview should materialize current violations as an explicit ratchet baseline'}, confirm_token: {type: 'string', description: 'One-time token returned by preview; required for approve'}}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
121
135
  {cap: 'graph', name: 'prepare_change', description: 'Select active target-architecture rules for an intended set of changed files. Run before a non-trivial edit.', inputSchema: {type: 'object', properties: {intent: {type: 'string'}, files: {type: 'array', items: {type: 'string'}, maxItems: 200}}, required: ['files']}, run: (g, a, ctx) => tar.tPrepareChange(g, a, ctx)},
122
136
  {cap: 'health', name: 'verify_architecture', description: 'Verify the fresh graph against the active target contract and ratchet; separates new, existing, fixed and excepted debt.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tVerifyArchitecture(g, a, ctx)},
123
137
  {cap: 'health', name: 'explain_architecture_violation', description: 'Explain one active architecture violation and the governing rule.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}}, required: ['fingerprint']}, run: (g, a, ctx) => tar.tExplainArchitectureViolation(g, a, ctx)},
@@ -169,6 +183,7 @@ export async function loadHotApi(version, capsArg) {
169
183
  import(new URL(`./tools-verified-change.mjs${v}`, import.meta.url).href),
170
184
  ])
171
185
  const raw = capsArg == null ? 'offline' : String(capsArg).trim()
186
+ const profile = Object.hasOwn(PROFILE_CAPS, raw) ? raw : 'custom'
172
187
  const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
173
188
  .flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
174
189
  const caps = new Set(selected)
@@ -178,6 +193,7 @@ export async function loadHotApi(version, capsArg) {
178
193
  tools,
179
194
  byName: new Map(tools.map((t) => [t.name, t])),
180
195
  caps,
196
+ profile,
181
197
  stalenessLine,
182
198
  resetStalenessCache,
183
199
  }
@@ -0,0 +1,209 @@
1
+ import {compareText, safeToken, STATE} from '../evidence-snapshot.common.mjs'
2
+ import {
3
+ MAX_DEPENDENCY_DECLARATIONS, MAX_PACKAGE_RECORDS, PACKAGE_NAME,
4
+ dependencyDeclarations, emptyDeclarations, emptyGraph, finalizeGraph,
5
+ packageId, packageVersion,
6
+ } from './package-graph-common.mjs'
7
+
8
+ // Bun >= 1.2 writes JSONC: JSON plus comments and trailing commas.
9
+ export function parseJsonc(text) {
10
+ const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
11
+ const out = []
12
+ let i = 0
13
+ const length = source.length
14
+ const skipComment = (index) => {
15
+ if (source[index] !== '/') return index
16
+ if (source[index + 1] === '/') {
17
+ let cursor = index + 2
18
+ while (cursor < length && source[cursor] !== '\n') cursor++
19
+ return cursor
20
+ }
21
+ if (source[index + 1] === '*') {
22
+ let cursor = index + 2
23
+ while (cursor < length && !(source[cursor] === '*' && source[cursor + 1] === '/')) cursor++
24
+ return Math.min(cursor + 2, length)
25
+ }
26
+ return index
27
+ }
28
+ while (i < length) {
29
+ const char = source[i]
30
+ if (char === '"') {
31
+ const start = i
32
+ i++
33
+ while (i < length && source[i] !== '"') i += source[i] === '\\' ? 2 : 1
34
+ i = Math.min(i + 1, length)
35
+ out.push(source.slice(start, i))
36
+ continue
37
+ }
38
+ if (char === '/') {
39
+ const next = skipComment(i)
40
+ if (next !== i) { i = next; continue }
41
+ }
42
+ if (char === ',') {
43
+ let ahead = i + 1
44
+ while (ahead < length) {
45
+ if (/\s/.test(source[ahead])) { ahead++; continue }
46
+ const next = skipComment(ahead)
47
+ if (next !== ahead) { ahead = next; continue }
48
+ break
49
+ }
50
+ if (ahead < length && (source[ahead] === '}' || source[ahead] === ']')) { i++; continue }
51
+ }
52
+ out.push(char)
53
+ i++
54
+ }
55
+ return JSON.parse(out.join(''))
56
+ }
57
+
58
+ function bunKeySegments(key) {
59
+ if (typeof key !== 'string' || key.length === 0 || key.length > 4096 || /[\u0000-\u001f\u007f]/.test(key)) return null
60
+ const parts = key.split('/')
61
+ const segments = []
62
+ for (let index = 0; index < parts.length; index++) {
63
+ let segment = parts[index]
64
+ if (segment.startsWith('@')) {
65
+ if (index + 1 >= parts.length) return null
66
+ segment = `${segment}/${parts[++index]}`
67
+ }
68
+ if (segment.length > 256 || !PACKAGE_NAME.test(segment)) return null
69
+ segments.push(segment)
70
+ }
71
+ return segments
72
+ }
73
+
74
+ function bunPackageRecord(entry) {
75
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string' || entry[0].length > 1024) return null
76
+ const at = entry[0].lastIndexOf('@')
77
+ if (at <= 0) return null
78
+ const name = entry[0].slice(0, at)
79
+ if (name.length > 256 || !PACKAGE_NAME.test(name)) return null
80
+ const rawVersion = entry[0].slice(at + 1)
81
+ const meta = entry.slice(1).find((value) => value && typeof value === 'object' && !Array.isArray(value)) || {}
82
+ return {name, rawVersion, workspace: rawVersion.startsWith('workspace:'), meta}
83
+ }
84
+
85
+ function bunDeclarationRecord(meta) {
86
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {}
87
+ const peerDependenciesMeta = {}
88
+ if (Array.isArray(meta.optionalPeers)) {
89
+ for (const name of meta.optionalPeers) if (typeof name === 'string') peerDependenciesMeta[name] = {optional: true}
90
+ }
91
+ return {
92
+ dependencies: meta.dependencies,
93
+ devDependencies: meta.devDependencies,
94
+ optionalDependencies: meta.optionalDependencies,
95
+ peerDependencies: meta.peerDependencies,
96
+ peerDependenciesMeta,
97
+ }
98
+ }
99
+
100
+ function resolveBunDependency(sourceSegments, name, records) {
101
+ for (let depth = sourceSegments.length; depth >= 0; depth--) {
102
+ const key = [...sourceSegments.slice(0, depth), name].join('/')
103
+ const record = records.get(key)
104
+ if (record) return {key, record}
105
+ }
106
+ return null
107
+ }
108
+
109
+ function applyBunReachabilityFlags(nodes, edges) {
110
+ const adjacency = new Map()
111
+ for (const edge of edges) {
112
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, [])
113
+ adjacency.get(edge.from).push(edge)
114
+ }
115
+ const reach = (excluded) => {
116
+ const seen = new Set(['(root)'])
117
+ const queue = ['(root)']
118
+ while (queue.length) {
119
+ for (const edge of adjacency.get(queue.pop()) || []) {
120
+ if (excluded.has(edge.kind) || seen.has(edge.to)) continue
121
+ seen.add(edge.to)
122
+ queue.push(edge.to)
123
+ }
124
+ }
125
+ return seen
126
+ }
127
+ const withoutDev = reach(new Set(['dev']))
128
+ const withoutOptional = reach(new Set(['optional', 'optional-peer']))
129
+ const withoutPeer = reach(new Set(['peer', 'optional-peer']))
130
+ for (const node of nodes) {
131
+ node.dev = !withoutDev.has(node.id)
132
+ node.optional = !withoutOptional.has(node.id)
133
+ node.peer = !withoutPeer.has(node.id)
134
+ }
135
+ }
136
+
137
+ export function parseBunLock(lock, lockfile) {
138
+ const lockfileVersion = Number(lock?.lockfileVersion)
139
+ const extras = {lockfile, lockfileVersion: Number.isFinite(lockfileVersion) ? Math.trunc(lockfileVersion) : 0}
140
+ if (!lock?.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
141
+ return emptyGraph(STATE.PARTIAL, 'BUN_LOCK_PACKAGES_REQUIRED', extras)
142
+ }
143
+ const allKeys = Object.keys(lock.packages).sort(compareText)
144
+ const selectedKeys = allKeys.slice(0, MAX_PACKAGE_RECORDS)
145
+ const reasons = []
146
+ if (selectedKeys.length < allKeys.length) reasons.push('LOCKFILE_PACKAGE_RECORD_LIMIT_REACHED')
147
+ const records = new Map()
148
+ let invalidRecords = 0
149
+ for (const key of selectedKeys) {
150
+ const segments = bunKeySegments(key)
151
+ const record = segments ? bunPackageRecord(lock.packages[key]) : null
152
+ if (!record) { invalidRecords++; continue }
153
+ records.set(key, {...record, segments})
154
+ }
155
+ if (invalidRecords > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_RECORDS')
156
+ const externalNodes = new Map()
157
+ let invalidPackageVersions = 0
158
+ for (const [key, record] of records) {
159
+ if (record.workspace) continue
160
+ const version = packageVersion(record.rawVersion)
161
+ if (!version) { invalidPackageVersions++; continue }
162
+ externalNodes.set(key, {
163
+ id: packageId(record.name, version, key), name: record.name, version,
164
+ direct: false, dev: false, optional: false, peer: false,
165
+ })
166
+ }
167
+ if (invalidPackageVersions > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_VERSIONS')
168
+ const workspaces = lock?.workspaces && typeof lock.workspaces === 'object' && !Array.isArray(lock.workspaces)
169
+ ? lock.workspaces : null
170
+ if (!workspaces) reasons.push('BUN_LOCK_WORKSPACES_MISSING')
171
+ const sources = []
172
+ for (const path of Object.keys(workspaces || {}).sort(compareText)) {
173
+ const meta = workspaces[path]
174
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue
175
+ const workspaceName = path === '' ? null : safeToken(meta.name)
176
+ const segments = workspaceName && records.has(workspaceName) ? [workspaceName] : []
177
+ sources.push({sourceId: '(root)', segments, declarationRecord: bunDeclarationRecord(meta)})
178
+ }
179
+ for (const [key, record] of [...records].sort(([a], [b]) => compareText(a, b))) {
180
+ const node = externalNodes.get(key)
181
+ if (node) sources.push({sourceId: node.id, segments: record.segments, declarationRecord: bunDeclarationRecord(record.meta)})
182
+ }
183
+ const edgeMap = new Map()
184
+ const declarations = emptyDeclarations()
185
+ let declarationLimitReached = false
186
+ outer: for (const source of sources) {
187
+ for (const declaration of dependencyDeclarations(source.declarationRecord)) {
188
+ if (declarations.total >= MAX_DEPENDENCY_DECLARATIONS) { declarationLimitReached = true; break outer }
189
+ declarations.total++
190
+ const resolved = resolveBunDependency(source.segments, declaration.name, records)
191
+ if (resolved?.record.workspace) { declarations.local++; continue }
192
+ const node = resolved ? externalNodes.get(resolved.key) : null
193
+ if (!node) {
194
+ if (declaration.kind === 'optional' || declaration.kind === 'optional-peer') declarations.optionalMissing++
195
+ else declarations.unresolved++
196
+ continue
197
+ }
198
+ declarations.resolved++
199
+ if (source.sourceId === '(root)') node.direct = true
200
+ if (source.sourceId === node.id) continue
201
+ const edge = {from: source.sourceId, to: node.id, kind: declaration.kind}
202
+ edgeMap.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge)
203
+ }
204
+ }
205
+ if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
206
+ if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
207
+ applyBunReachabilityFlags(externalNodes.values(), edgeMap.values())
208
+ return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion: extras.lockfileVersion})
209
+ }
@@ -0,0 +1,115 @@
1
+ import {createHash} from 'node:crypto'
2
+ import {CAPS, STATE, compareText, safeToken} from '../evidence-snapshot.common.mjs'
3
+
4
+ export const MAX_LOCKFILE_BYTES = 64 * 1024 * 1024
5
+ export const MAX_PACKAGE_RECORDS = 50_000
6
+ export const MAX_DEPENDENCY_DECLARATIONS = 200_000
7
+ export const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/i
8
+ const PACKAGE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/
9
+
10
+ export const packageVersion = (value) => {
11
+ const version = safeToken(value, 128)
12
+ return version && PACKAGE_VERSION.test(version) ? version : null
13
+ }
14
+
15
+ export const emptyDeclarations = () => ({total: 0, resolved: 0, unresolved: 0, local: 0, optionalMissing: 0})
16
+
17
+ export function emptyGraph(state, reason, extras = {}) {
18
+ const emptyCount = () => ({total: 0, returned: 0, truncated: false})
19
+ return {
20
+ state,
21
+ ecosystem: 'npm',
22
+ root: '(root)',
23
+ ...extras,
24
+ completeness: {
25
+ nodes: emptyCount(),
26
+ edges: emptyCount(),
27
+ declarations: emptyDeclarations(),
28
+ reasons: reason ? [reason] : [],
29
+ },
30
+ nodes: [],
31
+ edges: [],
32
+ }
33
+ }
34
+
35
+ export function normalizeLockPath(value) {
36
+ if (typeof value !== 'string' || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) return null
37
+ const raw = value.replace(/\\/g, '/')
38
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(raw)) return null
39
+ const normalized = raw.replace(/\/$/, '')
40
+ if (normalized === '') return ''
41
+ const parts = normalized.split('/')
42
+ return parts.every((part) => part && part !== '.' && part !== '..') ? normalized : null
43
+ }
44
+
45
+ export function packageNameFromPath(packagePath) {
46
+ const match = packagePath.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/)
47
+ const name = match?.[1]
48
+ return name && name.length <= 256 && PACKAGE_NAME.test(name) ? name : null
49
+ }
50
+
51
+ export function packageId(name, version, packagePath) {
52
+ const location = createHash('sha256').update(packagePath).digest('hex').slice(0, 12)
53
+ return `npm:${name}@${version}:${location}`
54
+ }
55
+
56
+ export function parentInstallPath(packagePath) {
57
+ const marker = packagePath.lastIndexOf('/node_modules/')
58
+ return marker >= 0 ? packagePath.slice(0, marker) : ''
59
+ }
60
+
61
+ export function dependencyDeclarations(record) {
62
+ const declarations = new Map()
63
+ const add = (values, kind, replace = false) => {
64
+ if (!values || typeof values !== 'object' || Array.isArray(values)) return
65
+ for (const name of Object.keys(values)) {
66
+ if (name.length > 256 || !PACKAGE_NAME.test(name)) continue
67
+ if (replace || !declarations.has(name)) declarations.set(name, kind)
68
+ }
69
+ }
70
+ add(record?.dependencies, 'runtime')
71
+ add(record?.devDependencies, 'dev')
72
+ add(record?.optionalDependencies, 'optional', true)
73
+ if (record?.peerDependencies && typeof record.peerDependencies === 'object' && !Array.isArray(record.peerDependencies)) {
74
+ for (const name of Object.keys(record.peerDependencies)) {
75
+ if (name.length > 256 || !PACKAGE_NAME.test(name) || declarations.has(name)) continue
76
+ const optional = record?.peerDependenciesMeta?.[name]?.optional === true
77
+ declarations.set(name, optional ? 'optional-peer' : 'peer')
78
+ }
79
+ }
80
+ return [...declarations].map(([name, kind]) => ({name, kind}))
81
+ .sort((a, b) => compareText(a.name, b.name) || compareText(a.kind, b.kind))
82
+ }
83
+
84
+ export function finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion}) {
85
+ const allNodes = [...externalNodes.values()].sort((a, b) =>
86
+ Number(b.direct) - Number(a.direct) || compareText(a.name, b.name) ||
87
+ compareText(a.version, b.version) || compareText(a.id, b.id))
88
+ const nodes = allNodes.slice(0, CAPS.packageGraphNodes)
89
+ const nodeIds = new Set(nodes.map((node) => node.id))
90
+ const allEdges = [...edgeMap.values()].sort((a, b) =>
91
+ compareText(a.from, b.from) || compareText(a.to, b.to) || compareText(a.kind, b.kind))
92
+ const eligibleEdges = allEdges.filter((edge) =>
93
+ (edge.from === '(root)' || nodeIds.has(edge.from)) && nodeIds.has(edge.to))
94
+ const edges = eligibleEdges.slice(0, CAPS.packageGraphEdges)
95
+ const nodesTruncated = nodes.length < allNodes.length
96
+ const edgesTruncated = edges.length < allEdges.length
97
+ if (nodesTruncated) reasons.push('PACKAGE_NODE_LIMIT_REACHED')
98
+ if (edgesTruncated) reasons.push('PACKAGE_EDGE_LIMIT_REACHED')
99
+
100
+ return {
101
+ state: reasons.length ? STATE.PARTIAL : STATE.COMPLETE,
102
+ ecosystem: 'npm',
103
+ lockfile,
104
+ lockfileVersion,
105
+ root: '(root)',
106
+ completeness: {
107
+ nodes: {total: allNodes.length, returned: nodes.length, truncated: nodesTruncated},
108
+ edges: {total: allEdges.length, returned: edges.length, truncated: edgesTruncated},
109
+ declarations,
110
+ reasons: [...new Set(reasons)].sort(compareText),
111
+ },
112
+ nodes,
113
+ edges,
114
+ }
115
+ }
@@ -0,0 +1,92 @@
1
+ import {compareText, STATE} from '../evidence-snapshot.common.mjs'
2
+ import {
3
+ MAX_DEPENDENCY_DECLARATIONS, MAX_PACKAGE_RECORDS, dependencyDeclarations,
4
+ emptyDeclarations, emptyGraph, finalizeGraph, normalizeLockPath, packageId,
5
+ packageNameFromPath, packageVersion, parentInstallPath,
6
+ } from './package-graph-common.mjs'
7
+
8
+ function resolveDependency(sourcePath, name, records, externalNodes) {
9
+ let cursor = sourcePath
10
+ const seen = new Set()
11
+ while (!seen.has(cursor)) {
12
+ seen.add(cursor)
13
+ const candidate = cursor ? `${cursor}/node_modules/${name}` : `node_modules/${name}`
14
+ const record = records.get(candidate)
15
+ if (record) {
16
+ if (record.link === true) return {kind: 'local'}
17
+ const node = externalNodes.get(candidate)
18
+ return node ? {kind: 'external', node} : {kind: 'unresolved'}
19
+ }
20
+ if (!cursor) break
21
+ cursor = parentInstallPath(cursor)
22
+ }
23
+ return {kind: 'unresolved'}
24
+ }
25
+
26
+ export function parsePackageLock(lock, lockfile) {
27
+ const lockfileVersion = Number(lock?.lockfileVersion || 0)
28
+ const extras = {lockfile, lockfileVersion: Number.isFinite(lockfileVersion) ? Math.trunc(lockfileVersion) : 0}
29
+ if (![2, 3].includes(lockfileVersion) || !lock?.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
30
+ return emptyGraph(STATE.PARTIAL, 'PACKAGE_LOCK_V2_V3_REQUIRED', extras)
31
+ }
32
+ const allKeys = Object.keys(lock.packages).sort(compareText)
33
+ const selectedKeys = allKeys.slice(0, MAX_PACKAGE_RECORDS)
34
+ const reasons = []
35
+ if (selectedKeys.length < allKeys.length) reasons.push('LOCKFILE_PACKAGE_RECORD_LIMIT_REACHED')
36
+ const records = new Map()
37
+ let invalidRecords = 0
38
+ for (const rawPath of selectedKeys) {
39
+ const packagePath = normalizeLockPath(rawPath)
40
+ const record = lock.packages[rawPath]
41
+ if (packagePath == null || !record || typeof record !== 'object' || Array.isArray(record) ||
42
+ ((packagePath.startsWith('node_modules/') || packagePath.includes('/node_modules/')) && !packageNameFromPath(packagePath)) || records.has(packagePath)) {
43
+ invalidRecords++
44
+ continue
45
+ }
46
+ records.set(packagePath, record)
47
+ }
48
+ if (invalidRecords > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_RECORDS')
49
+ const externalNodes = new Map()
50
+ let invalidPackageVersions = 0
51
+ for (const [packagePath, record] of records) {
52
+ const name = packageNameFromPath(packagePath)
53
+ const version = packageVersion(record.version)
54
+ if (!name || record.link === true) continue
55
+ if (!version) { invalidPackageVersions++; continue }
56
+ externalNodes.set(packagePath, {
57
+ id: packageId(name, version, packagePath), name, version, direct: false,
58
+ dev: record.dev === true || record.devOptional === true,
59
+ optional: record.optional === true || record.devOptional === true,
60
+ peer: record.peer === true,
61
+ })
62
+ }
63
+ if (invalidPackageVersions > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_VERSIONS')
64
+ const edgeMap = new Map()
65
+ const declarations = emptyDeclarations()
66
+ let declarationLimitReached = false
67
+ const sources = [...records].filter(([packagePath]) => packageNameFromPath(packagePath) == null || externalNodes.has(packagePath))
68
+ .sort(([a], [b]) => compareText(a, b))
69
+ outer: for (const [sourcePath, record] of sources) {
70
+ const sourceNode = externalNodes.get(sourcePath)
71
+ const sourceId = sourceNode?.id || '(root)'
72
+ for (const declaration of dependencyDeclarations(record)) {
73
+ if (declarations.total >= MAX_DEPENDENCY_DECLARATIONS) { declarationLimitReached = true; break outer }
74
+ declarations.total++
75
+ const resolved = resolveDependency(sourcePath, declaration.name, records, externalNodes)
76
+ if (resolved.kind === 'local') { declarations.local++; continue }
77
+ if (resolved.kind !== 'external') {
78
+ if (declaration.kind === 'optional' || declaration.kind === 'optional-peer') declarations.optionalMissing++
79
+ else declarations.unresolved++
80
+ continue
81
+ }
82
+ declarations.resolved++
83
+ if (sourceId === '(root)') resolved.node.direct = true
84
+ if (sourceId === resolved.node.id) continue
85
+ const edge = {from: sourceId, to: resolved.node.id, kind: declaration.kind}
86
+ edgeMap.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge)
87
+ }
88
+ }
89
+ if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
90
+ if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
91
+ return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion})
92
+ }