weavatrix 0.2.16 → 0.2.18
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 +46 -0
- package/docs/adr/0001-v0.3-offline-online-split.md +58 -0
- package/docs/releases/v0.2.18.md +41 -0
- package/package.json +3 -2
- package/src/analysis/cargo-dependency-evidence.js +3 -9
- package/src/analysis/dependency/scoped-dependencies.js +13 -7
- package/src/analysis/duplicates.tokenize.js +13 -6
- package/src/analysis/git-history/options.js +2 -4
- package/src/analysis/go-dependency-evidence.js +3 -9
- package/src/analysis/hot-path-review.js +2 -4
- package/src/analysis/http-contracts/shared.js +3 -4
- package/src/analysis/internal-audit/repo-files.js +5 -6
- package/src/analysis/internal-audit.reach.js +14 -0
- package/src/analysis/internal-audit.run.js +3 -3
- package/src/analysis/manifests.js +0 -49
- package/src/analysis/structure/dependency-graph.js +3 -8
- package/src/graph/internal-builder.langs.js +19 -0
- package/src/graph/node-id.js +7 -0
- package/src/mcp/architecture-bootstrap.mjs +3 -3
- package/src/mcp/evidence-snapshot.common.mjs +1 -3
- package/src/mcp/evidence-snapshot.health.mjs +11 -4
- package/src/mcp/evidence-snapshot.mjs +1 -1
- package/src/mcp/graph-diff.mjs +2 -1
- package/src/mcp/tools-architecture.mjs +2 -6
- package/src/mcp/tools-impact-change.mjs +3 -8
- package/src/security/typosquat.js +15 -6
- package/src/util.js +5 -0
- package/docs/releases/v0.2.16.md +0 -53
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.18 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,32 @@ 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.18 repository-root and self-audit trust patch
|
|
434
|
+
|
|
435
|
+
- A directory nested under an ignored parent Git repository is no longer mistaken for an empty
|
|
436
|
+
repository. The internal builder now falls back to its boundary-safe walker for that ambiguous
|
|
437
|
+
case while preserving Git ignore semantics at a real repository root.
|
|
438
|
+
- This is the first npm release containing the 0.2.17 self-audit work: Drizzle reachability,
|
|
439
|
+
scoped typosquat precision, regex-aware clone anchors, dead parser removal and shared owners.
|
|
440
|
+
- The 0.3 boundary remains unchanged: this offline package stays MIT, and the separately licensed
|
|
441
|
+
online connector owns outbound HTTP after the major split.
|
|
442
|
+
|
|
443
|
+
Full patch notes: [docs/releases/v0.2.18.md](docs/releases/v0.2.18.md).
|
|
444
|
+
|
|
445
|
+
### 0.2.17 self-audit trust patch (tagged, not published to npm)
|
|
446
|
+
|
|
447
|
+
- Health no longer treats configured Drizzle schema modules as orphaned/test-only production code,
|
|
448
|
+
and scoped typosquat checks no longer compare legitimate scoped packages with unrelated unscoped
|
|
449
|
+
names.
|
|
450
|
+
- Duplicate detection retains equality anchors for executable regular-expression bodies, preventing
|
|
451
|
+
unrelated validation/normalization pipelines from becoming perfect renamed clones.
|
|
452
|
+
- Dogfooding removed obsolete test-only parsers and consolidated dependency-scope, graph-ID,
|
|
453
|
+
architecture-contract, bounded-option and safe repository-read helpers.
|
|
454
|
+
- The accepted 0.3 product boundary keeps the offline engine MIT and moves all outbound HTTP tools
|
|
455
|
+
into the separately licensed `weavatrix-online` connector.
|
|
456
|
+
|
|
457
|
+
Full patch notes: [docs/releases/v0.2.17.md](docs/releases/v0.2.17.md).
|
|
458
|
+
|
|
413
459
|
### 0.2.16 exact dependents, multi-ecosystem dependencies and transport contracts
|
|
414
460
|
|
|
415
461
|
- 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,41 @@
|
|
|
1
|
+
# Weavatrix 0.2.18
|
|
2
|
+
|
|
3
|
+
0.2.18 is the publishable repository-root and self-audit trust patch. The `v0.2.17` source tag was
|
|
4
|
+
created, but its release validation exposed the nested-root discovery regression before an npm
|
|
5
|
+
package was published. The tag remains immutable; 0.2.18 contains that release plus the fix.
|
|
6
|
+
|
|
7
|
+
## Repository discovery
|
|
8
|
+
|
|
9
|
+
- The internal builder distinguishes an actual Git root from a directory that merely lives below
|
|
10
|
+
another Git repository.
|
|
11
|
+
- When the selected nested directory is ignored by its parent and Git returns an ambiguous empty
|
|
12
|
+
file universe, Weavatrix uses its boundary-safe filesystem walker instead of building a zero-node
|
|
13
|
+
graph.
|
|
14
|
+
- A real Git root with an empty tracked/unignored universe stays empty, so ignored build output and
|
|
15
|
+
secrets are not reintroduced.
|
|
16
|
+
- The live stdio hot-reload regression now exercises the corrected path and passes independently,
|
|
17
|
+
matching the Linux release runner rather than depending on full-suite ordering.
|
|
18
|
+
|
|
19
|
+
## Included 0.2.17 trust work
|
|
20
|
+
|
|
21
|
+
- Drizzle configuration roots its schema modules in production reachability.
|
|
22
|
+
- Scoped typosquat review compares only compatible scoped targets.
|
|
23
|
+
- Executable regular expressions retain equality anchors for clone fingerprints without exposing
|
|
24
|
+
their source text.
|
|
25
|
+
- Obsolete test-only dependency parsers were removed and repeated dependency, graph and architecture
|
|
26
|
+
helpers were consolidated under shared owners.
|
|
27
|
+
- Hosted evidence applies the same production-first classification and recomputes bounded summaries
|
|
28
|
+
after filtering.
|
|
29
|
+
|
|
30
|
+
## Product boundary
|
|
31
|
+
|
|
32
|
+
- `weavatrix` remains MIT and offline-first. No license change is made here.
|
|
33
|
+
- The accepted 0.3 split moves outbound HTTP into the separately versioned `weavatrix-online`
|
|
34
|
+
connector, whose permission-required license remains a release gate.
|
|
35
|
+
|
|
36
|
+
## Verification
|
|
37
|
+
|
|
38
|
+
Release validation covers the full Node test suite on supported Node versions, independent hot
|
|
39
|
+
reload execution, release-manifest checks, npm/MCPB packing and runtime smoke tests. The paired
|
|
40
|
+
Hosted deployment is rebuilt from this graph, then verified through its live Health and History
|
|
41
|
+
views.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weavatrix",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
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/
|
|
29
|
+
"docs/adr/0001-v0.3-offline-online-split.md",
|
|
30
|
+
"docs/releases/v0.2.18.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:
|
|
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) =>
|
|
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
|
-
|
|
2
|
-
|
|
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:
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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(
|
|
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 =
|
|
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:
|
|
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) =>
|
|
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 =
|
|
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
|
|
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
|
-
|
|
15
|
+
const readRepoFile = (boundary, relativePath, reader) => {
|
|
16
16
|
const resolved = boundary.resolve(relativePath)
|
|
17
|
-
return resolved.ok ?
|
|
17
|
+
return resolved.ok ? reader(resolved.path) : null
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export const
|
|
21
|
-
|
|
22
|
-
|
|
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 =
|
|
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 =
|
|
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 = [];
|
|
@@ -49,13 +49,13 @@ function materializeCandidate(candidate, verification, baselineMode) {
|
|
|
49
49
|
})
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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(
|
|
80
|
-
byCategory: numericRecord(
|
|
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
|
}
|
package/src/mcp/graph-diff.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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(
|
|
109
|
-
.map(([id, entry]) => ({id, depth: entry.depth, entry, degree: degreeOf(g, id), file:
|
|
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
|
|
@@ -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
|
|
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 =
|
|
60
|
-
const
|
|
61
|
-
|
|
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
|
|
64
|
-
const t =
|
|
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);
|
package/docs/releases/v0.2.16.md
DELETED
|
@@ -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.
|