weavatrix 0.2.14 → 0.2.16
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 +69 -13
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.16.md +53 -0
- package/package.json +6 -2
- package/src/analysis/cargo-dependency-evidence.js +111 -0
- package/src/analysis/cargo-manifests.js +74 -0
- package/src/analysis/dep-check-ecosystems.js +3 -0
- package/src/analysis/dep-check.js +8 -18
- package/src/analysis/dependency/conventions.js +16 -0
- package/src/analysis/dependency/scoped-dependencies.js +7 -2
- package/src/analysis/dependency/source-references.js +21 -0
- package/src/analysis/duplicates.tokenize.js +12 -2
- package/src/analysis/endpoints-java.js +2 -5
- package/src/analysis/findings.js +12 -0
- package/src/analysis/go-dependency-evidence.js +68 -0
- package/src/analysis/health-capabilities.js +1 -1
- package/src/analysis/http-contracts/analysis.js +2 -0
- package/src/analysis/http-contracts/client-call-detection.js +1 -0
- package/src/analysis/http-contracts/client-call-parser.js +18 -4
- package/src/analysis/internal-audit/dependency-health.js +15 -13
- package/src/analysis/internal-audit/python-manifests.js +18 -5
- package/src/analysis/internal-audit/supply-chain.js +2 -0
- package/src/analysis/internal-audit.reach.js +20 -0
- package/src/analysis/internal-audit.run.js +13 -7
- package/src/analysis/jvm-dependency-evidence.js +102 -56
- package/src/analysis/jvm-manifests.js +114 -0
- package/src/analysis/source-correctness.js +2 -5
- package/src/analysis/task-retrieval.js +3 -14
- package/src/analysis/transport-contracts.js +157 -0
- package/src/graph/builder/lang-go.js +4 -2
- package/src/graph/builder/lang-java.js +12 -3
- package/src/graph/builder/lang-rust.js +22 -3
- package/src/graph/freshness-probe.js +1 -1
- package/src/graph/internal-builder.build.js +2 -2
- package/src/graph/internal-builder.resolvers.js +18 -8
- package/src/mcp/actions/advisories.mjs +2 -3
- package/src/mcp/catalog.mjs +7 -4
- package/src/mcp/company-contract-verdict.mjs +42 -0
- package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
- package/src/mcp/git-output.mjs +10 -0
- package/src/mcp/graph/context-seeds.mjs +3 -14
- package/src/mcp/graph/reverse-reach.mjs +42 -0
- package/src/mcp/sync/evidence-duplicates.mjs +1 -4
- package/src/mcp/tools-company.mjs +28 -53
- package/src/mcp/tools-impact-change.mjs +2 -38
- package/src/mcp/tools-impact-precision.mjs +89 -0
- package/src/mcp/tools-impact.mjs +53 -136
- package/src/path-classification.js +14 -0
- package/src/precision/lsp-overlay/build.js +10 -0
- package/src/precision/lsp-overlay/contract.js +1 -1
- package/src/precision/symbol-query.js +39 -0
- package/src/precision/typescript-provider/client.js +16 -3
- package/src/precision/typescript-provider/discovery.js +1 -1
- package/src/precision/typescript-provider/isolated-runtime.js +39 -0
- package/src/precision/typescript-provider/project-host.js +11 -6
- package/src/precision/typescript-provider/project-safety.js +5 -0
- package/src/security/advisory-store.js +2 -2
- package/src/security/installed-jvm-rust.js +46 -0
- package/src/security/installed.js +21 -1
- package/src/security/malware-heuristics.sweep.js +1 -1
- package/src/security/registry-sig.classify.js +2 -1
- package/src/security/registry-sig.rules.js +3 -3
- package/src/util.js +8 -0
- package/docs/releases/v0.2.14.md +0 -93
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const stripComments = (value) => String(value || "").replace(/<!--[\s\S]*?-->/g, " ");
|
|
2
|
+
const clean = (value) => String(value || "").trim().replace(/^['"]|['"]$/g, "");
|
|
3
|
+
|
|
4
|
+
function xmlValue(body, name) {
|
|
5
|
+
return new RegExp(`<${name}>\\s*([^<]+?)\\s*</${name}>`, "i").exec(body)?.[1]?.trim() || "";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function resolveMavenValue(value, properties) {
|
|
9
|
+
let output = String(value || "");
|
|
10
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
11
|
+
let changed = false;
|
|
12
|
+
output = output.replace(/\$\{([^}]+)}/g, (whole, key) => {
|
|
13
|
+
if (!properties.has(key)) return whole;
|
|
14
|
+
changed = true;
|
|
15
|
+
return properties.get(key);
|
|
16
|
+
});
|
|
17
|
+
if (!changed) break;
|
|
18
|
+
}
|
|
19
|
+
return /\$\{/.test(output) ? "" : output.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseMavenPom(text) {
|
|
23
|
+
const source = stripComments(text);
|
|
24
|
+
const properties = new Map();
|
|
25
|
+
const propertyBlock = /<properties\b[^>]*>([\s\S]*?)<\/properties>/i.exec(source)?.[1] || "";
|
|
26
|
+
for (const match of propertyBlock.matchAll(/<([A-Za-z0-9_.-]+)>\s*([^<]+?)\s*<\/\1>/g)) properties.set(match[1], match[2].trim());
|
|
27
|
+
const parent = /<parent\b[^>]*>([\s\S]*?)<\/parent>/i.exec(source)?.[1] || "";
|
|
28
|
+
const beforeDependencies = source.split(/<dependencies\b/i)[0];
|
|
29
|
+
const projectVersion = xmlValue(beforeDependencies, "version") || xmlValue(parent, "version");
|
|
30
|
+
const projectGroup = xmlValue(beforeDependencies, "groupId") || xmlValue(parent, "groupId");
|
|
31
|
+
properties.set("project.version", projectVersion); properties.set("pom.version", projectVersion);
|
|
32
|
+
properties.set("project.groupId", projectGroup); properties.set("pom.groupId", projectGroup);
|
|
33
|
+
const managed = new Map();
|
|
34
|
+
for (const block of source.matchAll(/<dependencyManagement\b[^>]*>([\s\S]*?)<\/dependencyManagement>/gi)) {
|
|
35
|
+
for (const dep of block[1].matchAll(/<dependency\b[^>]*>([\s\S]*?)<\/dependency>/gi)) {
|
|
36
|
+
const group = resolveMavenValue(xmlValue(dep[1], "groupId"), properties);
|
|
37
|
+
const artifact = resolveMavenValue(xmlValue(dep[1], "artifactId"), properties);
|
|
38
|
+
const version = resolveMavenValue(xmlValue(dep[1], "version"), properties);
|
|
39
|
+
if (group && artifact && version) managed.set(`${group}:${artifact}`, version);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const directSource = source.replace(/<dependencyManagement\b[^>]*>[\s\S]*?<\/dependencyManagement>/gi, " ");
|
|
43
|
+
const dependencies = [];
|
|
44
|
+
const directBlocks = [...directSource.matchAll(/<dependency\b[^>]*>([\s\S]*?)<\/dependency>/gi)];
|
|
45
|
+
for (const match of directBlocks) {
|
|
46
|
+
const group = resolveMavenValue(xmlValue(match[1], "groupId"), properties);
|
|
47
|
+
const artifact = resolveMavenValue(xmlValue(match[1], "artifactId"), properties);
|
|
48
|
+
if (!group || !artifact) continue;
|
|
49
|
+
dependencies.push({
|
|
50
|
+
group, artifact, name: `${group}:${artifact}`,
|
|
51
|
+
version: resolveMavenValue(xmlValue(match[1], "version"), properties) || managed.get(`${group}:${artifact}`) || "",
|
|
52
|
+
scope: xmlValue(match[1], "scope") || "compile",
|
|
53
|
+
optional: xmlValue(match[1], "optional") === "true",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { projectGroup, projectVersion, dependencies, unresolvedDeclarations: directBlocks.length - dependencies.length };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseGradleVersionCatalog(text) {
|
|
60
|
+
const versions = new Map(), libraries = new Map();
|
|
61
|
+
let section = "";
|
|
62
|
+
for (const raw of String(text || "").split(/\r?\n/)) {
|
|
63
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
64
|
+
const header = /^\[([^\]]+)]$/.exec(line);
|
|
65
|
+
if (header) { section = header[1]; continue; }
|
|
66
|
+
const kv = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line);
|
|
67
|
+
if (!kv) continue;
|
|
68
|
+
if (section === "versions") { versions.set(kv[1], clean(kv[2])); continue; }
|
|
69
|
+
if (section !== "libraries") continue;
|
|
70
|
+
const alias = kv[1], value = kv[2];
|
|
71
|
+
const module = /\bmodule\s*=\s*["']([^"']+)["']/.exec(value)?.[1]
|
|
72
|
+
|| /\bgroup\s*=\s*["']([^"']+)["'][\s\S]*?\bname\s*=\s*["']([^"']+)["']/.exec(value)?.slice(1, 3).join(":")
|
|
73
|
+
|| (/^["'][^"']+:[^"']+["']$/.test(value) ? clean(value) : "");
|
|
74
|
+
if (!module) continue;
|
|
75
|
+
const explicit = /\bversion\s*=\s*["']([^"']+)["']/.exec(value)?.[1] || "";
|
|
76
|
+
const reference = /\bversion\.ref\s*=\s*["']([^"']+)["']/.exec(value)?.[1] || "";
|
|
77
|
+
libraries.set(alias.replace(/[_.]/g, "-"), { module, version: explicit || versions.get(reference) || "" });
|
|
78
|
+
}
|
|
79
|
+
return libraries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseGradleDependencies(text, catalog = new Map()) {
|
|
83
|
+
const source = String(text || "").replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|\s)\/\/.*$/gm, "$1");
|
|
84
|
+
const dependencies = [];
|
|
85
|
+
const configurations = "api|implementation|compile|compileOnly|runtimeOnly|annotationProcessor|kapt|classpath|testImplementation|testCompileOnly|testRuntimeOnly|androidTestImplementation";
|
|
86
|
+
const matcher = new RegExp(`^\\s*(${configurations})\\s*(?:\\(\\s*)?([^\\r\\n]+)`, "gmi");
|
|
87
|
+
const declarations = [...source.matchAll(matcher)];
|
|
88
|
+
for (const match of declarations) {
|
|
89
|
+
const expression = match[2].trim().replace(/[),;]+\s*$/, "");
|
|
90
|
+
const coordinate = /["']([^"']+:[^"']+)["']/.exec(expression)?.[1] || "";
|
|
91
|
+
if (coordinate) {
|
|
92
|
+
const [group, artifact, version = ""] = coordinate.split(":");
|
|
93
|
+
if (group && artifact) dependencies.push({ group, artifact, name: `${group}:${artifact}`, version, scope: match[1] });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const alias = /\blibs((?:\.[A-Za-z_]\w*)+)/.exec(expression)?.[1]?.slice(1).replace(/\./g, "-") || "";
|
|
97
|
+
const entry = catalog.get(alias);
|
|
98
|
+
if (entry) {
|
|
99
|
+
const [group, artifact] = entry.module.split(":");
|
|
100
|
+
dependencies.push({ group, artifact, name: entry.module, version: entry.version, scope: match[1], catalogAlias: alias });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
dependencies.unresolvedDeclarations = declarations.length - dependencies.length;
|
|
104
|
+
return dependencies;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseGradleLockPackages(text) {
|
|
108
|
+
const packages = [];
|
|
109
|
+
for (const raw of String(text || "").split(/\r?\n/)) {
|
|
110
|
+
const match = /^([^#=\s:]+(?:\.[^#=\s:]+)*):([^#=\s:]+):([^#=\s=]+)=/.exec(raw.trim());
|
|
111
|
+
if (match) packages.push({ ecosystem: "Maven", name: `${match[1]}:${match[2]}`, version: match[3], dev: false, integrity: "", source: "gradle-lock" });
|
|
112
|
+
}
|
|
113
|
+
return packages;
|
|
114
|
+
}
|
|
@@ -3,12 +3,9 @@
|
|
|
3
3
|
// the surrounding behavior is wrong.
|
|
4
4
|
import { makeFinding } from "./findings.js";
|
|
5
5
|
import {retryFindings} from './source-correctness/retry-patterns.js'
|
|
6
|
+
import {lineNumberAt} from '../util.js'
|
|
6
7
|
|
|
7
|
-
const lineAt =
|
|
8
|
-
let line = 1;
|
|
9
|
-
for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
|
|
10
|
-
return line;
|
|
11
|
-
};
|
|
8
|
+
const lineAt = lineNumberAt;
|
|
12
9
|
|
|
13
10
|
const lineText = (text, index) => {
|
|
14
11
|
const start = text.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
|
|
@@ -1,19 +1,8 @@
|
|
|
1
|
-
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
1
|
+
import {PATH_CLASS_NAMES, PATH_CLASS_TASK_QUERY_TERMS, createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
2
2
|
|
|
3
3
|
const words = (value) => new Set(String(value || '').toLowerCase().match(/[\p{L}_$][\p{L}\p{N}_$-]{2,}/gu) || [])
|
|
4
4
|
const fileOf = (node) => String(node?.source_file || (String(node?.id || '').includes('#') ? String(node.id).split('#')[0] : node?.id || '')).replace(/\\/g, '/')
|
|
5
5
|
const isSymbol = (id) => String(id || '').includes('#')
|
|
6
|
-
const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
7
|
-
const CLASS_TERMS = Object.freeze({
|
|
8
|
-
test: ['test', 'tests', 'testing', 'spec', 'coverage', 'verify'],
|
|
9
|
-
e2e: ['e2e', 'playwright', 'cypress'],
|
|
10
|
-
generated: ['generated', 'autogenerated', 'dist'],
|
|
11
|
-
mock: ['mock', 'mocks', 'fixture', 'fixtures', 'fake'],
|
|
12
|
-
story: ['story', 'stories', 'storybook'],
|
|
13
|
-
docs: ['doc', 'docs', 'documentation', 'readme', 'guide'],
|
|
14
|
-
benchmark: ['benchmark', 'benchmarks', 'bench'],
|
|
15
|
-
temp: ['temp', 'temporary', 'tmp'],
|
|
16
|
-
})
|
|
17
6
|
|
|
18
7
|
const INTENT_TRANSLATIONS = [
|
|
19
8
|
[/авториз|аутентиф|логин|сесси|токен/iu, 'auth authentication login session token'],
|
|
@@ -64,7 +53,7 @@ export function retrieveTaskContext(g, {
|
|
|
64
53
|
} = {}) {
|
|
65
54
|
const expandedTask = expandTaskQuery(task)
|
|
66
55
|
const taskWords = words(expandedTask)
|
|
67
|
-
const requestedClasses = new Set(Object.entries(
|
|
56
|
+
const requestedClasses = new Set(Object.entries(PATH_CLASS_TASK_QUERY_TERMS)
|
|
68
57
|
.filter(([, terms]) => terms.some((term) => taskWords.has(term)))
|
|
69
58
|
.map(([name]) => name))
|
|
70
59
|
if (requestedClasses.has('test')) requestedClasses.add('e2e')
|
|
@@ -77,7 +66,7 @@ export function retrieveTaskContext(g, {
|
|
|
77
66
|
if (!file || changedFiles.has(file) || includeClassified === true) return true
|
|
78
67
|
if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
|
|
79
68
|
const info = classificationCache.get(file)
|
|
80
|
-
const classes =
|
|
69
|
+
const classes = PATH_CLASS_NAMES.filter((name) => hasPathClass(info, name))
|
|
81
70
|
if (!info?.excluded && !classes.length) return true
|
|
82
71
|
return classes.some((name) => requestedClasses.has(name))
|
|
83
72
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
2
|
+
import { lineNumberAt, uniqueBy } from "../util.js";
|
|
3
|
+
import { listRepoFiles, readRepoText } from "./internal-audit/repo-files.js";
|
|
4
|
+
import { affectedForEndpoint, reverseRuntimeImports } from "./http-contracts/graph-context.js";
|
|
5
|
+
|
|
6
|
+
const TEST_RE = /(^|\/)(?:test|tests|__tests__|spec|e2e|fixtures?)(\/|$)|[._-](?:test|spec)\.[a-z0-9]+$/i;
|
|
7
|
+
const SOURCE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|cs|proto|graphql|gql)$/i;
|
|
8
|
+
const lineAt = lineNumberAt;
|
|
9
|
+
const norm = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
10
|
+
|
|
11
|
+
function sourcesFor(descriptor, { includeTests, maxFiles }) {
|
|
12
|
+
const boundary = createRepoBoundary(descriptor.repoRoot);
|
|
13
|
+
if (!boundary.root) return { sources: [], reasons: [`${descriptor.id}: repository boundary is unreadable`] };
|
|
14
|
+
const candidates = listRepoFiles(boundary.root).filter((file) => SOURCE_RE.test(file) && (includeTests || !TEST_RE.test(file))).sort();
|
|
15
|
+
const sources = [];
|
|
16
|
+
for (const file of candidates.slice(0, maxFiles)) {
|
|
17
|
+
const text = readRepoText(boundary, file);
|
|
18
|
+
if (text != null) sources.push({ file, text });
|
|
19
|
+
}
|
|
20
|
+
return { sources, reasons: candidates.length > maxFiles ? [`${descriptor.id}: transport file cap reached`] : [] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function graphQlEvidence(source, role) {
|
|
24
|
+
const contracts = [], uncertain = [];
|
|
25
|
+
for (const schema of source.text.matchAll(/\btype\s+(Query|Mutation|Subscription)\s*(?:extends\s+\w+\s*)?\{([\s\S]*?)\}/gi)) {
|
|
26
|
+
for (const field of schema[2].matchAll(/^\s*([A-Za-z_]\w*)\s*(?:\([^)]*\))?\s*:/gm)) contracts.push({
|
|
27
|
+
transport: "graphql", side: "server", operation: schema[1].toUpperCase(), name: field[1], file: source.file,
|
|
28
|
+
line: lineAt(source.text, schema.index + schema[0].indexOf(field[0])), detector: "graphql-schema",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
for (const operation of source.text.matchAll(/\b(query|mutation|subscription)\s*(?:[A-Za-z_]\w*\s*)?(?:\([^)]*\)\s*)?\{\s*([A-Za-z_]\w*)/gi)) contracts.push({
|
|
32
|
+
transport: "graphql", side: role === "backend" ? "server-call" : "client", operation: operation[1].toUpperCase(), name: operation[2],
|
|
33
|
+
file: source.file, line: lineAt(source.text, operation.index), detector: "graphql-operation",
|
|
34
|
+
});
|
|
35
|
+
for (const dynamic of source.text.matchAll(/\b(?:gql|graphql)\s*(?:\(|`)\s*\$\{|\b(?:query|mutate)\s*\(\s*(?!["'`])/gi)) uncertain.push({
|
|
36
|
+
transport: "graphql", file: source.file, line: lineAt(source.text, dynamic.index), reason: "GraphQL document or operation is runtime-computed",
|
|
37
|
+
});
|
|
38
|
+
return { contracts, uncertain };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function grpcEvidence(source, role) {
|
|
42
|
+
const contracts = [], uncertain = [], aliases = new Map();
|
|
43
|
+
for (const service of source.text.matchAll(/\bservice\s+([A-Za-z_]\w*)\s*\{([\s\S]*?)\}/g)) {
|
|
44
|
+
for (const rpc of service[2].matchAll(/\brpc\s+([A-Za-z_]\w*)\s*\(/g)) contracts.push({
|
|
45
|
+
transport: "grpc", side: "server", service: service[1], name: rpc[1], file: source.file,
|
|
46
|
+
line: lineAt(source.text, service.index + service[0].indexOf(rpc[0])), detector: "proto-service",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const aliasPatterns = [
|
|
50
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+([A-Za-z_]\w*(?:Service)?Client)\b/g,
|
|
51
|
+
/\b([A-Za-z_]\w*)\s*=\s*([A-Za-z_]\w*Stub)\s*\(/g,
|
|
52
|
+
/\b([A-Za-z_]\w*)\s*:?=\s*\w*\.New([A-Za-z_]\w*)Client\s*\(/g,
|
|
53
|
+
/\b(?:[A-Za-z_]\w*\.)*([A-Za-z_]\w*(?:Blocking|Future|Async)?Stub)\s+([A-Za-z_]\w*)\s*[=;]/g,
|
|
54
|
+
];
|
|
55
|
+
for (const [index, pattern] of aliasPatterns.entries()) for (const match of source.text.matchAll(pattern)) {
|
|
56
|
+
const alias = index === 3 ? match[2] : match[1];
|
|
57
|
+
const type = index === 3 ? match[1] : match[2];
|
|
58
|
+
aliases.set(alias, type.replace(/(?:Service)?Client$|(?:Blocking|Future|Async)?Stub$/i, ""));
|
|
59
|
+
}
|
|
60
|
+
for (const call of source.text.matchAll(/\b([A-Za-z_$][\w$]*)\s*(?:\.|->)\s*([A-Za-z_]\w*)\s*\(/g)) {
|
|
61
|
+
if (!aliases.has(call[1])) continue;
|
|
62
|
+
contracts.push({ transport: "grpc", side: role === "backend" ? "server-call" : "client", service: aliases.get(call[1]), name: call[2], file: source.file, line: lineAt(source.text, call.index), detector: "grpc-stub-call" });
|
|
63
|
+
}
|
|
64
|
+
for (const dynamic of source.text.matchAll(/\b(?:ServerReflection|ProtoReflection|grpc\.reflection|Class\.forName|Method\.invoke|reflect\.Value|dynamicStub)\b/g)) uncertain.push({
|
|
65
|
+
transport: "grpc", file: source.file, line: lineAt(source.text, dynamic.index), reason: "gRPC/reflection target is runtime-resolved",
|
|
66
|
+
});
|
|
67
|
+
return { contracts, uncertain };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function eventEvidence(source, role) {
|
|
71
|
+
const contracts = [], uncertain = [];
|
|
72
|
+
const add = (side, kind, match, topicIndex = 2) => contracts.push({
|
|
73
|
+
transport: "event", side, kind, name: match[topicIndex], file: source.file, line: lineAt(source.text, match.index), detector: "static-topic",
|
|
74
|
+
});
|
|
75
|
+
const subscriptions = [
|
|
76
|
+
/\b(?:consumer|kafka|bus|events?|eventBus)\s*(?:\.|->)\s*(subscribe|on|consume)\s*\(\s*["'`]([^"'`]+)["'`]/gi,
|
|
77
|
+
/@KafkaListener\s*\([^)]*\btopics?\s*=\s*["']([^"']+)["']/gi,
|
|
78
|
+
/\bsubscribe\s*\(\s*\{\s*topic\s*:\s*["'`]([^"'`]+)["'`]/gi,
|
|
79
|
+
];
|
|
80
|
+
for (const [index, pattern] of subscriptions.entries()) for (const match of source.text.matchAll(pattern)) add("subscriber", index === 0 ? match[1] : "subscribe", match, index === 0 ? 2 : 1);
|
|
81
|
+
const publications = [
|
|
82
|
+
/\b(?:producer|kafka|bus|events?|eventBus|kafkaTemplate)\s*(?:\.|->)\s*(publish|emit|send|produce)\s*\(\s*["'`]([^"'`]+)["'`]/gi,
|
|
83
|
+
/\b(?:producer|kafka)\s*(?:\.|->)\s*send\s*\(\s*\{\s*topic\s*:\s*["'`]([^"'`]+)["'`]/gi,
|
|
84
|
+
];
|
|
85
|
+
for (const [index, pattern] of publications.entries()) for (const match of source.text.matchAll(pattern)) add("publisher", index === 0 ? match[1] : "send", match, index === 0 ? 2 : 1);
|
|
86
|
+
for (const dynamic of source.text.matchAll(/\b(?:subscribe|publish|emit|consume|produce)\s*\(\s*(?!["'`{])([A-Za-z_$][\w$]*)/gi)) uncertain.push({
|
|
87
|
+
transport: "event", file: source.file, line: lineAt(source.text, dynamic.index), reason: `Event topic is runtime-computed (${dynamic[1]})`, role,
|
|
88
|
+
});
|
|
89
|
+
return { contracts, uncertain };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function detect(descriptor, role, options) {
|
|
93
|
+
const loaded = sourcesFor(descriptor, options), contracts = [], uncertain = [];
|
|
94
|
+
for (const source of loaded.sources) {
|
|
95
|
+
for (const detector of [graphQlEvidence, grpcEvidence, eventEvidence]) {
|
|
96
|
+
const result = detector(source, role);
|
|
97
|
+
contracts.push(...result.contracts); uncertain.push(...result.uncertain);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
contracts: uniqueBy(contracts, (item) => `${item.transport}|${item.side}|${item.service || ""}|${item.operation || ""}|${item.name}|${item.file}|${item.line}`),
|
|
102
|
+
uncertain: uniqueBy(uncertain, (item) => `${item.transport}|${item.file}|${item.line}|${item.reason}`),
|
|
103
|
+
reasons: loaded.reasons,
|
|
104
|
+
filesScanned: loaded.sources.length,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function contractMatch(server, caller, backendServers) {
|
|
109
|
+
if (server.transport !== caller.transport) return null;
|
|
110
|
+
if (server.transport === "graphql" && caller.side === "client" && server.operation === caller.operation && server.name === caller.name) return { confidence: "high", kind: "operation-field" };
|
|
111
|
+
if (server.transport === "grpc" && caller.side === "client" && norm(server.name) === norm(caller.name)) {
|
|
112
|
+
if (caller.service && norm(server.service) === norm(caller.service)) return { confidence: "high", kind: "service-method" };
|
|
113
|
+
const sameMethod = backendServers.filter((item) => item.transport === "grpc" && norm(item.name) === norm(caller.name));
|
|
114
|
+
if (!caller.service && sameMethod.length === 1) return { confidence: "medium", kind: "unique-method" };
|
|
115
|
+
}
|
|
116
|
+
if (server.transport === "event" && server.name === caller.name && server.side !== caller.side) return { confidence: "high", kind: "topic-direction" };
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function analyzeTransportContracts(input = {}) {
|
|
121
|
+
const transport = ["all", "graphql", "grpc", "event"].includes(input.transport) ? input.transport : "all";
|
|
122
|
+
const options = { includeTests: input.includeTests === true, maxFiles: Math.max(1, Math.min(10_000, Number(input.maxFiles) || 3_000)) };
|
|
123
|
+
const backend = detect(input.backend, "backend", options);
|
|
124
|
+
const clients = (input.clients || []).map((descriptor) => ({ descriptor, evidence: detect(descriptor, "client", options), reverse: reverseRuntimeImports(descriptor.graph) }));
|
|
125
|
+
const selected = (item) => transport === "all" || item.transport === transport;
|
|
126
|
+
const backendContracts = backend.contracts.filter(selected);
|
|
127
|
+
const servers = backendContracts.filter((item) => item.side === "server" || item.transport === "event");
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const server of servers) {
|
|
130
|
+
const callsites = [];
|
|
131
|
+
for (const client of clients) for (const caller of client.evidence.contracts.filter(selected)) {
|
|
132
|
+
const match = contractMatch(server, caller, servers);
|
|
133
|
+
if (!match) continue;
|
|
134
|
+
callsites.push({ clientRepo: client.descriptor.id, file: caller.file, line: caller.line, detector: caller.detector, match });
|
|
135
|
+
}
|
|
136
|
+
const affected = affectedForEndpoint(callsites, clients.map((item) => ({ id: item.descriptor.id, reverse: item.reverse })), {
|
|
137
|
+
maxImpactDepth: Math.max(0, Math.min(5, Number(input.maxImpactDepth) || 2)),
|
|
138
|
+
maxAffectedFiles: Math.max(1, Math.min(500, Number(input.maxAffectedFiles) || 100)), maxScreens: 50, maxModules: 50,
|
|
139
|
+
});
|
|
140
|
+
results.push({ ...server, callsites, affected, liveness: callsites.length ? "NOT_DEAD_EXTERNAL_USE" : "UNKNOWN" });
|
|
141
|
+
}
|
|
142
|
+
const uncertain = [
|
|
143
|
+
...backend.uncertain.map((item) => ({ repository: input.backend.id, ...item })),
|
|
144
|
+
...clients.flatMap((item) => item.evidence.uncertain.map((entry) => ({ repository: item.descriptor.id, ...entry }))),
|
|
145
|
+
].filter(selected);
|
|
146
|
+
const reasons = [...backend.reasons, ...clients.flatMap((item) => item.evidence.reasons)];
|
|
147
|
+
if (uncertain.length) reasons.push(`${uncertain.length} dynamic/reflection contract expression(s) remain UNKNOWN`);
|
|
148
|
+
return {
|
|
149
|
+
transportContractsV: 1,
|
|
150
|
+
transport,
|
|
151
|
+
status: reasons.length ? "PARTIAL" : "COMPLETE",
|
|
152
|
+
completeness: { complete: reasons.length === 0, reasons: [...new Set(reasons)] },
|
|
153
|
+
totals: { contracts: results.length, matches: results.reduce((sum, item) => sum + item.callsites.length, 0), uncertain: uncertain.length, filesScanned: backend.filesScanned + clients.reduce((sum, item) => sum + item.evidence.filesScanned, 0) },
|
|
154
|
+
contracts: results,
|
|
155
|
+
uncertain: uncertain.slice(0, 200),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -72,7 +72,9 @@ export default {
|
|
|
72
72
|
heritage: [],
|
|
73
73
|
|
|
74
74
|
pass1(ctx) {
|
|
75
|
-
const { grammar, tree, fileRel, caps, addSym, addImportEdge, addExternalImport, imports, resolveGoImport, dirFiles, goModule, goRequires } = ctx;
|
|
75
|
+
const { grammar, tree, fileRel, caps, addSym, addImportEdge, addExternalImport, imports, resolveGoImport, dirFiles, goModule, goModules, goRequires } = ctx;
|
|
76
|
+
const owningModule = (goModules || []).filter((item) => !item.root || fileRel === item.root || fileRel.startsWith(`${item.root}/`))
|
|
77
|
+
.sort((left, right) => right.root.length - left.root.length)[0];
|
|
76
78
|
|
|
77
79
|
// ---- symbols ----
|
|
78
80
|
for (const cap of caps(grammar, `(function_declaration name: (identifier) @fn)`, tree.rootNode)) {
|
|
@@ -124,7 +126,7 @@ export default {
|
|
|
124
126
|
if (!d) {
|
|
125
127
|
// not a repo package: stdlib (builtin) or an external module — record for dependency analysis
|
|
126
128
|
const line = pathNode.startPosition.row + 1;
|
|
127
|
-
const r = goSpecToPkg(importPath, { requires: goRequires || [], ownModule: goModule || "" });
|
|
129
|
+
const r = goSpecToPkg(importPath, { requires: owningModule?.requires || goRequires || [], ownModule: owningModule?.module || goModule || "" });
|
|
128
130
|
if (r) addExternalImport({ spec: importPath, pkg: r.pkg, builtin: r.builtin, ecosystem: "Go", kind: "go-import", line });
|
|
129
131
|
else addExternalImport({ spec: importPath, kind: "go-import", line, unresolved: true }); // own-module path with no matching dir
|
|
130
132
|
continue;
|
|
@@ -83,7 +83,7 @@ export default {
|
|
|
83
83
|
pass1(ctx) {
|
|
84
84
|
const {
|
|
85
85
|
grammar, tree, fileRel, caps, field, addSym, addImportEdge, imports,
|
|
86
|
-
resolveJavaImport, fileSet, links, nodeIds,
|
|
86
|
+
resolveJavaImport, fileSet, links, nodeIds, addExternalImport,
|
|
87
87
|
} = ctx;
|
|
88
88
|
const ownerIds = new Map();
|
|
89
89
|
const addJavaSym = (nameNode, callable, extra) => {
|
|
@@ -143,11 +143,17 @@ export default {
|
|
|
143
143
|
const line = lineOf(cap.node);
|
|
144
144
|
if (!isStatic && wildcard) {
|
|
145
145
|
wildcardPackages.push(parts);
|
|
146
|
+
const packagePath = `${parts.join("/")}/`;
|
|
147
|
+
const projectLocal = [...fileSet].some((file) => file.startsWith(packagePath) || file.includes(`/${packagePath}`));
|
|
148
|
+
if (!projectLocal) addExternalImport({ spec: `${parts.join(".")}.*`, pkg: parts.join("."), builtin: ["java", "jdk", "sun"].includes(parts[0]), ecosystem: "Maven", kind: "java-import", line });
|
|
146
149
|
continue;
|
|
147
150
|
}
|
|
148
151
|
if (!isStatic) {
|
|
149
152
|
const target = exactJavaTarget(resolveJavaImport, parts);
|
|
150
|
-
if (!target)
|
|
153
|
+
if (!target) {
|
|
154
|
+
addExternalImport({ spec: parts.join("."), pkg: parts.join("."), builtin: ["java", "jdk", "sun"].includes(parts[0]), ecosystem: "Maven", kind: "java-import", line });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
151
157
|
const local = parts[parts.length - 1];
|
|
152
158
|
addImportEdge(target, { line, specifier: parts.join("."), compileOnly: true });
|
|
153
159
|
imports.set(local, { imported: local, targetFile: target });
|
|
@@ -162,7 +168,10 @@ export default {
|
|
|
162
168
|
target = exactJavaTarget(resolveJavaImport, candidate);
|
|
163
169
|
if (target) classParts = candidate;
|
|
164
170
|
}
|
|
165
|
-
if (!target)
|
|
171
|
+
if (!target) {
|
|
172
|
+
addExternalImport({ spec: parts.join("."), pkg: parts.join("."), builtin: ["java", "jdk", "sun"].includes(parts[0]), ecosystem: "Maven", kind: "java-static-import", line });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
166
175
|
addImportEdge(target, { line, specifier: `${isStatic ? "static " : ""}${parts.join(".")}${wildcard ? ".*" : ""}`, compileOnly: true });
|
|
167
176
|
if (wildcard) staticWildcardTargets.push(target);
|
|
168
177
|
else {
|
|
@@ -113,7 +113,7 @@ export default {
|
|
|
113
113
|
],
|
|
114
114
|
|
|
115
115
|
pass1(ctx) {
|
|
116
|
-
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
|
|
116
|
+
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, addExternalImport, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
|
|
117
117
|
const owned = [];
|
|
118
118
|
for (const src of [SYMS_CORE, ...SYMS_OPTIONAL]) {
|
|
119
119
|
for (const cap of caps(grammar, src, tree.rootNode)) {
|
|
@@ -175,7 +175,13 @@ export default {
|
|
|
175
175
|
const ancestors = inlineAncestors(use);
|
|
176
176
|
for (const leaf of useLeaves(use)) {
|
|
177
177
|
const resolved = resolveRustPath(fileRel, leaf.segments, { inlineModules: ancestors, unqualified: true });
|
|
178
|
-
if (!resolved)
|
|
178
|
+
if (!resolved) {
|
|
179
|
+
const crate = cleanSegment(leaf.segments[0]);
|
|
180
|
+
if (crate && !["crate", "self", "super", "std", "core", "alloc"].includes(crate) && /^[a-z_][\w]*$/.test(crate)) {
|
|
181
|
+
addExternalImport({ spec: leaf.segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-use", line: use.startPosition.row + 1 });
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
179
185
|
emit(resolved.targetFile, { relation, line: use.startPosition.row + 1, specifier: use.text.replace(/;\s*$/, "") });
|
|
180
186
|
if (!leaf.wildcard && leaf.local && leaf.local !== "_") {
|
|
181
187
|
const imported = resolved.remaining.length ? cleanSegment(resolved.remaining.at(-1)) : "*";
|
|
@@ -192,7 +198,13 @@ export default {
|
|
|
192
198
|
if (under(node, "use_declaration")) continue;
|
|
193
199
|
if (["scoped_identifier", "scoped_type_identifier"].includes(node.parent?.type)) continue;
|
|
194
200
|
const segments = pathParts(node);
|
|
195
|
-
if (!["crate", "self", "super"].includes(segments[0]))
|
|
201
|
+
if (!["crate", "self", "super"].includes(segments[0])) {
|
|
202
|
+
const crate = cleanSegment(segments[0]);
|
|
203
|
+
if (crate && !["std", "core", "alloc"].includes(crate) && /^[a-z_][\w]*$/.test(crate)) {
|
|
204
|
+
addExternalImport({ spec: segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-path", line: node.startPosition.row + 1 });
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
196
208
|
const resolved = resolveRustPath(fileRel, segments, { inlineModules: inlineAncestors(node), unqualified: false });
|
|
197
209
|
if (!resolved) continue;
|
|
198
210
|
emit(resolved.targetFile, { line: node.startPosition.row + 1, specifier: node.text });
|
|
@@ -201,5 +213,12 @@ export default {
|
|
|
201
213
|
imports.set(finalName, { imported: finalName, targetFile: resolved.targetFile, rustQualified: true });
|
|
202
214
|
}
|
|
203
215
|
}
|
|
216
|
+
|
|
217
|
+
for (const cap of caps(grammar, `(extern_crate_declaration name: (identifier) @crate)`, tree.rootNode)) {
|
|
218
|
+
const crate = cleanSegment(cap.node.text);
|
|
219
|
+
if (crate && !["std", "core", "alloc"].includes(crate)) {
|
|
220
|
+
addExternalImport({ spec: crate, pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-extern-crate", line: cap.node.startPosition.row + 1 });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
204
223
|
},
|
|
205
224
|
};
|
|
@@ -19,7 +19,7 @@ export const GRAPH_BUILDER_VERSION = (() => {
|
|
|
19
19
|
// version invalidates stamps across releases; the explicit schema requirements also fail closed when
|
|
20
20
|
// a saved graph is hand-edited or a development build bumps a structural schema without a version bump.
|
|
21
21
|
const CURRENT_GRAPH_SCHEMA = Object.freeze({
|
|
22
|
-
extImportsV:
|
|
22
|
+
extImportsV: 3,
|
|
23
23
|
edgeTypesV: 2,
|
|
24
24
|
edgeProvenanceV: 1,
|
|
25
25
|
complexityV: 2,
|
|
@@ -248,7 +248,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
248
248
|
assignDeterministicCommunities(nodes);
|
|
249
249
|
stampEdgeProvenance(links);
|
|
250
250
|
|
|
251
|
-
// extImportsV: bump when the externalImports schema/coverage changes (
|
|
251
|
+
// extImportsV: bump when the externalImports schema/coverage changes (v3 = Java/Rust ecosystems) —
|
|
252
252
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
253
253
|
// edgeTypesV 2 adds language-neutral compile-only edges (currently Rust mod/use/re-export) on top
|
|
254
254
|
// of v1's TypeScript typeOnly classification.
|
|
@@ -256,7 +256,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
256
256
|
nodes,
|
|
257
257
|
links,
|
|
258
258
|
externalImports,
|
|
259
|
-
extImportsV:
|
|
259
|
+
extImportsV: 3,
|
|
260
260
|
edgeTypesV: 2,
|
|
261
261
|
edgeProvenanceV: EDGE_PROVENANCE_V,
|
|
262
262
|
complexityV: 2,
|
|
@@ -6,6 +6,7 @@ import { readFileSync } from "node:fs";
|
|
|
6
6
|
import { join, dirname } from "node:path";
|
|
7
7
|
import { parseGoMod } from "../analysis/manifests.js";
|
|
8
8
|
import { createRepoBoundary } from "../repo-path.js";
|
|
9
|
+
import { listRepoFiles } from "../analysis/internal-audit/repo-files.js";
|
|
9
10
|
import { createRustResolvers } from "./resolvers/rust.js";
|
|
10
11
|
|
|
11
12
|
export function buildResolvers(repoDir, fileSet) {
|
|
@@ -19,11 +20,18 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
19
20
|
// go.mod requires also feed goSpecToPkg so external Go imports map to their declared module.
|
|
20
21
|
let goModule = "";
|
|
21
22
|
let goRequires = [];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const goModules = [];
|
|
24
|
+
for (const manifest of listRepoFiles(repoDir).filter((file) => /(^|\/)go\.mod$/i.test(file))) {
|
|
25
|
+
try {
|
|
26
|
+
const gomod = parseGoMod(readLocal(manifest));
|
|
27
|
+
if (!gomod.module) continue;
|
|
28
|
+
const root = manifest.includes("/") ? manifest.slice(0, manifest.lastIndexOf("/")) : "";
|
|
29
|
+
goModules.push({ root, module: gomod.module, requires: gomod.requires.map((item) => item.path) });
|
|
30
|
+
} catch { /* unreadable module */ }
|
|
31
|
+
}
|
|
32
|
+
goModules.sort((left, right) => right.module.length - left.module.length);
|
|
33
|
+
goModule = goModules.find((item) => !item.root)?.module || goModules[0]?.module || "";
|
|
34
|
+
goRequires = [...new Set(goModules.flatMap((item) => item.requires))];
|
|
27
35
|
const dirFiles = new Map();
|
|
28
36
|
const filesByBase = new Map();
|
|
29
37
|
for (const fr of fileSet) {
|
|
@@ -32,8 +40,10 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
32
40
|
if (fr.endsWith(".go")) { const d = fr.includes("/") ? fr.slice(0, fr.lastIndexOf("/")) : ""; (dirFiles.get(d) || dirFiles.set(d, []).get(d)).push(fr); }
|
|
33
41
|
}
|
|
34
42
|
const resolveGoImport = (importPath) => {
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
for (const own of goModules) {
|
|
44
|
+
if (importPath !== own.module && !importPath.startsWith(own.module + "/")) continue;
|
|
45
|
+
const subpath = importPath === own.module ? "" : importPath.slice(own.module.length + 1);
|
|
46
|
+
const d = [own.root, subpath].filter(Boolean).join("/");
|
|
37
47
|
if (dirFiles.has(d)) return d;
|
|
38
48
|
}
|
|
39
49
|
// a module DECLARED in go.mod is external by definition — never let the suffix fallback hijack it
|
|
@@ -207,5 +217,5 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
207
217
|
return fileSet.has(cand) ? cand : null;
|
|
208
218
|
};
|
|
209
219
|
|
|
210
|
-
return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goRequires };
|
|
220
|
+
return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goModules, goRequires };
|
|
211
221
|
}
|
|
@@ -4,15 +4,14 @@ import {collectInstalled} from '../../security/installed.js'
|
|
|
4
4
|
export async function tRefreshAdvisories(g, args, ctx) {
|
|
5
5
|
if (!ctx.repoRoot) return 'No repo root — cannot collect installed packages.'
|
|
6
6
|
const {installed} = collectInstalled(ctx.repoRoot)
|
|
7
|
-
if (!installed.length) return 'No pinned packages found in lockfiles (npm/yarn/pip/poetry/uv/go) — nothing to query.'
|
|
7
|
+
if (!installed.length) return 'No pinned packages found in supported manifests/lockfiles (npm/yarn/pip/poetry/uv/go/Maven/Gradle/Cargo) — nothing to query.'
|
|
8
8
|
const res = await refreshAdvisories({installed, repoKey: ctx.repoRoot, timeoutMs: Number(args.timeout_ms) || undefined})
|
|
9
9
|
if (res.ok === false) return `Advisory refresh failed: ${res.error}`
|
|
10
10
|
const meta = storeMeta()
|
|
11
11
|
return [
|
|
12
12
|
`Advisory store ${res.status === 'PARTIAL' ? 'partially refreshed' : 'refreshed'} from OSV.dev: ${res.queriedOk ?? res.queried}/${res.queried} package versions queried successfully, ${res.vulnerable} with known advisories (${res.fetched} advisory records fetched).`,
|
|
13
|
-
res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — npm/PyPI/Go
|
|
13
|
+
res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — supported: npm/PyPI/Go/Maven/crates.io).` : null,
|
|
14
14
|
res.errors?.length ? `Partial: ${res.errors.length} request error(s), first: ${res.errors[0]}` : null,
|
|
15
15
|
`Store: ${DEFAULT_STORE} (${meta.advisoryCount} advisories, fetched ${meta.fetchedAt}). run_audit now reflects it — offline.`,
|
|
16
16
|
].filter(Boolean).join('\n')
|
|
17
17
|
}
|
|
18
|
-
|