weavatrix 0.2.18 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +102 -93
  2. package/SECURITY.md +13 -31
  3. package/bin/weavatrix-mcp.mjs +2 -1
  4. package/docs/adr/0001-v0.3-offline-online-split.md +9 -7
  5. package/docs/releases/v0.2.19.md +33 -0
  6. package/docs/releases/v0.3.0.md +43 -0
  7. package/package.json +9 -3
  8. package/skill/SKILL.md +34 -59
  9. package/src/analysis/architecture/contract-storage.js +2 -2
  10. package/src/analysis/audit-extensions.js +60 -0
  11. package/src/analysis/duplicate-groups.js +13 -2
  12. package/src/analysis/duplicates.compute.js +7 -2
  13. package/src/analysis/health-capabilities.js +2 -1
  14. package/src/analysis/internal-audit/supply-chain.js +17 -2
  15. package/src/analysis/jvm-artifact-index.js +87 -0
  16. package/src/analysis/jvm-dependency-evidence.js +31 -8
  17. package/src/analysis/transport-contracts.js +78 -11
  18. package/src/analysis/transport-runtime-evidence.js +155 -0
  19. package/src/child-env.js +2 -2
  20. package/src/extension/local-services.mjs +75 -0
  21. package/src/graph/internal-builder.langs.js +2 -0
  22. package/src/graph/repo-registry.js +2 -2
  23. package/src/mcp/catalog.mjs +47 -21
  24. package/src/mcp/evidence/duplicate-member-order.mjs +1 -1
  25. package/src/mcp/evidence-snapshot.health.mjs +2 -2
  26. package/src/mcp/extension-api.mjs +77 -0
  27. package/src/mcp/health/audit-format.mjs +11 -1
  28. package/src/mcp/health/audit.mjs +10 -7
  29. package/src/mcp/health/duplicates.mjs +5 -3
  30. package/src/mcp/sync-payload.mjs +1 -1
  31. package/src/mcp/tools-actions.mjs +1 -8
  32. package/src/mcp/tools-architecture.mjs +2 -2
  33. package/src/mcp/tools-company.mjs +4 -2
  34. package/src/mcp-runtime.mjs +2 -0
  35. package/src/mcp-server.mjs +28 -17
  36. package/src/security/advisory-store.js +133 -181
  37. package/src/security/malware-scoring.js +4 -0
  38. package/src/security/registry-sig.rules.js +4 -3
  39. package/src/security/rust-advisory-report.js +60 -0
  40. package/docs/releases/v0.2.18.md +0 -41
  41. package/src/mcp/actions/advisories.mjs +0 -17
  42. package/src/mcp/actions/graph-sync.mjs +0 -195
  43. package/src/mcp/actions/hosted-architecture.mjs +0 -57
package/skill/SKILL.md CHANGED
@@ -6,7 +6,7 @@ description: "Use the Weavatrix MCP as a reusable local repository-intelligence
6
6
  # weavatrix MCP
7
7
 
8
8
  Structure-analysis tools over a prebuilt code graph plus the weavatrix analysis engines. The default
9
- `offline` profile has every HTTP tool disabled and permits repository switching only through an
9
+ `offline` profile contains no HTTP tool and permits repository switching only through an
10
10
  explicit local `open_repo` call. Use `pinned` when a shared MCP process must never change or inspect
11
11
  outside its startup repository.
12
12
 
@@ -17,27 +17,21 @@ Tools are named `mcp__weavatrix__…`. If none are available, ask the user to re
17
17
  `codex mcp add weavatrix -- npx -y weavatrix <repoRoot>`), then retry.
18
18
 
19
19
  First compare the selected profile with the `graph_stats` runtime line. Expected catalogs are 31
20
- tools for `pinned`, 34 for `offline`, 35 for `osv`, and 38 for `hosted` / `full`. Weavatrix refuses
20
+ tools for `pinned` and 34 for `offline`. Weavatrix refuses
21
21
  `initialize`, `tools/list`, and tool calls when the running package version differs from the
22
22
  `package.json` version on disk, with a loud `STALE_RUNTIME` error; restart/reconnect rather than using
23
23
  an old daemon. `WEAVATRIX_ALLOW_STALE_RUNTIME=1` is only for deliberate source development. A custom
24
- capability list needs `crossrepo` for `trace_api_contract`; legacy `online` adds only
25
- `advisories,hosted`. Missing HTTP tools are intentional only when the selected profile excludes them.
24
+ capability list needs `crossrepo` for `trace_api_contract`. Legacy network profile names fail loudly
25
+ and direct the user to the separate `weavatrix-online` package.
26
26
 
27
27
  Profiles use the same npm package and binary:
28
28
 
29
29
  - `offline` (default): all local analysis and explicit `open_repo`; no HTTP tools.
30
30
  - `pinned`: local analysis with no `open_repo`, no global/cross-repository graph access, and no HTTP tools.
31
- - `osv`: `offline` plus explicit `refresh_advisories`.
32
- - `hosted` / `full`: `osv` plus local-only `preview_sync` and the explicitly invoked network tools
33
- `pull_architecture_contract` and confirmed `sync_graph`.
34
-
35
- The legacy `online` capability remains a compatibility alias for `advisories,hosted`; prefer the
36
- named profiles for new registrations.
37
31
 
