weavatrix 0.2.13 → 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.
- package/README.md +83 -39
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +57 -38
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +20 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -331
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp-server.mjs +35 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.13.md +0 -48
package/src/mcp/catalog.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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,
|
|
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
|
|
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,
|
|
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: '
|
|
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)},
|
|
@@ -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
|
+
}
|