weavatrix 0.2.16 → 0.2.19

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 CHANGED
@@ -14,6 +14,26 @@ the defaults, no repository data leaves your machine.**
14
14
  - Source: [github.com/sergii-ziborov/weavatrix](https://github.com/sergii-ziborov/weavatrix)
15
15
  - npm: [`weavatrix`](https://www.npmjs.com/package/weavatrix) — `npx -y weavatrix <repoRoot>`
16
16
 
17
+ ## 0.3 boundary: offline stays MIT
18
+
19
+ The next major boundary makes this package the complete offline engine: graph,
20
+ local semantic precision, Health, dependencies, duplicates, impact, history and
21
+ architecture safeguards remain here under the existing MIT license. The MIT
22
+ license is not changing.
23
+
24
+ Every outbound HTTP capability moves to a separately versioned public package,
25
+ `weavatrix-online`, beginning at 0.1.0. That connector targets either the
26
+ managed Weavatrix Cloud or a commercially licensed self-hosted Weavatrix
27
+ Enterprise deployment through one source-free wire contract. It is a thin
28
+ extension of this package, not a fork of the parser/graph/Health engine, so core
29
+ updates arrive as normal dependency updates.
30
+
31
+ Version 0.2.19 still contains the explicit opt-in compatibility profiles
32
+ documented below. The paired 0.3/0.1 release removes their network
33
+ implementations from this package; until then the new connector is not
34
+ publishable. The decision and release gates are in
35
+ [docs/adr/0001-v0.3-offline-online-split.md](docs/adr/0001-v0.3-offline-online-split.md).
36
+
17
37
  ## One graph, many views
18
38
 
19
39
  The 38 MCP methods are not one linear workflow. They project the same reusable graph into the
@@ -410,6 +430,44 @@ metrics are not persisted or transmitted by Weavatrix. If a source checkout's pa
410
430
  while an old daemon remains alive, `initialize`, `tools/list`, and tool calls fail loudly with
411
431
  `STALE_RUNTIME` until the client reconnects; the opt-out is reserved for deliberate development.
412
432
 
433
+ ### 0.2.19 supply-chain signal precision
434
+
435
+ - Installed-package URL literals and ordinary standalone network calls no longer become malware
436
+ findings without a separate security signal. They remain bounded co-evidence beside behavior such
437
+ as environment harvesting, while exfil endpoints, public raw IPs and fetch-plus-exec stay active.
438
+ - Unicode variation selectors used for emoji/presentation are no longer confused with hidden text
439
+ controls. Bidirectional override/isolate controls remain covered.
440
+ - Real Hosted dogfood now completes OSV over 651 package versions and the local malware sweep over
441
+ 535 installed packages with zero critical/high/medium/low findings.
442
+
443
+ Full patch notes: [docs/releases/v0.2.19.md](docs/releases/v0.2.19.md).
444
+
445
+ ### 0.2.18 repository-root and self-audit trust patch
446
+
447
+ - A directory nested under an ignored parent Git repository is no longer mistaken for an empty
448
+ repository. The internal builder now falls back to its boundary-safe walker for that ambiguous
449
+ case while preserving Git ignore semantics at a real repository root.
450
+ - This is the first npm release containing the 0.2.17 self-audit work: Drizzle reachability,
451
+ scoped typosquat precision, regex-aware clone anchors, dead parser removal and shared owners.
452
+ - The 0.3 boundary remains unchanged: this offline package stays MIT, and the separately licensed
453
+ online connector owns outbound HTTP after the major split.
454
+
455
+ Full patch notes: [docs/releases/v0.2.18.md](docs/releases/v0.2.18.md).
456
+
457
+ ### 0.2.17 self-audit trust patch (tagged, not published to npm)
458
+
459
+ - Health no longer treats configured Drizzle schema modules as orphaned/test-only production code,
460
+ and scoped typosquat checks no longer compare legitimate scoped packages with unrelated unscoped
461
+ names.
462
+ - Duplicate detection retains equality anchors for executable regular-expression bodies, preventing
463
+ unrelated validation/normalization pipelines from becoming perfect renamed clones.
464
+ - Dogfooding removed obsolete test-only parsers and consolidated dependency-scope, graph-ID,
465
+ architecture-contract, bounded-option and safe repository-read helpers.
466
+ - The accepted 0.3 product boundary keeps the offline engine MIT and moves all outbound HTTP tools
467
+ into the separately licensed `weavatrix-online` connector.
468
+
469
+ Full patch notes: [docs/releases/v0.2.17.md](docs/releases/v0.2.17.md).
470
+
413
471
  ### 0.2.16 exact dependents, multi-ecosystem dependencies and transport contracts
414
472
 
415
473
  - Safe TypeScript project plugins no longer disable semantic precision. Plugin loading remains
@@ -0,0 +1,58 @@
1
+ # ADR 0001: Weavatrix 0.3 offline/online split
2
+
3
+ Status: accepted direction; extraction in progress
4
+
5
+ Date: 2026-07-19
6
+
7
+ ## Decision
8
+
9
+ The public `weavatrix` package remains MIT licensed and becomes a strictly
10
+ offline MCP in 0.3. Its analyzers, graph, local LSP provider, cache, repository
11
+ registry, Health, dependency, duplicate, impact, history and architecture tools
12
+ remain available without a network capability.
13
+
14
+ All outbound HTTP tools move to the separate public-source `weavatrix-online`
15
+ package, starting at 0.1.0. Its permission-required source license must be
16
+ approved before publication; the scaffold remains unpublished and
17
+ `UNLICENSED`. It extends the MIT engine rather than copying or relicensing it.
18
+
19
+ The managed Cloud and licensed self-hosted Enterprise products are private
20
+ server implementations of the same source-free wire contract. Neither is
21
+ bundled into the offline package.
22
+
23
+ ## Tool ownership after extraction
24
+
25
+ Offline owns local advisory matching but not remote refresh. Online owns:
26
+
27
+ - `refresh_advisories`;
28
+ - `preview_sync`;
29
+ - `sync_graph`;
30
+ - `pull_architecture_contract`;
31
+ - endpoint capability negotiation and authentication.
32
+
33
+ The exact final catalog is locked by tests before release. There is no legacy
34
+ `online`, `hosted` or `full` alias in the offline artifact that can re-enable an
35
+ HTTP implementation.
36
+
37
+ ## Update model
38
+
39
+ `weavatrix-online` depends on a compatible `weavatrix` release and uses a
40
+ supported tool-extension API. Parser, graph schema, LSP and Health fixes are
41
+ therefore updated once in the MIT core. The online repository contains only
42
+ transport, endpoint/authentication and consent-flow code.
43
+
44
+ ## Release gates
45
+
46
+ The two public packages are released as a compatible pair only after:
47
+
48
+ 1. offline npm, MCPB, skill, server metadata and docs expose no HTTP tools or
49
+ hosted URL/token inputs;
50
+ 2. network action modules and fetch paths are absent from the offline package;
51
+ 3. the online connector passes the same local graph/Health regression suite
52
+ through its core dependency;
53
+ 4. Cloud and Enterprise pass one wire-contract suite;
54
+ 5. license metadata clearly distinguishes MIT core from permission-required,
55
+ source-available connector code;
56
+ 6. stale 0.2 processes fail clearly rather than advertising mixed catalogs.
57
+
58
+ Existing MIT releases are unchanged and remain usable under MIT.
@@ -0,0 +1,33 @@
1
+ # Weavatrix 0.2.19
2
+
3
+ 0.2.19 is a supply-chain signal-precision patch. It was produced after the complete Hosted
4
+ installed-package scan exposed false positives in otherwise ordinary dependency content.
5
+
6
+ ## Malware evidence precision
7
+
8
+ - A hardcoded external URL is no longer sufficient for a malware finding.
9
+ - A standalone `fetch`, Axios, request, curl or wget call to an ordinary external URL is also weak
10
+ evidence by itself. It remains attached as bounded co-evidence when the same package has a
11
+ separate security signal, such as whole-environment serialization.
12
+ - Strong URL and execution evidence is unchanged: known exfil/webhook hosts, public raw-IP URLs,
13
+ cloud metadata endpoints, download-plus-exec lifecycle scripts, reverse shells and miners still
14
+ produce review findings.
15
+ - Unicode variation selectors (`U+FE00`–`U+FE0F`) are presentation controls commonly used with
16
+ emoji and no longer trigger hidden-Unicode review. Bidirectional override/isolate controls remain
17
+ covered because they can conceal source order.
18
+
19
+ ## Verification
20
+
21
+ - Synthetic end-to-end fixtures prove ordinary URL literals, standalone network calls and variation
22
+ selectors stay quiet while multi-signal and strong malicious families remain detectable.
23
+ - Real Weavatrix Hosted dogfood checked 651/651 pinned package versions through OSV with no known
24
+ advisories and scanned 535 installed npm packages with zero malware findings.
25
+ - The same audit reports complete npm declaration/import verification, zero missing dependencies,
26
+ zero unused dependencies, zero duplicate declarations and zero runtime cycles.
27
+ - Full release tests, release-manifest checks, npm/MCPB pack/runtime smoke and supported Node CI remain
28
+ mandatory before publication.
29
+
30
+ ## Product boundary
31
+
32
+ The offline package remains MIT. The accepted 0.3 split and the separate permission-licensed online
33
+ connector are unchanged by this patch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.2.16",
3
+ "version": "0.2.19",
4
4
  "type": "module",
5
5
  "description": "Local repository intelligence MCP for AI coding agents: reusable architecture graph for fast application understanding, Health, dead code, clones, history, blast radius and architecture safeguards.",
6
6
  "author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
@@ -26,7 +26,8 @@
26
26
  "skill",
27
27
  "scripts/run-agent-task-benchmark.mjs",
28
28
  "docs/agent-task-benchmark.md",
29
- "docs/releases/v0.2.16.md",
29
+ "docs/adr/0001-v0.3-offline-online-split.md",
30
+ "docs/releases/v0.2.19.md",
30
31
  "README.md",
31
32
  "SECURITY.md",
32
33
  "LICENSE"
@@ -1,19 +1,13 @@
1
- import { dirname } from "node:path";
2
1
  import { createRepoBoundary } from "../repo-path.js";
3
2
  import { cargoName, parseCargoToml } from "./cargo-manifests.js";
4
3
  import { dependencyVerification, makeFinding } from "./findings.js";
5
4
  import { listRepoFiles, readRepoText } from "./internal-audit.collect.js";
6
-
7
- const rootOf = (file) => {
8
- const root = dirname(String(file || "").replace(/\\/g, "/"));
9
- return root === "." ? "" : root;
10
- };
11
- const owns = (root, file) => !root || file === root || String(file || "").replace(/\\/g, "/").startsWith(`${root}/`);
5
+ import { dependencyScopeOwnsFile, dependencyScopeRoot } from "./dependency/scoped-dependencies.js";
12
6
 
13
7
  export function collectCargoDependencyEvidence(repoRoot, { files = listRepoFiles(repoRoot), externalImports = [] } = {}) {
14
8
  const boundary = createRepoBoundary(repoRoot);
15
9
  const manifests = files.filter((file) => /(^|\/)Cargo\.toml$/i.test(file));
16
- const scopes = manifests.map((file) => ({ file, root: rootOf(file), ...parseCargoToml(readRepoText(boundary, file)) }))
10
+ const scopes = manifests.map((file) => ({ file, root: dependencyScopeRoot(file), ...parseCargoToml(readRepoText(boundary, file)) }))
17
11
  .sort((left, right) => right.root.length - left.root.length || left.root.localeCompare(right.root));
18
12
  const workspaceDeps = new Map();
19
13
  for (const scope of scopes) for (const dependency of scope.workspaceDependencies) workspaceDeps.set(cargoName(dependency.alias), dependency);
@@ -29,7 +23,7 @@ export function collectCargoDependencyEvidence(repoRoot, { files = listRepoFiles
29
23
  const importsByScope = new Map(scopes.map((scope) => [scope, []]));
30
24
  for (const entry of externalImports) {
31
25
  if (entry.ecosystem !== "crates.io" || !entry.pkg || entry.builtin || entry.unresolved) continue;
32
- const owner = scopes.find((scope) => owns(scope.root, entry.file));
26
+ const owner = scopes.find((scope) => dependencyScopeOwnsFile(scope.root, entry.file));
33
27
  if (owner) importsByScope.get(owner).push(entry);
34
28
  }
35
29
  const findings = [], declared = new Set();
@@ -1,5 +1,11 @@
1
- const normalizeScope = (root) => String(root || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
2
- const ownsFile = (scope, file) => !scope || file === scope || String(file || '').startsWith(`${scope}/`)
1
+ import {posix} from 'node:path'
2
+
3
+ export const normalizeDependencyScope = (root) => String(root || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
4
+ export const dependencyScopeRoot = (manifest) => {
5
+ const root = posix.dirname(normalizeDependencyScope(manifest))
6
+ return root === '.' ? '' : root
7
+ }
8
+ export const dependencyScopeOwnsFile = (scope, file) => !scope || file === scope || String(file || '').startsWith(`${scope}/`)
3
9
 
4
10
  // Bind the ecosystem-specific core without introducing a facade/helper import cycle.
5
11
  export function createScopedDepFindings(computeDepFindings) {
@@ -8,25 +14,25 @@ export function createScopedDepFindings(computeDepFindings) {
8
14
  sourceTexts = new Map(), nonRuntimeRoots = [], sourceFiles = [],
9
15
  } = {}) {
10
16
  const scopes = packageScopes.length
11
- ? packageScopes.map((scope) => ({...scope, root: normalizeScope(scope.root)})).sort((a, b) => b.root.length - a.root.length)
17
+ ? packageScopes.map((scope) => ({...scope, root: normalizeDependencyScope(scope.root)})).sort((a, b) => b.root.length - a.root.length)
12
18
  : [{root: '', manifest: 'package.json', pkg: {}, aliases: []}]
13
19
  const importsByScope = new Map(scopes.map((scope) => [scope, []]))
14
20
  const sourceFilesByScope = new Map(scopes.map((scope) => [scope, []]))
15
21
  const sourceTextsByScope = new Map(scopes.map((scope) => [scope, new Map()]))
16
22
  for (const item of externalImports) {
17
- const owner = scopes.find((scope) => ownsFile(scope.root, item.file)) || scopes[scopes.length - 1]
23
+ const owner = scopes.find((scope) => dependencyScopeOwnsFile(scope.root, item.file)) || scopes[scopes.length - 1]
18
24
  importsByScope.get(owner).push(item)
19
25
  }
20
26
  for (const file of sourceFiles) {
21
- const owner = scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1]
27
+ const owner = scopes.find((scope) => dependencyScopeOwnsFile(scope.root, file)) || scopes[scopes.length - 1]
22
28
  sourceFilesByScope.get(owner).push(file)
23
29
  }
24
30
  for (const [file, text] of sourceTexts) {
25
- const owner = scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1]
31
+ const owner = scopes.find((scope) => dependencyScopeOwnsFile(scope.root, file)) || scopes[scopes.length - 1]
26
32
  sourceTextsByScope.get(owner).set(file, text)
27
33
  }
28
34
  const configOwner = new Map()
29
- for (const [file] of configTexts) configOwner.set(file, scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1])
35
+ for (const [file] of configTexts) configOwner.set(file, scopes.find((scope) => dependencyScopeOwnsFile(scope.root, file)) || scopes[scopes.length - 1])
30
36
  const findings = [], usedPackages = new Map(), declared = new Set()
31
37
  for (const scope of scopes) {
32
38
  const scopeConfig = new Map([...configTexts].filter(([file]) => configOwner.get(file) === scope))
@@ -3,9 +3,16 @@
3
3
 
4
4
  const K = 8; // k-gram length (tokens)
5
5
  const W = 4; // winnowing window → guaranteed detection of matches ≥ K+W-1 tokens
6
- const STRLIT = String.fromCharCode(0); // opaque-literal sentinel for every string/regex body: a NUL
6
+ const STRLIT = String.fromCharCode(0); // opaque-literal sentinel for string bodies: a NUL
7
7
  // its own class (never an identifier "I" nor a number "N"), so a literal can
8
8
  // never masquerade as code in either mode
9
+ const REGEXLIT = String.fromCharCode(1); // regex bodies are executable behavior, so retain an equality anchor
10
+ const literalHash = (value) => {
11
+ let hash = 2166136261;
12
+ for (const char of String(value || "")) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
13
+ return (hash >>> 0).toString(16);
14
+ };
15
+ const regexMarker = (value) => ` ${REGEXLIT} 0x${literalHash(value)} `;
9
16
 
10
17
  const KEYWORDS = new Set(("if else for while do switch case break continue return function const let var new class extends async await try catch finally throw import from export default typeof instanceof in of delete void yield static get set this super null undefined true false def elif except lambda pass raise with as is not and or None True False func go defer chan map range struct interface type package nil err string int bool byte float64 public private protected final void long double boolean").split(" "));
11
18
 
@@ -16,9 +23,9 @@ const KEYWORDS = new Set(("if else for while do switch case break continue retur
16
23
  // regex and ate the rest of the line.
17
24
  const REGEX_PREV = new Set("(,=:[!&|?{};~*%^<>".split(""));
18
25
 
19
- // Replace comments with nothing and string/regex BODIES with the sentinel, so their contents never
20
- // count as code. Handles Python triple-quoted docstrings (embedded quotes no longer desync the scan)
21
- // and JS regex literals (a quote inside /['"]/ no longer opens a phantom string).
26
+ // Replace comments with nothing and string bodies with an opaque sentinel. Regex bodies retain only
27
+ // a one-way equality anchor: their source is executable behavior, and collapsing every pattern to the
28
+ // same token creates false clone groups for unrelated replace/validate pipelines.
22
29
  export function stripNonCode(text, py) {
23
30
  let out = "", i = 0, prevSig = "";
24
31
  const s = String(text || "");
@@ -34,7 +41,7 @@ export function stripNonCode(text, py) {
34
41
  if (!py && two === "//") { while (i < s.length && s[i] !== "\n") i++; continue; }
35
42
  if (!py && two === "/*") { i += 2; while (i < s.length && s.slice(i, i + 2) !== "*/") i++; i += 2; continue; }
36
43
  if (!py && ch === "/" && REGEX_PREV.has(prevSig)) {
37
- i++; let inClass = false; // '/' inside a [...] char class is literal, not the terminator
44
+ i++; const regexStart = i; let inClass = false; // '/' inside a [...] char class is literal, not the terminator
38
45
  while (i < s.length && s[i] !== "\n") {
39
46
  const c = s[i];
40
47
  if (c === "\\") { i += 2; continue; }
@@ -44,7 +51,7 @@ export function stripNonCode(text, py) {
44
51
  i++;
45
52
  }
46
53
  while (i < s.length && /[a-z]/i.test(s[i])) i++; // regex flags (gimsuy)
47
- push(` ${STRLIT} `); continue;
54
+ push(regexMarker(s.slice(regexStart, i))); continue;
48
55
  }
49
56
  if (ch === '"' || ch === "'" || ch === "`") {
50
57
  const q = ch; i++;
@@ -1,4 +1,5 @@
1
1
  import {isAbsolute} from 'node:path'
2
+ import {boundedInteger} from '../../util.js'
2
3
 
3
4
  export const GIT_HISTORY_V = 1
4
5
  export const GIT_HISTORY_WINDOWS = Object.freeze([3, 6, 12])
@@ -28,10 +29,7 @@ export const roundHistoryNumber = (value, digits = 4) => {
28
29
  const scale = 10 ** digits
29
30
  return Math.round(value * scale) / scale
30
31
  }
31
- export const boundedHistoryInteger = (value, fallback, min, max) => {
32
- const number = Number(value)
33
- return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback
34
- }
32
+ export const boundedHistoryInteger = boundedInteger
35
33
 
36
34
  export function safeHistoryPath(value) {
37
35
  const path = String(value || '').replace(/\\/g, '/').replace(/^\.\//, '')
@@ -1,25 +1,19 @@
1
- import { dirname } from "node:path";
2
1
  import { createRepoBoundary } from "../repo-path.js";
3
2
  import { computeGoDepFindings } from "./dep-check-ecosystems.js";
4
3
  import { listRepoFiles, readRepoText } from "./internal-audit.collect.js";
5
4
  import { parseGoMod } from "./manifests.js";
6
5
  import { makeFinding } from "./findings.js";
7
-
8
- const rootOf = (file) => {
9
- const root = dirname(String(file || "").replace(/\\/g, "/"));
10
- return root === "." ? "" : root;
11
- };
12
- const owns = (root, file) => !root || file === root || String(file || "").replace(/\\/g, "/").startsWith(`${root}/`);
6
+ import { dependencyScopeOwnsFile, dependencyScopeRoot } from "./dependency/scoped-dependencies.js";
13
7
 
14
8
  export function collectGoDependencyEvidence(repoRoot, { files = listRepoFiles(repoRoot), externalImports = [], nonRuntimeRoots = [] } = {}) {
15
9
  const boundary = createRepoBoundary(repoRoot);
16
10
  const manifests = files.filter((file) => /(^|\/)go\.mod$/i.test(file));
17
- const scopes = manifests.map((file) => ({ file, root: rootOf(file), parsed: parseGoMod(readRepoText(boundary, file)) }))
11
+ const scopes = manifests.map((file) => ({ file, root: dependencyScopeRoot(file), parsed: parseGoMod(readRepoText(boundary, file)) }))
18
12
  .sort((left, right) => right.root.length - left.root.length || left.root.localeCompare(right.root));
19
13
  const importsByScope = new Map(scopes.map((scope) => [scope, []]));
20
14
  for (const entry of externalImports) {
21
15
  if (entry.ecosystem !== "Go") continue;
22
- const owner = scopes.find((scope) => owns(scope.root, entry.file));
16
+ const owner = scopes.find((scope) => dependencyScopeOwnsFile(scope.root, entry.file));
23
17
  if (owner) importsByScope.get(owner).push(entry);
24
18
  }
25
19
  const findings = [], declared = new Set(), issues = [];
@@ -5,14 +5,12 @@ import {readCoverageForRepo, normalizeRepoParts} from './coverage-reports.js'
5
5
  import {computeStaticTestReachability} from './static-test-reachability.js'
6
6
  import {createPathClassifier, hasPathClass} from '../path-classification.js'
7
7
  import {isStructuralRelation} from '../graph/relations.js'
8
+ import {boundedInteger} from '../util.js'
8
9
 
9
10
  const NON_PRODUCT = ['generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
10
11
  const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value || '')
11
12
  const normalize = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
12
- const boundedInt = (value, fallback, min, max) => {
13
- const number = Number(value)
14
- return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback
15
- }
13
+ const boundedInt = boundedInteger
16
14
  const round = (value) => Math.round(Number(value || 0) * 100) / 100
17
15
  const clamp = (value, min = 0, max = 100) => Math.max(min, Math.min(max, Number(value) || 0))
18
16
 
@@ -1,3 +1,5 @@
1
+ import { boundedInteger } from "../../util.js";
2
+
1
3
  export const HTTP_CONTRACTS_V = 2;
2
4
  export const HTTP_CONTRACT_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
3
5
 
@@ -38,10 +40,7 @@ export function normalizeContractFile(value) {
38
40
  return normalized.split("/").some((part) => !part || part === "." || part === "..") ? "" : normalized;
39
41
  }
40
42
 
41
- export function boundedInteger(value, fallback, min, max) {
42
- const number = Number(value);
43
- return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback;
44
- }
43
+ export { boundedInteger };
45
44
 
46
45
  export function safeContractName(value, fallback) {
47
46
  const text = String(value || "").trim();
@@ -12,15 +12,14 @@ export const readJson = (path) => {
12
12
  try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null }
13
13
  }
14
14
 
15
- export const readRepoText = (boundary, relativePath) => {
15
+ const readRepoFile = (boundary, relativePath, reader) => {
16
16
  const resolved = boundary.resolve(relativePath)
17
- return resolved.ok ? readText(resolved.path) : null
17
+ return resolved.ok ? reader(resolved.path) : null
18
18
  }
19
19
 
20
- export const readRepoJson = (boundary, relativePath) => {
21
- const resolved = boundary.resolve(relativePath)
22
- return resolved.ok ? readJson(resolved.path) : null
23
- }
20
+ export const readRepoText = (boundary, relativePath) => readRepoFile(boundary, relativePath, readText)
21
+
22
+ export const readRepoJson = (boundary, relativePath) => readRepoFile(boundary, relativePath, readJson)
24
23
 
25
24
  const SKIP_DIRS = new Set([
26
25
  '.git', '.hg', '.svn', 'node_modules', 'vendor', 'dist', 'build', 'coverage', '.next', 'out',
@@ -118,6 +118,20 @@ export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { dec
118
118
  if (target) entries.add(target);
119
119
  }
120
120
  }
121
+ // Drizzle Kit loads schema modules from configuration strings rather than a
122
+ // runtime import. Resolve the bounded `schema:` value so the schema and its
123
+ // exported tables are not reported as orphan/test-only production code.
124
+ for (const [rawFile, rawText] of sources || []) {
125
+ const configFile = String(rawFile || "").replace(/\\/g, "/");
126
+ if (!/(^|\/)drizzle(?:\.[^/]*)?\.config\.[cm]?[jt]s$/i.test(configFile)) continue;
127
+ const value = /\bschema\s*:\s*(\[[\s\S]{0,1000}?\]|["'][^"']+["'])/i.exec(String(rawText || ""))?.[1] || "";
128
+ const quoted = /["']([^"']+)["']/g;
129
+ let match;
130
+ while ((match = quoted.exec(value))) {
131
+ const candidate = posix.normalize(posix.join(posix.dirname(configFile), match[1].replace(/^\.\//, "")));
132
+ if (fileSet.has(candidate)) entries.add(candidate);
133
+ }
134
+ }
121
135
  for (const evidence of springConventionEntries(sources, fileSet)) {
122
136
  entries.add(evidence.file);
123
137
  conventionEvidence.push(evidence);
@@ -121,7 +121,7 @@ export async function runInternalAudit(repoPath, {
121
121
  ];
122
122
  const deadFileSet = new Set(dead.deadFiles.map((finding) => finding.file));
123
123
  for (const finding of structure.findings) {
124
- if (!(finding.rule === "orphan-file" && deadFileSet.has(finding.file))) findings.push(finding);
124
+ if (!(finding.rule === "orphan-file" && (deadFileSet.has(finding.file) || entries.has(finding.file)))) findings.push(finding);
125
125
  }
126
126
 
127
127
  const actionableDeadFiles = dead.deadFiles.filter((finding) => !isNonProductPath(finding.file));
@@ -142,7 +142,7 @@ export async function runInternalAudit(repoPath, {
142
142
  }
143
143
  let unusedExportCount = 0;
144
144
  for (const symbol of unusedExports) {
145
- if (symbol.test || isNonProductPath(symbol.file)) continue;
145
+ if (symbol.test || isNonProductPath(symbol.file) || entries.has(symbol.file) || dynamicTargets.has(symbol.file)) continue;
146
146
  if (/(^|\/)[^/]*\.config\.[a-z0-9]+$|(^|\/)\.[^/]+rc(\.[a-z]+)?$/i.test(symbol.file)) continue;
147
147
  findings.push(makeFinding({
148
148
  category: "unused",
@@ -159,7 +159,7 @@ export async function runInternalAudit(repoPath, {
159
159
  unusedExportCount++;
160
160
  }
161
161
  for (const symbol of dead.testOnlySymbols || []) {
162
- if (isNonProductPath(symbol.file)) continue;
162
+ if (isNonProductPath(symbol.file) || entries.has(symbol.file) || dynamicTargets.has(symbol.file)) continue;
163
163
  findings.push(makeFinding({
164
164
  category: "unused",
165
165
  rule: "test-only-symbol",
@@ -99,55 +99,6 @@ export function parsePyprojectDeps(text) {
99
99
  return { present, deps };
100
100
  }
101
101
 
102
- // ---- poetry.lock → [{name, version, deps}] — [[package]] blocks + their [package.dependencies] keys ----
103
- export function parsePoetryLockDeps(text) {
104
- const out = [];
105
- let cur = null, inDeps = false;
106
- for (const raw of String(text || "").split(/\r?\n/)) {
107
- const line = raw.trim();
108
- if (line === "[[package]]") { cur = { name: "", version: "", deps: [] }; out.push(cur); inDeps = false; continue; }
109
- if (line.startsWith("[")) { inDeps = cur != null && line === "[package.dependencies]"; if (line.startsWith("[[")) cur = null; continue; }
110
- if (!cur) continue;
111
- if (inDeps) { const m = line.match(/^["']?([A-Za-z0-9][\w.-]*)["']?\s*=/); if (m) cur.deps.push(m[1]); continue; }
112
- let m = line.match(/^name\s*=\s*"([^"]+)"/); if (m && !cur.name) { cur.name = m[1]; continue; }
113
- m = line.match(/^version\s*=\s*"([^"]+)"/); if (m && !cur.version) cur.version = m[1];
114
- }
115
- return out.filter((p) => p.name);
116
- }
117
-
118
- // ---- uv.lock → [{name, version, deps}] — [[package]] blocks with dependencies = [{ name = "x" }, …] ----
119
- export function parseUvLockDeps(text) {
120
- const out = [];
121
- let cur = null, inArr = false;
122
- for (const raw of String(text || "").split(/\r?\n/)) {
123
- const line = raw.trim();
124
- if (line === "[[package]]") { cur = { name: "", version: "", deps: [] }; out.push(cur); inArr = false; continue; }
125
- if (line.startsWith("[") && !inArr) { if (line.startsWith("[[")) cur = null; continue; }
126
- if (!cur) continue;
127
- if (inArr) { for (const m of line.matchAll(/name\s*=\s*"([^"]+)"/g)) cur.deps.push(m[1]); if (/\]\s*$/.test(line)) inArr = false; continue; }
128
- if (/^dependencies\s*=\s*\[/.test(line)) { for (const m of line.matchAll(/name\s*=\s*"([^"]+)"/g)) cur.deps.push(m[1]); inArr = !/\]\s*$/.test(line); continue; }
129
- let m = line.match(/^name\s*=\s*"([^"]+)"/); if (m && !cur.name) { cur.name = m[1]; continue; }
130
- m = line.match(/^version\s*=\s*"([^"]+)"/); if (m && !cur.version) cur.version = m[1];
131
- }
132
- return out.filter((p) => p.name);
133
- }
134
-
135
- // ---- installed dist-info METADATA → { name, version, deps, repository } (headers until the first blank
136
- // line; Requires-Dist entries gated behind `extra ==` markers are optional extras — skipped) ----
137
- export function parseDistMetadata(text) {
138
- const out = { name: "", version: "", deps: [], repository: "" };
139
- for (const raw of String(text || "").split(/\r?\n/)) {
140
- if (raw.trim() === "") break;
141
- let m = raw.match(/^Name:\s*(.+)$/i); if (m) { out.name = m[1].trim(); continue; }
142
- m = raw.match(/^Version:\s*(.+)$/i); if (m) { out.version = m[1].trim(); continue; }
143
- m = raw.match(/^Requires-Dist:\s*([A-Za-z0-9][\w.-]*)(.*)$/i);
144
- if (m) { if (!/extra\s*==/.test(m[2])) out.deps.push(m[1]); continue; }
145
- m = raw.match(/^(?:Home-page:\s*|Project-URL:\s*(?:Source|Repository|Homepage|Home|Code)[^,]*,\s*)(https?:\/\/\S+)/i);
146
- if (m && !out.repository) out.repository = m[1];
147
- }
148
- return out;
149
- }
150
-
151
102
  // ---- Pipfile → { present, deps: [{name, dev}] } — [packages] / [dev-packages] table keys ----
152
103
  export function parsePipfileDeps(text) {
153
104
  const deps = [];
@@ -1,13 +1,8 @@
1
1
  import {ENTRY_FILE} from '../dead-check.js'
2
+ import {fileOfId, graphEndpointId} from '../../graph/node-id.js'
2
3
 
3
4
  const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i
4
5
  const NON_CODE_RE = /\.(json|ya?ml|sh|ps1|md|txt|html?|css|scss|less)$|(^|[/])(dockerfile|containerfile)/i
5
- const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
6
- const fileOf = (value) => {
7
- const id = endpoint(value)
8
- const hash = id.indexOf('#')
9
- return hash < 0 ? id : id.slice(0, hash)
10
- }
11
6
  const dirOf = (file) => file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : ''
12
7
 
13
8
  export function buildFileImportGraph(graph, {includeTypeOnly = false, includeCompileOnly = false} = {}) {
@@ -23,7 +18,7 @@ export function buildFileImportGraph(graph, {includeTypeOnly = false, includeCom
23
18
  }
24
19
  for (const link of graph.links || []) {
25
20
  if (link.relation !== 'imports' && link.relation !== 're_exports') continue
26
- const source = endpoint(link.source), target = endpoint(link.target)
21
+ const source = graphEndpointId(link.source), target = graphEndpointId(link.target)
27
22
  if (!fileIds.has(source) || !fileIds.has(target) || source === target) continue
28
23
  if (source.endsWith('.go') && target.endsWith('.go') && dirOf(source) === dirOf(target)) continue
29
24
  const key = `${source}\0${target}`
@@ -112,7 +107,7 @@ export function findOrphans(graph, {entrySet = new Set(), externalImportFiles =
112
107
  const degree = new Map()
113
108
  for (const link of graph.links || []) {
114
109
  if (link.relation === 'contains') continue
115
- const source = fileOf(link.source), target = fileOf(link.target)
110
+ const source = fileOfId(graphEndpointId(link.source)), target = fileOfId(graphEndpointId(link.target))
116
111
  if (source === target) continue
117
112
  degree.set(source, (degree.get(source) || 0) + 1)
118
113
  degree.set(target, (degree.get(target) || 0) + 1)
@@ -115,6 +115,25 @@ function gitFileUniverse(dir) {
115
115
  });
116
116
  } catch { return null; }
117
117
 
118
+ // `git -C <dir>` also succeeds when <dir> is merely nested under some parent repository. If that
119
+ // nested directory is ignored by the parent, `ls-files` is empty even though <dir> can be a valid
120
+ // standalone repository root selected by the user (or a repository staged by a test/tool). In
121
+ // that one ambiguous case, fall back to the boundary-safe walker. Preserve an empty universe for
122
+ // an actual Git root so ignored files do not leak back into its graph.
123
+ if (!raw) {
124
+ try {
125
+ const top = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
126
+ encoding: "utf8",
127
+ windowsHide: true,
128
+ stdio: ["ignore", "pipe", "ignore"],
129
+ timeout: 15_000,
130
+ maxBuffer: 1024 * 1024,
131
+ env: childProcessEnv(),
132
+ }).trim();
133
+ if (realpathSync.native(top) !== realpathSync.native(dir)) return null;
134
+ } catch { return null; }
135
+ }
136
+
118
137
  let rootReal;
119
138
  try { rootReal = realpathSync.native(dir); } catch { return null; }
120
139
  const files = [];
@@ -0,0 +1,7 @@
1
+ export const graphEndpointId = (value) => String(value && typeof value === "object" ? value.id : value);
2
+
3
+ export const fileOfId = (value) => {
4
+ const id = String(value || "");
5
+ const hash = id.indexOf("#");
6
+ return hash < 0 ? id : id.slice(0, hash);
7
+ };
@@ -49,13 +49,13 @@ function materializeCandidate(candidate, verification, baselineMode) {
49
49
  })
50
50
  }
51
51
 
52
- function activeContract(ctx) {
52
+ export function activeArchitectureContract(ctx) {
53
53
  if (!ctx?.repoRoot) return {contract: null, source: null, error: 'no repository root is active'}
54
54
  return loadArchitectureContract(ctx.repoRoot, ctx.graphPath)
55
55
  }
56
56
 
57
57
  function preview(g, args, ctx) {
58
- const active = activeContract(ctx)
58
+ const active = activeArchitectureContract(ctx)
59
59
  if (active.contract) return toolResult(
60
60
  `Architecture bootstrap refused: an active contract already exists at ${active.source}.`,
61
61
  {state: 'ALREADY_CONFIGURED', source: active.source, contractHash: active.contract.contractHash},
@@ -122,7 +122,7 @@ function approve(g, args, ctx) {
122
122
  state: 'REPOSITORY_CHANGED', wrote: false,
123
123
  })
124
124
  }
125
- const active = activeContract(ctx)
125
+ const active = activeArchitectureContract(ctx)
126
126
  if (active.contract || active.error) return toolResult(
127
127
  `Architecture approval refused: ${active.source || 'a contract'} appeared or changed after preview.`,
128
128
  {state: active.error ? 'ERROR' : 'ALREADY_CONFIGURED', wrote: false, source: active.source, error: active.error || undefined},
@@ -83,9 +83,7 @@ export function moduleId(value) {
83
83
  }
84
84
 
85
85
  export function nonNegativeInteger(value) {
86
- return typeof value === 'number' && Number.isFinite(value) && value >= 0
87
- ? Math.trunc(value)
88
- : 0
86
+ return optionalNonNegativeInteger(value) ?? 0
89
87
  }
90
88
 
91
89
  export function optionalNonNegativeInteger(value) {
@@ -3,6 +3,8 @@ import {
3
3
  normalizeCheckState, numericRecord, optionalNonNegativeInteger, privacySafeText, repoRelativePath,
4
4
  sanitizeFinding,
5
5
  } from './evidence-snapshot.common.mjs'
6
+ import {summarizeFindings} from '../analysis/findings.js'
7
+ import {auditFindingPathScope} from './health/audit-format.mjs'
6
8
 
7
9
  function buildHotspots(graph) {
8
10
  let analyzed = 0
@@ -40,7 +42,7 @@ function buildHotspots(graph) {
40
42
  return {analyzed, ...bounded(facts, CAPS.hotspots)}
41
43
  }
42
44
 
43
- export function buildHealthSection(graph, audit) {
45
+ export function buildHealthSection(graph, audit, repoRoot = null) {
44
46
  if (!audit?.ok) {
45
47
  return {
46
48
  state: STATE.ERROR,
@@ -52,7 +54,12 @@ export function buildHealthSection(graph, audit) {
52
54
  complexity: {thresholds: COMPLEXITY_THRESHOLDS, analyzed: 0, hotspots: []},
53
55
  }
54
56
  }
55
- const findings = bounded((audit.findings || []).map(sanitizeFinding).filter(Boolean)
57
+ // Public/private hosted evidence follows the same production-first path policy as
58
+ // run_audit. Classified-only findings remain available locally through
59
+ // include_classified:true, but must not turn a hosted production snapshot red.
60
+ const scopedFindings = auditFindingPathScope(audit.findings, {repoRoot}).findings
61
+ const scopedSummary = summarizeFindings(scopedFindings)
62
+ const findings = bounded(scopedFindings.map(sanitizeFinding).filter(Boolean)
56
63
  .sort((a, b) => compareText(a.severity, b.severity) || compareText(a.category, b.category) ||
57
64
  compareText(a.rule, b.rule) || compareText(a.file || a.package || '', b.file || b.package || '') || compareText(a.id, b.id)),
58
65
  CAPS.findings)
@@ -76,8 +83,8 @@ export function buildHealthSection(graph, audit) {
76
83
  reasons: state === STATE.PARTIAL ? ['OPTIONAL_CHECKS_INCOMPLETE'] : [],
77
84
  },
78
85
  summary: {
79
- bySeverity: numericRecord(audit.summary?.bySeverity, ['critical', 'high', 'medium', 'low', 'info']),
80
- byCategory: numericRecord(audit.summary?.byCategory, ['unused', 'structure', 'vulnerability', 'malware']),
86
+ bySeverity: numericRecord(scopedSummary.bySeverity, ['critical', 'high', 'medium', 'low', 'info']),
87
+ byCategory: numericRecord(scopedSummary.byCategory, ['unused', 'structure', 'vulnerability', 'malware']),
81
88
  dead: numericRecord(audit.deadReport, ['deadSymbols', 'deadFiles', 'unusedExports']),
82
89
  structure: numericRecord(audit.structureReport, [
83
90
  'runtimeImportEdges', 'typeOnlyImportEdges', 'compileOnlyImportEdges', 'runtimeCycles',
@@ -35,7 +35,7 @@ export async function createEvidenceSnapshot({repoRoot, graph}) {
35
35
  const sections = {
36
36
  architecture: buildArchitectureSection(inputGraph, aggregate, audit, structure),
37
37
  duplicates: buildDuplicatesSection(repoRoot, inputGraph),
38
- health: buildHealthSection(inputGraph, audit),
38
+ health: buildHealthSection(inputGraph, audit, repoRoot),
39
39
  technologies: buildTechnologiesSection(stack, stackError),
40
40
  packages: buildPackagesSection(installedResult, installedError, inputGraph, audit, repoRoot),
41
41
  }
@@ -2,6 +2,7 @@
2
2
  import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
3
3
  import {folderModuleOf} from '../analysis/graph-analysis.aggregate.js'
4
4
  import {isStructuralRelation} from '../graph/relations.js'
5
+ import {fileOfId} from '../graph/node-id.js'
5
6
 
6
7
  // ---- graph diff ----------------------------------------------------------------------------------
7
8
  // One previous state is enough (4-13 MB per repo — cheap): rebuild_graph snapshots the outgoing
@@ -10,7 +11,7 @@ import {isStructuralRelation} from '../graph/relations.js'
10
11
  // the signal is aggregated: module-dependency drift, cycle count changes, newly orphaned symbols.
11
12
  export const prevGraphPathFor = (graphPath) => String(graphPath).replace(/\.json$/, '.prev.json')
12
13
  export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
13
- export const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
14
+ export {fileOfId}
14
15
  const folderOfFile = folderModuleOf
15
16
 
16
17
  const terminalLineSuffix = (id) => {
@@ -1,6 +1,6 @@
1
1
  // Executable target architecture for agents: read the active contract before a change, then run the
2
2
  // same deterministic verifier after it. No tool silently edits policy or accepts debt.
3
- import {contractForChange, loadArchitectureContract, normalizeArchitectureContract, verifyArchitecture} from '../analysis/architecture-contract.js'
3
+ import {contractForChange, normalizeArchitectureContract, verifyArchitecture} from '../analysis/architecture-contract.js'
4
4
  import {createPathClassifier, hasPathClass} from '../path-classification.js'
5
5
  import {detectRepoStack} from '../scan/discover.js'
6
6
  import {toolResult} from './tool-result.mjs'
@@ -13,6 +13,7 @@ const [starter, bootstrap] = await Promise.all([
13
13
  ])
14
14
  const {createArchitectureStarter, PROVISIONAL_BUDGETS} = starter
15
15
  export const tBootstrapArchitecture = bootstrap.tBootstrapArchitecture
16
+ const activeContract = bootstrap.activeArchitectureContract
16
17
 
17
18
  const remediation = () => ({
18
19
  offlinePath: '.weavatrix/architecture.json',
@@ -29,11 +30,6 @@ const stackIds = (repoRoot) => {
29
30
  } catch { return [] }
30
31
  }
31
32
 
32
- function activeContract(ctx) {
33
- if (!ctx?.repoRoot) return {contract: null, source: null, error: 'no repository root is active'}
34
- return loadArchitectureContract(ctx.repoRoot, ctx.graphPath)
35
- }
36
-
37
33
  function notConfiguredResult(g, action, {includeStarter = false, repoRoot = null} = {}) {
38
34
  const proposal = includeStarter ? createArchitectureStarter(g, repoRoot) : null
39
35
  const starter = proposal?.contract || null
@@ -2,6 +2,7 @@
2
2
  // classifier/evidence contract can be tested without perturbing get_dependents or graph_diff.
3
3
  import {spawnSync} from 'node:child_process'
4
4
  import {childProcessEnv} from '../child-env.js'
5
+ import {fileOfId} from '../graph/node-id.js'
5
6
  import {classifyChangeImpact} from '../analysis/change-classification.js'
6
7
  import {readCoverageForRepo} from '../analysis/coverage-reports.js'
7
8
  import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
@@ -29,12 +30,6 @@ const impactKind = (entry) => entry?.runtimeDepth != null
29
30
  ? (entry.compileDepth != null ? `runtime + compile-time(d${entry.compileDepth})` : 'runtime')
30
31
  : 'compile-time'
31
32
 
32
- const fileOfNode = (id) => {
33
- const value = String(id)
34
- const hash = value.indexOf('#')
35
- return hash < 0 ? value : value.slice(0, hash)
36
- }
37
-
38
33
  const partialResult = (text, reason, status = 'UNAVAILABLE') => toolResult(text, {
39
34
  status,
40
35
  verdict: 'HIGH',
@@ -105,8 +100,8 @@ export function tChangeImpactV2(g, args = {}, ctx = {}) {
105
100
  const cap = Math.max(5, Math.min(120, Number(args.max_nodes) || 40))
106
101
  const reached = seeds.size ? reverseReach(g, seeds, maxDepth) : new Map()
107
102
  const impacted = [...reached.entries()]
108
- .filter(([id]) => !seeds.has(id) && !changedSet.has(fileOfNode(id)))
109
- .map(([id, entry]) => ({id, depth: entry.depth, entry, degree: degreeOf(g, id), file: fileOfNode(id)}))
103
+ .filter(([id]) => !seeds.has(id) && !changedSet.has(fileOfId(id)))
104
+ .map(([id, entry]) => ({id, depth: entry.depth, entry, degree: degreeOf(g, id), file: fileOfId(id)}))
110
105
  .sort((left, right) => left.depth - right.depth
111
106
  || Number(left.entry.compileOnly) - Number(right.entry.compileOnly)
112
107
  || right.degree - left.degree
@@ -22,6 +22,10 @@ function addTyposquatCoSignals(byPkg) {
22
22
  function visibleSignals(signals, allow) {
23
23
  const kept = signals.filter((s) => !(allow && s.noisy));
24
24
  if (!kept.length) return [];
25
+ // A literal URL, or even a network call to one, is ordinary package behavior without a second
26
+ // security signal. Keep those weak observations available as co-evidence (for example beside
27
+ // environment harvesting), but never create a standalone malware finding from them.
28
+ if (!kept.some((s) => !s.weak)) return [];
25
29
  const hasStrongerUrl = kept.some((s) => STRONG_URL_KEYS.has(s.key));
26
30
  return hasStrongerUrl ? kept.filter((s) => s.key !== "hardcoded-url") : kept;
27
31
  }
@@ -90,6 +90,7 @@ export const CONTENT_RULES = [
90
90
  severity: "low",
91
91
  nearZeroFp: false,
92
92
  noisy: true,
93
+ weak: true,
93
94
  pattern: "(fetch|axios\\.|XMLHttpRequest|sendBeacon|https?\\.request|request\\(|curl\\b|wget\\b).{0,220}https?://",
94
95
  re: new RegExp(`\\b(fetch|axios\\.(get|post|put|patch|request)|XMLHttpRequest|sendBeacon|https?\\.request|http\\.request|request\\(|curl\\b|wget\\b)[\\s\\S]{0,220}?${PLAIN_EXTERNAL_URL}`, "i"),
95
96
  what: "network call to hardcoded external URL",
@@ -172,9 +173,9 @@ export const CONTENT_RULES = [
172
173
  key: "hidden-unicode",
173
174
  severity: "medium",
174
175
  nearZeroFp: false,
175
- pattern: "[\\x{202A}-\\x{202E}\\x{2066}-\\x{2069}\\x{FE00}-\\x{FE0F}]",
176
- re: new RegExp("\\u202A|\\u202B|\\u202C|\\u202D|\\u202E|\\u2066|\\u2067|\\u2068|\\u2069|\\uFE00|\\uFE01|\\uFE02|\\uFE03|\\uFE04|\\uFE05|\\uFE06|\\uFE07|\\uFE08|\\uFE09|\\uFE0A|\\uFE0B|\\uFE0C|\\uFE0D|\\uFE0E|\\uFE0F", "u"),
177
- what: "hidden Unicode control/variation character in package source",
176
+ pattern: "[\\x{202A}-\\x{202E}\\x{2066}-\\x{2069}]",
177
+ re: new RegExp("\\u202A|\\u202B|\\u202C|\\u202D|\\u202E|\\u2066|\\u2067|\\u2068|\\u2069", "u"),
178
+ what: "hidden bidirectional Unicode control character in package source",
178
179
  },
179
180
  {
180
181
  key: "obfuscated-code",
@@ -31,7 +31,9 @@ const KNOWN_LEGIT = new Set([
31
31
  "markdown-it", "babel-loader", "css-loader", "style-loader", "query-string",
32
32
  ]);
33
33
 
34
- const norm = (name) => String(name || "").toLowerCase().replace(/^@[^/]+\//, ""); // drop scope for the compare
34
+ const rawName = (name) => String(name || "").trim().toLowerCase();
35
+ const isScoped = (name) => /^@[^/]+\/.+/.test(name);
36
+ const unscopedTail = (name) => name.replace(/^@[^/]+\//, "");
35
37
 
36
38
  // Damerau-Levenshtein (optimal string alignment) — includes adjacent transposition.
37
39
  export function damerau(a, b) {
@@ -56,12 +58,19 @@ export function damerau(a, b) {
56
58
  // Threshold: distance 1 always; distance 2 only for names ≥8 chars (short names collide too easily).
57
59
  // Skip: exact popular names, known-legit pairs, names <4 chars.
58
60
  export function classifyTyposquat(name) {
59
- const raw = String(name || "");
60
- const n = norm(raw);
61
- if (n.length < 4 || TOP_PACKAGES.has(raw) || TOP_PACKAGES.has(n) || KNOWN_LEGIT.has(raw) || KNOWN_LEGIT.has(n)) return null;
61
+ const raw = rawName(name);
62
+ const scoped = isScoped(raw);
63
+ const tail = unscopedTail(raw);
64
+ // A package scope is an ownership boundary, not punctuation to discard.
65
+ // Comparing every scoped basename with unscoped popular names made the
66
+ // official @playwright/test package look like a one-edit misspelling of jest.
67
+ if (tail.length < 4 || TOP_PACKAGES.has(raw) || TOP_PACKAGES.has(tail)
68
+ || KNOWN_LEGIT.has(raw) || KNOWN_LEGIT.has(tail)) return null;
69
+ const candidates = [...TOP_PACKAGES].filter((top) => isScoped(top) === scoped);
70
+ const n = scoped ? raw : tail;
62
71
  let best = null;
63
- for (const top of TOP_PACKAGES) {
64
- const t = norm(top);
72
+ for (const top of candidates) {
73
+ const t = rawName(top);
65
74
  if (t === n) return null; // scope-only difference of a popular name → not a squat
66
75
  const dist = damerau(n, t);
67
76
  if (dist === 0) return null;
package/src/util.js CHANGED
@@ -24,6 +24,11 @@ export function lineNumberAt(text, index) {
24
24
  return line;
25
25
  }
26
26
 
27
+ export function boundedInteger(value, fallback, min, max) {
28
+ const number = Number(value);
29
+ return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback;
30
+ }
31
+
27
32
  export function safeRead(path) {
28
33
  try {
29
34
  const st = statSync(path);
@@ -1,53 +0,0 @@
1
- # Weavatrix 0.2.16
2
-
3
- 0.2.16 is a semantic-precision, dependency-coverage, and cross-transport contract release. It keeps
4
- the 38-tool MCP surface and local-first defaults while fixing the critical case where a safe
5
- TypeScript project plugin could disable every exact reference edge.
6
-
7
- ## Exact dependents for real TypeScript projects
8
-
9
- - TypeScript project plugins such as Next.js are recorded and suppressed instead of making the
10
- semantic overlay unavailable. Repository-local plugin code is never loaded.
11
- - `get_dependents` performs a bounded exact point query for JS/TS symbols by default. A complete LSP
12
- result replaces heuristic direct references; an incomplete result retains graph evidence and says
13
- why.
14
- - `change_impact` batches exact direct-reference queries for changed JS/TS symbols. Transitive hops
15
- remain explicitly graph-backed instead of being presented as LSP proof.
16
- - Real-repository validation moved an affected Next.js repository from `UNAVAILABLE / 0 EXACT_LSP`
17
- to a bounded partial overlay with 154 exact edges; a point query outside the initial budget found
18
- 16 exact direct references and then served the same result from cache.
19
-
20
- ## Multi-ecosystem dependency and advisory evidence
21
-
22
- - Python checks nested requirements, pyproject/Poetry/Pipfile scopes; Go resolves nested modules,
23
- direct/indirect requirements and replacements; both compare declarations with indexed imports.
24
- - Maven resolves properties and dependency declarations; Gradle resolves common Groovy/Kotlin
25
- declarations, version catalogs and lock evidence. Import-to-artifact mapping uses bounded group
26
- prefixes and artifact tokens and keeps reflection/generated/runtime-only cases review-only.
27
- - Cargo resolves workspace inheritance, renamed packages and lockfile pins, compares crate paths to
28
- the nearest manifest, and reports feature/proc-macro uncertainty rather than claiming compiler
29
- proof.
30
- - Explicit advisory refresh now includes concrete Maven and crates.io pins in addition to npm,
31
- PyPI and Go. It remains opt-in and sends only package identities and versions to OSV.
32
-
33
- ## Contracts beyond static HTTP
34
-
35
- - `trace_api_contract` can select HTTP, GraphQL, gRPC or event contracts. Static GraphQL
36
- operations/schema fields, proto service methods/stub calls and Kafka/event topics join across
37
- registered local repositories.
38
- - Runtime-computed URLs, operations/topics, reflection and dynamic stubs remain `UNKNOWN` with
39
- bounded evidence. Runtime configuration is never converted into a false clean result.
40
-
41
- ## Agent and Hosted fixes
42
-
43
- - Package metadata now names Codex/OpenAI Codex alongside other MCP and coding-agent workflows.
44
- - The Hosted Flow view distinguishes an aggregate folder-boundary feedback loop from a proven
45
- file-level runtime import cycle. Current Weavatrix evidence has zero file-level runtime cycles.
46
- - The release checklist includes a fresh preview-confirmed private graph sync, so public demo history
47
- and authenticated repository maps cannot silently drift apart.
48
-
49
- ## Verification
50
-
51
- The release is gated by the full Node suite, six-language fixture benchmark, real-repository
52
- semantic checks, exact LSP dogfooding, release-manifest verification, packed npm and MCPB stdio
53
- checks, npm audit, Hosted tests/build/lint, explicit private sync, and live browser verification.