38
32
  ## Intent router
39
33
 
40
- Weavatrix is not one report or three fixed workflows. Its 38 methods expose different bounded views
34
+ Weavatrix is not one report or three fixed workflows. Its 34 methods expose different bounded views
41
35
  and analyses over the same reusable graph. Start from the task and choose the smallest sufficient
42
36
  projection; expand only when the answer requires it.
43
37
 
@@ -95,20 +89,26 @@ projection; expand only when the answer requires it.
95
89
  a changed file remains existing.
96
90
  - **Review dependency declarations/imports**: `run_audit category=dependencies`; it includes missing,
97
91
  unused, duplicate, unresolved-import and lockfile-drift evidence without relabelling identities.
98
- Maven/Gradle manifests are inventoried but unsupported import-to-artifact verification is reported
99
- `NOT_SUPPORTED`/`PARTIAL`, never a false clean `0 declared / 0 external`.
100
- - **Refresh vulnerability evidence when explicitly authorized**: `refresh_advisories`, then
101
- `run_audit category=vulnerability`. `NOT_CHECKED` is unknown, never clean.
102
- - **Review dead files, functions, methods and symbols**: `find_dead_code`; every result remains a
103
- review candidate with framework/dynamic/public-API caveats, never an auto-delete verdict.
92
+ Maven/Gradle imports are mapped to exact artifact ownership when the referenced class is present in
93
+ an already installed local JAR. Missing artifacts, ambiguous class owners, version catalogs or
94
+ dynamic build logic remain `PARTIAL`/`NOT_SUPPORTED`, never a false clean `0 declared / 0 external`.
95
+ - **Review vulnerability evidence**: `run_audit category=vulnerability`. `NOT_CHECKED` is unknown,
96
+ never clean; remote refresh is an explicit `weavatrix-online` workflow.
97
+ - **Review dead files, functions, methods and symbols**: `find_dead_code`; every result remains a
98
+ review candidate with framework/dynamic/public-API caveats, never an auto-delete verdict.
99
+ - **Trace dynamic GraphQL/gRPC/Kafka contracts**: export a source-free
100
+ `weavatrix.transport-runtime.v1` JSON report (normalized observations or OTLP `resourceSpans`) to
101
+ `.weavatrix/transport-runtime.json`, rebuild the graph, and call `trace_api_contract`. Only a
102
+ fresh report whose `repositoryRevision` matches `graphRevision` and whose observation maps to the
103
+ dynamic file/line resolves that `UNKNOWN`; missing capture remains unknown.
104
104
  - **Review clone families or same-name divergence**: `find_duplicates`; framework router boilerplate
105
- is suppressed unless explicitly requested.
105
+ and immutable declarative catalog shapes stay suppressed unless explicitly included.
106
106
  - **Map measured coverage or honest static test reachability**: `coverage_map`; unavailable measured
107
107
  coverage is not 0%.
108
108
  - **Prioritize local performance-review candidates**: `hot_path_review`; confirm with a profiler or
109
109
  benchmark before changing runtime behavior.
110
110
 
111
- ### Intended architecture and Hosted governance
111
+ ### Intended architecture
112
112
 
113
113
  - **Read or establish intended architecture**: `get_architecture_contract`. A returned starter adapts
114
114
  to Maven/Gradle source roots and monorepos and proposes product-code territories plus observed
@@ -123,11 +123,8 @@ projection; expand only when the answer requires it.
123
123
  `verify_architecture`.
124
124
  - **Understand or request an exception**: `explain_architecture_violation` ->
125
125
  `propose_architecture_exception`; proposals never mutate policy automatically. After human approval,
126
- the owner must add the returned proposal to the local contract's `exceptions` or approve it in Hosted.
127
- - **Pull an owner-approved Hosted target**: `pull_architecture_contract` only in an explicitly enabled
128
- Hosted profile.
129
- - **Review exactly what Hosted sync would send**: `preview_sync`; only an approved
130
- `sync_graph dry_run=false confirm_token=...` may send that exact bounded payload.
126
+ the owner must add the returned proposal to the local contract's `exceptions`; remote governance
127
+ belongs to `weavatrix-online`.
131
128
 
132
129
  Across every scenario, treat `PARTIAL`, `UNAVAILABLE`, `OFF`, `NOT_SUPPORTED`, `NOT_CHECKED`,
133
130
  `ERROR`, or capped evidence as incomplete rather than success. Java and Rust exact language-server
@@ -196,22 +193,14 @@ interface dispatch, reflection, or runtime behavior.
196
193
  ecosystem is not a repository-wide clean zero. For OSV, `OK` is
197
194
  complete for the recorded dependency fingerprint; `PARTIAL` is incomplete or stale,
198
195
  `NOT_CHECKED` has no repository-specific result, and `ERROR` means the local check failed. Treat
199
- the same non-`OK` states as incomplete for malware scanning. Refresh OSV only when the user has
200
- authorized selecting the optional `osv` profile (or `advisories` capability) and then
201
- invoking `refresh_advisories`; enabling the group alone sends nothing.
196
+ the same non-`OK` states as incomplete for malware scanning. Remote OSV refresh belongs to the
197
+ separate `weavatrix-online` package; this MIT core reads only its validated local cache.
202
198
  - **Offline by design**: scans and graph queries use local files; the semantic overlay may launch
203
199
  Weavatrix's bundled read-only TypeScript language-server child process, but it never runs repository
204
200
  scripts or downloads a provider. Coverage tools read existing reports and never run tests. The
205
- ONLY network-touching tools live in the optional
206
- `advisories` / `hosted` capabilities and run solely when explicitly called:
207
- `refresh_advisories` (queries OSV.dev with
208
- package names + versions so `run_audit` has fresh vulnerability data) and `sync_graph` (pushes only
209
- the exact allowlisted graph/evidence contract previously serialized by local-only `preview_sync` to a
210
- user-configured endpoint; analyzers may read local source, but the wire contract has no body,
211
- snippet, absolute-host-path or environment fields, and unknown fields are discarded; disabled until
212
- `WEAVATRIX_SYNC_URL` is set). `pull_architecture_contract` sends only the active repository's opaque
213
- stable UUID, downloads the owner-approved contract, validates it, and caches it locally; it requires
214
- `WEAVATRIX_SYNC_URL` and `WEAVATRIX_SYNC_TOKEN`. All three are absent from the default profile.
201
+ package contains no network-touching tool or network-enabled profile. Online/Cloud/Enterprise work
202
+ uses the separate `weavatrix-online` superset, which depends on this core and may add tools, skills
203
+ and local analyzer providers without replacing core implementations.
215
204
  - **Repository boundary**: source reads and graph-derived paths are realpath-contained. `open_repo`
216
205
  intentionally changes the active boundary through an explicit offline tool call. It is included in
217
206
  `offline`, absent from `pinned`, and available in a custom registration only when `retarget` is
@@ -362,34 +351,20 @@ dependency findings when every use is inside those roots; graph edges, cycles an
362
351
  visible. `managedDependencies` documents modules provided outside the repo's Python manifest;
363
352
  `ignoreDependencies` suppresses intentionally unresolved imports.
364
353
 
365
- ## Sync
366
-
367
- Call `preview_sync` first. It validates the configured destination and serializes payload v3 locally,
368
- returning the repository UUID, exact fields/sections, counts, bytes, hash and short-lived confirmation
369
- token without networking. After explicit approval, call `sync_graph dry_run=false` with that token.
370
- Payload v3 contains graph metadata plus deterministic architecture, health, stack,
371
- package-dependency and clone-review evidence. Read each section's `state`, `verdict` and completeness counts; `PARTIAL`,
372
- `NOT_CHECKED` and `ERROR` are unknown/incomplete, never a clean result. Architecture evidence
373
- contains concrete runtime versus compile-time cycles, declared boundary violations and separated
374
- runtime/type-only/compile-only module dependencies. Package evidence contains a bounded lockfile
375
- graph with direct/transitive runtime, dev, optional and peer edges plus explicit resolution counts.
376
- Duplicate evidence contains stable, source-free clone/divergence candidates; it never sends method
377
- bodies or snippets. Use `payload_version: 2` only when the user explicitly wants graph-only
378
- compatibility—Weavatrix never silently downgrades. A graph predating current provenance metadata, or one stale against
379
- the working tree, must be rebuilt first. Sync remains unavailable until the user selects `hosted`
380
- (or the exact `hosted` capability) and configures `WEAVATRIX_SYNC_URL`.
354
+ ## Online extensions
355
+
356
+ This package is the MIT, network-free core. If `refresh_advisories`, `online_status`, `preview_sync`,
357
+ `pull_architecture_contract` or `sync_graph` is required, use the separately installed
358
+ `weavatrix-online` MCP and follow its skill. Do not attempt to enable a legacy profile here: the
359
+ `online`, `osv`, `hosted` and `full` names fail loudly and cannot add network code to this artifact.
381
360
 
382
361
  ## Troubleshooting
383
362
 
384
363
  - `Graph unavailable` → `rebuild_graph`; normal graph/health calls automatically refresh an existing
385
364
  graph and report `none`, `incremental`, or `full`. `open_repo` can select another valid repository path unless
386
365
  the registration deliberately omitted `retarget`.
387
- - `refresh_advisories` is unavailable → with the user's approval, re-register/reconfigure the MCP
388
- with the `osv` profile, reconnect it, and then invoke the tool. Do not enable network
389
- access merely to turn `NOT_CHECKED` into a cosmetic green state.
390
- - `preview_sync` / `pull_architecture_contract` / `sync_graph` is unavailable → use the `hosted` profile only after
391
- the user chooses hosted integration; configure `WEAVATRIX_SYNC_URL` and a bearer token for contract
392
- pull. Profile selection alone never performs a request.
366
+ - An Online-only tool is unavailable → keep this core registration network-free and configure the
367
+ separate `weavatrix-online` MCP only when the user actually wants Cloud or Enterprise integration.
393
368
  - `No coverage report` → run the repo's own tests with coverage (`vitest run --coverage`,
394
369
  `jest --coverage`, `pytest --cov --cov-report=json`, `go test -coverprofile=coverage.out`),
395
370
  then re-call.
@@ -21,12 +21,12 @@ export function loadArchitectureContract(repoRoot, graphPath) {
21
21
  }
22
22
  const cached = graphPath ? join(dirname(graphPath), 'architecture.contract.json') : null
23
23
  return cached && existsSync(cached)
24
- ? readContract(cached, 'hosted-cache')
24
+ ? readContract(cached, 'extension-cache')
25
25
  : {contract: null, source: null, error: null}
26
26
  }
27
27
 
28
28
  export function writeCachedArchitectureContract(graphPath, input) {
29
- if (!graphPath) throw new Error('graph path is required for hosted contract cache')
29
+ if (!graphPath) throw new Error('graph path is required for extension contract cache')
30
30
  const contract = normalizeArchitectureContract(input)
31
31
  const path = join(dirname(graphPath), 'architecture.contract.json')
32
32
  mkdirSync(dirname(path), {recursive: true})
@@ -0,0 +1,60 @@
1
+ import {sortFindings, summarizeFindings} from './findings.js'
2
+
3
+ const VALID_STATUS = new Set(['CHECKED', 'NOT_CHECKED', 'PARTIAL', 'ERROR', 'NOT_SUPPORTED'])
4
+ const VALID_COMPLETENESS = new Set(['COMPLETE', 'PARTIAL'])
5
+
6
+ const providerFailure = (provider, reason) => ({
7
+ id: provider.id,
8
+ extension: provider.extension || null,
9
+ status: 'ERROR',
10
+ completeness: 'PARTIAL',
11
+ detail: reason,
12
+ findings: [],
13
+ })
14
+
15
+ const normalizeResult = (provider, value) => {
16
+ if (!value || typeof value !== 'object') return providerFailure(provider, 'provider returned no result envelope')
17
+ const status = VALID_STATUS.has(value.status) ? value.status : 'ERROR'
18
+ const completeness = VALID_COMPLETENESS.has(value.completeness) ? value.completeness : 'PARTIAL'
19
+ const findings = Array.isArray(value.findings) ? value.findings.map((finding) => ({
20
+ ...finding,
21
+ source: finding?.source || `extension:${provider.extension || provider.id}`,
22
+ })) : []
23
+ return {
24
+ id: provider.id,
25
+ extension: provider.extension || null,
26
+ status,
27
+ completeness,
28
+ detail: String(value.detail || (status === 'ERROR' ? 'provider returned an invalid status' : 'extension analyzer completed')),
29
+ findings,
30
+ evidence: value.evidence ?? null,
31
+ }
32
+ }
33
+
34
+ // Extension analyzers are deliberately local. A provider failure stays explicit ERROR/PARTIAL and
35
+ // never converts missing evidence into a clean result for the core audit.
36
+ export async function applyAuditExtensions(coreAudit, {providers = [], repoRoot, graph, args = {}} = {}) {
37
+ if (!coreAudit?.ok || !providers.length) return coreAudit
38
+ const results = []
39
+ for (const provider of providers) {
40
+ try {
41
+ const value = await provider.run({repoRoot, graph, args, coreAudit})
42
+ results.push(normalizeResult(provider, value))
43
+ } catch (error) {
44
+ results.push(providerFailure(provider, error instanceof Error ? error.message : String(error)))
45
+ }
46
+ }
47
+ const findings = sortFindings([
48
+ ...coreAudit.findings,
49
+ ...results.flatMap((result) => result.findings),
50
+ ])
51
+ return {
52
+ ...coreAudit,
53
+ findings,
54
+ summary: summarizeFindings(findings),
55
+ extensionCapabilities: results.map(({findings: providerFindings, ...result}) => ({
56
+ ...result,
57
+ findingCount: providerFindings.length,
58
+ })),
59
+ }
60
+ }
@@ -44,6 +44,11 @@ export function isFrameworkBoilerplateCloneGroup(group) {
44
44
  && /^(?:router|routes)\(?\)?$/i.test(String(member.label || '').trim()))
45
45
  }
46
46
 
47
+ export function isDeclarativeCatalogCloneGroup(group) {
48
+ const members = Array.isArray(group?.members) ? group.members : []
49
+ return members.length >= 2 && members.every((member) => member.declarative === true)
50
+ }
51
+
47
52
  export function analyzeDuplicateGroups(repoRoot, graphPath, args = {}) {
48
53
  const settings = {
49
54
  simMin: Math.min(100, Math.max(50, Number(args.min_similarity) || 80)),
@@ -52,12 +57,18 @@ export function analyzeDuplicateGroups(repoRoot, graphPath, args = {}) {
52
57
  skipTests: args.include_tests !== true,
53
58
  includeClassified: args.include_classified === true || args.include_non_product === true,
54
59
  includeBoilerplate: args.include_boilerplate === true,
60
+ includeDeclarative: args.include_declarative === true,
55
61
  }
56
62
  const data = computeDuplicates(repoRoot, graphPath, {includeStrings: args.include_strings === true, minTokens: settings.tokMin})
57
63
  const allGroups = groupPairs(data, settings)
58
- const groups = settings.includeBoilerplate ? allGroups : allGroups.filter((group) => !isFrameworkBoilerplateCloneGroup(group))
64
+ const afterBoilerplate = settings.includeBoilerplate ? allGroups : allGroups.filter((group) => !isFrameworkBoilerplateCloneGroup(group))
65
+ const groups = settings.includeDeclarative ? afterBoilerplate : afterBoilerplate.filter((group) => !isDeclarativeCatalogCloneGroup(group))
59
66
  const suppressed = (data.frags || []).filter((fragment) => !eligible(fragment, settings)).length
60
- return {settings, groups, suppressed, boilerplateSuppressed: allGroups.length - groups.length}
67
+ return {
68
+ settings, groups, suppressed,
69
+ boilerplateSuppressed: allGroups.length - afterBoilerplate.length,
70
+ declarativeSuppressed: afterBoilerplate.length - groups.length,
71
+ }
61
72
  }
62
73
 
63
74
  const digest = (value) => createHash('sha256').update(value).digest('hex').slice(0, 20)
@@ -111,7 +111,7 @@ function symbolRanges(graph) {
111
111
  if (!line) continue;
112
112
  let arr = byFile.get(n.source_file);
113
113
  if (!arr) byFile.set(n.source_file, (arr = []));
114
- arr.push({ id: n.id, label: n.label || n.id, line });
114
+ arr.push({ id: n.id, label: n.label || n.id, line, symbolKind: n.symbol_kind || "" });
115
115
  }
116
116
  for (const arr of byFile.values()) arr.sort((a, b) => a.line - b.line);
117
117
  return byFile;
@@ -175,9 +175,14 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
175
175
  if (end - start < 2) continue;
176
176
  const toks = tokenize(stripNonCode(body.join("\n"), py));
177
177
  if (toks.strict.length < scanTokenFloor) continue;
178
+ const strippedBody = stripNonCode(body.join("\n"), py);
179
+ const declarativeValue = /^(?:export\s+)?(?:const|let|var)\s+[\w$]+(?:\s*:[^=]+)?\s*=\s*(?:Object\.(?:freeze|seal)\s*\(\s*)?[\[{]/s.test(strippedBody.trim())
180
+ && !/=>|\b(?:function|if|for|while|switch|try|await|yield|return|throw|new)\b/.test(strippedBody);
181
+ const declarativeFactory = /^(?:export\s+)?const\s+[\w$]+(?:\s*:[^=]+)?\s*=\s*(?:sqliteTable|pgTable|mysqlTable)\s*\(/s.test(strippedBody.trim());
182
+ const declarative = declarativeValue || declarativeFactory || /^(?:interface|type|enum)$/i.test(syms[i].symbolKind);
178
183
  frags.push({
179
184
  id: syms[i].id, label: syms[i].label, file, start, end,
180
- n: toks.strict.length,
185
+ n: toks.strict.length, declarative,
181
186
  ...classificationFields(pathInfo),
182
187
  fp: { strict: fingerprints(toks.strict), renamed: fingerprints(toks.renamed) },
183
188
  });
@@ -39,6 +39,7 @@ export function buildHealthCapabilityMatrix({
39
39
  const noDependencyEvidence = ecosystemRows.length === 0;
40
40
  const onlyUnsupportedDependencies = unsupportedDependencyRows.length > 0 && supportedDependencyRows.length === 0;
41
41
  const advisorySupported = supportedDependencyRows.some((item) => ["npm", "go", "python", "rust", "maven", "gradle"].includes(item.ecosystem));
42
+ const onlyRustAdvisories = supportedDependencyRows.length > 0 && supportedDependencyRows.every((item) => item.ecosystem === "rust");
42
43
  const malwareSupported = supportedDependencyRows.some((item) => ["npm", "go", "python"].includes(item.ecosystem));
43
44
  const coverageSupported = [...languages].some((language) => ["javascript/typescript", "python", "go"].includes(language));
44
45
  const runtimeFiles = Number(correctnessCoverage.runtimeCorrectnessFiles || 0);
@@ -71,7 +72,7 @@ export function buildHealthCapabilityMatrix({
71
72
  concurrency: concurrencyFiles
72
73
  ? capability("CHECKED", "PARTIAL", `Checked ${concurrencyFiles} Java source file(s) for direct InterruptedException restore/rethrow evidence. No race detector ran; race freedom is not claimed.`)
73
74
  : capability("NOT_SUPPORTED", "PARTIAL", "No supported Java interruption check applied. No race detector ran; race freedom is not claimed."),
74
- advisories: checkCapability(checks.osv, advisorySupported, "OSV advisory matching"),
75
+ advisories: checkCapability(onlyRustAdvisories && checks.rustsec?.status === "OK" ? checks.rustsec : checks.osv, advisorySupported, onlyRustAdvisories ? "RustSec advisory matching" : "OSV advisory matching"),
75
76
  malware: checkCapability(checks.malware, malwareSupported, "Installed-package malware scanning"),
76
77
  coverage: measuredCoverageFiles > 0
77
78
  ? capability("CHECKED", "COMPLETE", `Mapped an existing supported coverage report to ${measuredCoverageFiles} file(s).`)
@@ -3,6 +3,7 @@ import { advisoryQueryFingerprint, loadStore, queryStore } from "../../security/
3
3
  import { matchAdvisories } from "../../security/match.js";
4
4
  import { scanMalware } from "../../security/malware-heuristics.js";
5
5
  import { classifyTyposquat } from "../../security/typosquat.js";
6
+ import { loadRustAdvisoryReport } from "../../security/rust-advisory-report.js";
6
7
  import { makeFinding } from "../findings.js";
7
8
  import { packageReachability } from "../package-reachability.js";
8
9
 
@@ -31,8 +32,9 @@ export async function runSupplyChainChecks(repoPath, {
31
32
  let installedCount = 0;
32
33
  let inst = { installed: [], drift: [] };
33
34
  const checks = {
34
- osv: { status: "NOT_CHECKED", detail: "Advisory cache was never refreshed for this repository. Enable the osv profile (or advisories capability), then call refresh_advisories explicitly to opt in to sending pinned package names and versions to OSV.dev." },
35
+ osv: { status: "NOT_CHECKED", detail: "Advisory cache was never refreshed for this repository. The MIT core remains offline; use the separate Weavatrix Online connector only if you choose to query OSV with pinned package names and versions." },
35
36
  malware: { status: skipMalwareScan ? "NOT_CHECKED" : "PENDING", detail: skipMalwareScan ? "Installed-package malware scan is opt-in and was not requested." : "" },
37
+ rustsec: { status: "NOT_CHECKED", detail: "No saved cargo audit --json report was inspected." },
36
38
  };
37
39
 
38
40
  try {
@@ -48,7 +50,7 @@ export async function runSupplyChainChecks(repoPath, {
48
50
  const coverage = typeof repoStamp === "object" && Number.isFinite(repoStamp.queried)
49
51
  ? ` (${repoStamp.queried_ok ?? repoStamp.queried}/${repoStamp.queried} package versions queried successfully)`
50
52
  : "";
51
- const drift = fingerprintMatches ? "" : " Dependency versions changed, or this is a legacy stamp without a package fingerprint; enable the osv profile (or advisories capability) and call refresh_advisories for complete coverage.";
53
+ const drift = fingerprintMatches ? "" : " Dependency versions changed, or this is a legacy stamp without a package fingerprint; refresh through the separate Weavatrix Online connector for complete advisory coverage.";
52
54
  checks.osv = {
53
55
  status,
54
56
  detail: `${status === "PARTIAL" ? "Partially matched" : "Matched"} installed packages against the cached OSV snapshot from ${advisoryDbDate}${coverage}.${drift}`,
@@ -114,6 +116,19 @@ export async function runSupplyChainChecks(repoPath, {
114
116
  fixHint: "npm ci (clean install from the lockfile)",
115
117
  }));
116
118
  }
119
+ const rustsec = loadRustAdvisoryReport(repoPath);
120
+ checks.rustsec = { status: rustsec.status, detail: rustsec.detail, checkedAt: rustsec.checkedAt || null };
121
+ for (const issue of rustsec.findings) {
122
+ findings.push(makeFinding({
123
+ category: "vulnerability", rule: issue.kind === "vulnerability" ? "known-vuln" : "rust-advisory-warning",
124
+ severity: issue.kind === "vulnerability" ? "high" : "medium", confidence: "high",
125
+ title: `${issue.id}: ${issue.package}${issue.version ? `@${issue.version}` : ""}`,
126
+ detail: `${issue.title}${issue.patched.length ? ` Patched: ${issue.patched.join(", ")}.` : ""}`,
127
+ package: issue.package, version: issue.version, source: "rustsec",
128
+ evidence: issue.url ? [{ file: issue.url, line: 0, snippet: `imported from ${rustsec.file}` }] : [{ file: rustsec.file, line: 0, snippet: issue.id }],
129
+ fixHint: issue.patched.length ? `upgrade ${issue.package} to a patched RustSec range (${issue.patched.join(", ")})` : `review or replace ${issue.package}; no patched range was reported`,
130
+ }));
131
+ }
117
132
  } catch (error) {
118
133
  checks.osv = { status: "ERROR", detail: `Offline advisory matching failed: ${error instanceof Error ? error.message : String(error)}` };
119
134
  }
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const concreteVersion = (value) => !!value && !/[\[\](),${}*+]/.test(String(value));
6
+ const safeEntries = (path) => { try { return readdirSync(path, { withFileTypes: true }); } catch { return []; } };
7
+
8
+ function findEndOfCentralDirectory(buffer) {
9
+ const minimum = Math.max(0, buffer.length - 65_557);
10
+ for (let offset = buffer.length - 22; offset >= minimum; offset--) {
11
+ if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
12
+ }
13
+ return -1;
14
+ }
15
+
16
+ // JARs are ZIP files. Reading only the central directory avoids extraction and code execution.
17
+ export function readJarClassNames(path, { maxClasses = 250_000 } = {}) {
18
+ const buffer = readFileSync(path);
19
+ const end = findEndOfCentralDirectory(buffer);
20
+ if (end < 0) throw new Error("ZIP central directory is missing");
21
+ const entries = buffer.readUInt16LE(end + 10);
22
+ let offset = buffer.readUInt32LE(end + 16);
23
+ const classes = new Set(), packages = new Set();
24
+ let truncated = false;
25
+ for (let index = 0; index < entries && offset + 46 <= buffer.length; index++) {
26
+ if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error("invalid ZIP central directory entry");
27
+ const nameLength = buffer.readUInt16LE(offset + 28);
28
+ const extraLength = buffer.readUInt16LE(offset + 30);
29
+ const commentLength = buffer.readUInt16LE(offset + 32);
30
+ let name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
31
+ offset += 46 + nameLength + extraLength + commentLength;
32
+ name = name.replace(/^META-INF\/versions\/\d+\//, "");
33
+ if (!name.endsWith(".class") || /(?:^|\/)(?:module-info|package-info)\.class$/.test(name)) continue;
34
+ const identity = name.slice(0, -6).replace(/\$/g, ".").replace(/\//g, ".");
35
+ classes.add(identity);
36
+ packages.add(identity.includes(".") ? identity.slice(0, identity.lastIndexOf(".")) : "");
37
+ if (classes.size >= maxClasses) { truncated = true; break; }
38
+ }
39
+ return { classes, packages, truncated };
40
+ }
41
+
42
+ function artifactCandidates(dependency, home) {
43
+ if (!dependency.group || !dependency.artifact || !concreteVersion(dependency.version)) return [];
44
+ const file = `${dependency.artifact}-${dependency.version}.jar`;
45
+ const maven = join(home, ".m2", "repository", ...dependency.group.split("."), dependency.artifact, dependency.version, file);
46
+ const gradleRoot = join(home, ".gradle", "caches", "modules-2", "files-2.1", dependency.group, dependency.artifact, dependency.version);
47
+ const gradle = safeEntries(gradleRoot).filter((entry) => entry.isDirectory()).flatMap((entry) =>
48
+ safeEntries(join(gradleRoot, entry.name)).filter((child) => child.isFile() && child.name.endsWith(".jar"))
49
+ .map((child) => join(gradleRoot, entry.name, child.name)));
50
+ return [maven, ...gradle].filter((candidate, index, all) => existsSync(candidate) && all.indexOf(candidate) === index);
51
+ }
52
+
53
+ const addOwner = (map, key, owner) => {
54
+ const owners = map.get(key) || new Set();
55
+ owners.add(owner); map.set(key, owners);
56
+ };
57
+
58
+ // Exact import -> artifact evidence from already-installed Maven/Gradle JARs. This is offline and
59
+ // read-only: no build tool, plugin, class loader or repository code is executed.
60
+ export function collectJvmArtifactIndex(dependencies, { home = homedir(), maxArtifacts = 256, maxClasses = 500_000 } = {}) {
61
+ const unique = [...new Map(dependencies.map((dependency) => [dependency.name, dependency])).values()];
62
+ const classes = new Map(), packages = new Map(), errors = [];
63
+ let artifactsIndexed = 0, artifactsMissing = 0, classCount = 0, truncated = false;
64
+ for (const dependency of unique) {
65
+ if (artifactsIndexed >= maxArtifacts || classCount >= maxClasses) { truncated = true; break; }
66
+ const candidates = artifactCandidates(dependency, home);
67
+ if (!candidates.length) { artifactsMissing++; continue; }
68
+ try {
69
+ const remaining = Math.max(1, maxClasses - classCount);
70
+ const content = readJarClassNames(candidates[0], { maxClasses: remaining });
71
+ for (const identity of content.classes) addOwner(classes, identity, dependency.name);
72
+ for (const identity of content.packages) addOwner(packages, identity, dependency.name);
73
+ classCount += content.classes.size; artifactsIndexed++; truncated ||= content.truncated;
74
+ } catch (error) {
75
+ errors.push(`${dependency.name}: ${error instanceof Error ? error.message : String(error)}`);
76
+ }
77
+ }
78
+ const resolve = (spec) => {
79
+ const imported = String(spec || "").replace(/\.\*$/, "");
80
+ const owners = String(spec || "").endsWith(".*") ? packages.get(imported) : classes.get(imported);
81
+ return [...(owners || [])].sort();
82
+ };
83
+ return {
84
+ resolve, artifactsRequired: unique.length, artifactsIndexed, artifactsMissing,
85
+ classCount, errors, truncated,
86
+ };
87
+ }
@@ -1,6 +1,7 @@
1
1
  import { createRepoBoundary } from "../repo-path.js";
2
2
  import { dependencyVerification, makeFinding } from "./findings.js";
3
3
  import { parseGradleDependencies, parseGradleVersionCatalog, parseMavenPom } from "./jvm-manifests.js";
4
+ import { collectJvmArtifactIndex } from "./jvm-artifact-index.js";
4
5
  import { listRepoFiles, readRepoText } from "./internal-audit.collect.js";
5
6
 
6
7
  const normalize = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
@@ -24,12 +25,18 @@ function bestDependency(spec, dependencies) {
24
25
  return ranked[0].dependency;
25
26
  }
26
27
 
27
- function analyze(ecosystem, manifests, dependencies, imports, unresolvedDeclarations, mappingDependencies = dependencies, includeMissing = true) {
28
+ function analyze(ecosystem, manifests, dependencies, imports, unresolvedDeclarations, mappingDependencies = dependencies, includeMissing = true, artifactEvidence) {
28
29
  const findings = [], used = new Map(), missing = new Map();
29
30
  const owned = new Set(dependencies.map((dependency) => dependency.name));
31
+ let exactMappedImports = 0, heuristicMappedImports = 0, ambiguousImports = 0;
30
32
  for (const item of imports) {
31
- const dependency = bestDependency(item.spec || item.pkg, mappingDependencies);
33
+ const exactOwners = artifactEvidence.resolve(item.spec || item.pkg);
34
+ if (exactOwners.length > 1) ambiguousImports++;
35
+ const dependency = exactOwners.length === 1
36
+ ? mappingDependencies.find((candidate) => candidate.name === exactOwners[0])
37
+ : bestDependency(item.spec || item.pkg, mappingDependencies);
32
38
  if (dependency) {
39
+ if (exactOwners.length === 1) exactMappedImports++; else heuristicMappedImports++;
33
40
  if (owned.has(dependency.name)) {
34
41
  const list = used.get(dependency.name) || [];
35
42
  list.push(item); used.set(dependency.name, list);
@@ -69,21 +76,36 @@ function analyze(ecosystem, manifests, dependencies, imports, unresolvedDeclarat
69
76
  }));
70
77
  }
71
78
  const present = manifests.length > 0;
79
+ const exactComplete = present && unresolvedDeclarations === 0 && !artifactEvidence.truncated
80
+ && artifactEvidence.errors.length === 0 && artifactEvidence.artifactsMissing === 0
81
+ && artifactEvidence.artifactsIndexed === artifactEvidence.artifactsRequired
82
+ && exactMappedImports === imports.length && ambiguousImports === 0;
72
83
  return {
73
84
  present,
74
85
  status: present ? "CHECKED" : "NOT_PRESENT",
75
- completeness: present ? (unresolvedDeclarations ? "PARTIAL" : "COMPLETE") : "NOT_APPLICABLE",
86
+ completeness: present ? (exactComplete ? "COMPLETE" : "PARTIAL") : "NOT_APPLICABLE",
76
87
  manifests,
77
88
  declared: dependencies.length,
78
89
  mappedImports: [...used.values()].reduce((sum, list) => sum + list.length, 0),
79
90
  unmappedImports: [...missing.values()].reduce((sum, list) => sum + list.length, 0),
80
91
  unresolvedDeclarations,
92
+ exactArtifactEvidence: {
93
+ artifactsRequired: artifactEvidence.artifactsRequired,
94
+ artifactsIndexed: artifactEvidence.artifactsIndexed,
95
+ artifactsMissing: artifactEvidence.artifactsMissing,
96
+ classesIndexed: artifactEvidence.classCount,
97
+ exactMappedImports,
98
+ heuristicMappedImports,
99
+ ambiguousImports,
100
+ truncated: artifactEvidence.truncated,
101
+ errors: artifactEvidence.errors.slice(0, 10),
102
+ },
81
103
  sample: dependencies.slice(0, 20).map(({ file, name, version }) => ({ file, identity: name, version })),
82
104
  reason: !present
83
105
  ? `No ${ecosystem === "maven" ? "pom.xml" : "Gradle build file"} was discovered.`
84
- : unresolvedDeclarations
85
- ? `${dependencies.length} declarations and all indexed Java imports were checked, but ${unresolvedDeclarations} dynamic/property/catalog declaration(s) could not be resolved statically.`
86
- : `${dependencies.length} declarations were compared with every indexed non-JDK Java import using bounded group-prefix and artifact-token evidence. Missing and unused results remain review evidence, not compiler proof.`,
106
+ : exactComplete
107
+ ? `${dependencies.length} declarations were compared with every indexed non-JDK Java import using exact class ownership from ${artifactEvidence.artifactsIndexed} installed JAR(s).`
108
+ : `${dependencies.length} declarations and every indexed non-JDK Java import were checked, but exact artifact evidence is partial: ${unresolvedDeclarations} unresolved declaration(s), ${artifactEvidence.artifactsMissing} installed JAR(s) missing, ${heuristicMappedImports} heuristic mapping(s), ${ambiguousImports} ambiguous mapping(s). Heuristic missing/unused findings remain review evidence, never compiler proof.`,
87
109
  findings,
88
110
  };
89
111
  }
@@ -108,8 +130,9 @@ export function collectJvmDependencyEvidence(repoRoot, { files = listRepoFiles(r
108
130
  });
109
131
  const javaImports = externalImports.filter((entry) => entry.ecosystem === "Maven" && entry.pkg && !entry.builtin && !entry.unresolved);
110
132
  const allDependencies = [...mavenDependencies, ...gradleDependencies];
133
+ const artifactEvidence = collectJvmArtifactIndex(allDependencies);
111
134
  return {
112
- maven: analyze("maven", mavenFiles, mavenDependencies, javaImports, mavenUnresolved, allDependencies, true),
113
- gradle: analyze("gradle", [...gradleFiles, ...catalogFiles], gradleDependencies, javaImports, gradleUnresolved, allDependencies, mavenFiles.length === 0),
135
+ maven: analyze("maven", mavenFiles, mavenDependencies, javaImports, mavenUnresolved, allDependencies, true, artifactEvidence),
136
+ gradle: analyze("gradle", [...gradleFiles, ...catalogFiles], gradleDependencies, javaImports, gradleUnresolved, allDependencies, mavenFiles.length === 0, artifactEvidence),
114
137
  };
115
138
  }