auditor-lambda 0.3.41 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/dispatch.js +5 -1
- package/dist/cli/prompts.d.ts +19 -0
- package/dist/cli/prompts.js +95 -0
- package/dist/cli/steps.d.ts +1 -1
- package/dist/cli.js +398 -78
- package/dist/extractors/analyzers/css.d.ts +2 -0
- package/dist/extractors/analyzers/css.js +101 -0
- package/dist/extractors/analyzers/html.d.ts +2 -0
- package/dist/extractors/analyzers/html.js +92 -0
- package/dist/extractors/analyzers/merge.d.ts +14 -0
- package/dist/extractors/analyzers/merge.js +85 -0
- package/dist/extractors/analyzers/python.d.ts +2 -0
- package/dist/extractors/analyzers/python.js +104 -0
- package/dist/extractors/analyzers/registry.d.ts +33 -0
- package/dist/extractors/analyzers/registry.js +100 -0
- package/dist/extractors/analyzers/resourceUrl.d.ts +7 -0
- package/dist/extractors/analyzers/resourceUrl.js +25 -0
- package/dist/extractors/analyzers/sql.d.ts +2 -0
- package/dist/extractors/analyzers/sql.js +19 -0
- package/dist/extractors/analyzers/treeSitter.d.ts +34 -0
- package/dist/extractors/analyzers/treeSitter.js +111 -0
- package/dist/extractors/analyzers/types.d.ts +53 -0
- package/dist/extractors/analyzers/types.js +1 -0
- package/dist/extractors/analyzers/typescript.d.ts +2 -0
- package/dist/extractors/analyzers/typescript.js +257 -0
- package/dist/extractors/disposition.js +8 -1
- package/dist/extractors/graph.d.ts +1 -0
- package/dist/extractors/graph.js +167 -1
- package/dist/extractors/graphPythonImports.d.ts +15 -0
- package/dist/extractors/graphPythonImports.js +36 -0
- package/dist/extractors/pathPatterns.d.ts +6 -0
- package/dist/extractors/pathPatterns.js +8 -0
- package/dist/io/artifacts.d.ts +13 -1
- package/dist/io/artifacts.js +19 -3
- package/dist/mcp/server.js +3 -3
- package/dist/orchestrator/advance.d.ts +20 -0
- package/dist/orchestrator/advance.js +61 -2
- package/dist/orchestrator/dependencyMap.js +27 -0
- package/dist/orchestrator/edgeReasoning.d.ts +39 -0
- package/dist/orchestrator/edgeReasoning.js +125 -0
- package/dist/orchestrator/executors.js +11 -1
- package/dist/orchestrator/graphEnrichmentExecutor.d.ts +29 -0
- package/dist/orchestrator/graphEnrichmentExecutor.js +196 -0
- package/dist/orchestrator/internalExecutors.d.ts +10 -1
- package/dist/orchestrator/internalExecutors.js +89 -11
- package/dist/orchestrator/localCommands.js +6 -25
- package/dist/orchestrator/nextStep.js +2 -0
- package/dist/orchestrator/reviewPackets.d.ts +37 -4
- package/dist/orchestrator/reviewPackets.js +93 -46
- package/dist/orchestrator/runtimeValidation.js +4 -31
- package/dist/orchestrator/scope.d.ts +62 -0
- package/dist/orchestrator/scope.js +227 -0
- package/dist/orchestrator/state.js +2 -0
- package/dist/reporting/synthesis.d.ts +37 -2
- package/dist/reporting/synthesis.js +95 -16
- package/dist/reporting/synthesisNarrativePrompt.d.ts +7 -0
- package/dist/reporting/synthesisNarrativePrompt.js +60 -0
- package/dist/reporting/workBlocks.d.ts +2 -10
- package/dist/supervisor/operatorHandoff.d.ts +1 -1
- package/dist/supervisor/operatorHandoff.js +26 -16
- package/dist/supervisor/sessionConfig.d.ts +8 -1
- package/dist/supervisor/sessionConfig.js +22 -1
- package/dist/types/analyzerCapability.d.ts +16 -0
- package/dist/types/analyzerCapability.js +1 -0
- package/dist/types/auditScope.d.ts +43 -0
- package/dist/types/auditScope.js +14 -0
- package/dist/types/synthesisNarrative.d.ts +7 -0
- package/dist/types/synthesisNarrative.js +5 -0
- package/dist/types.d.ts +2 -19
- package/dist/validation/artifacts.js +9 -0
- package/dist/validation/sessionConfig.js +24 -1
- package/docs/contracts.md +10 -3
- package/package.json +4 -2
- package/schemas/analyzer_capability.schema.json +47 -0
- package/schemas/audit_findings.schema.json +141 -0
- package/schemas/finding.schema.json +2 -1
- package/schemas/graph_bundle.schema.json +5 -0
- package/schemas/scope.schema.json +46 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { graphEdge, normalizeGraphPath } from "../graphPathUtils.js";
|
|
4
|
+
import { getTreeSitterParser } from "./treeSitter.js";
|
|
5
|
+
import { resolveResourceUrl } from "./resourceUrl.js";
|
|
6
|
+
const CSS_IMPORT_EDGE_CONFIDENCE = 0.9;
|
|
7
|
+
const CSS_URL_EDGE_CONFIDENCE = 0.82;
|
|
8
|
+
const MAX_CSS_SOURCE_BYTES = 512 * 1024;
|
|
9
|
+
function supports(file) {
|
|
10
|
+
return normalizeGraphPath(file).toLowerCase().endsWith(".css");
|
|
11
|
+
}
|
|
12
|
+
function unquote(value) {
|
|
13
|
+
const trimmed = value.trim();
|
|
14
|
+
if (trimmed.length >= 2 &&
|
|
15
|
+
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
16
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
|
17
|
+
return trimmed.slice(1, -1);
|
|
18
|
+
}
|
|
19
|
+
return trimmed;
|
|
20
|
+
}
|
|
21
|
+
/** First quoted or unquoted literal value beneath a node (string / url arg). */
|
|
22
|
+
function literalValue(node) {
|
|
23
|
+
// tree-sitter-css string_value includes the surrounding quotes; plain_value
|
|
24
|
+
// (unquoted url() args) does not.
|
|
25
|
+
const value = node.descendantsOfType(["string_value", "plain_value"])[0];
|
|
26
|
+
return value ? unquote(value.text) : undefined;
|
|
27
|
+
}
|
|
28
|
+
function collectFileEdges(fromPath, root, pathLookup, edges) {
|
|
29
|
+
const importTargets = new Set();
|
|
30
|
+
// @import "x.css"; and @import url("x.css");
|
|
31
|
+
for (const statement of root.descendantsOfType("import_statement")) {
|
|
32
|
+
const url = literalValue(statement);
|
|
33
|
+
if (!url)
|
|
34
|
+
continue;
|
|
35
|
+
const target = resolveResourceUrl(fromPath, url, pathLookup);
|
|
36
|
+
if (!target || target === fromPath || importTargets.has(target))
|
|
37
|
+
continue;
|
|
38
|
+
importTargets.add(target);
|
|
39
|
+
edges.push(graphEdge({
|
|
40
|
+
from: fromPath,
|
|
41
|
+
to: target,
|
|
42
|
+
kind: "css-import",
|
|
43
|
+
confidence: CSS_IMPORT_EDGE_CONFIDENCE,
|
|
44
|
+
reason: `CSS @import references '${target}'.`,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
// url(...) references in declarations (background images, fonts, …).
|
|
48
|
+
const seenUrls = new Set(importTargets);
|
|
49
|
+
for (const call of root.descendantsOfType("call_expression")) {
|
|
50
|
+
if (!/^\s*url\s*\(/i.test(call.text))
|
|
51
|
+
continue;
|
|
52
|
+
const url = literalValue(call);
|
|
53
|
+
if (!url)
|
|
54
|
+
continue;
|
|
55
|
+
const target = resolveResourceUrl(fromPath, url, pathLookup);
|
|
56
|
+
if (!target || target === fromPath || seenUrls.has(target))
|
|
57
|
+
continue;
|
|
58
|
+
seenUrls.add(target);
|
|
59
|
+
edges.push(graphEdge({
|
|
60
|
+
from: fromPath,
|
|
61
|
+
to: target,
|
|
62
|
+
kind: "css-url",
|
|
63
|
+
confidence: CSS_URL_EDGE_CONFIDENCE,
|
|
64
|
+
reason: `CSS url() references '${target}'.`,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function analyze(files, context) {
|
|
69
|
+
if (files.length === 0)
|
|
70
|
+
return { edges: [] };
|
|
71
|
+
const parser = await getTreeSitterParser("css", context.dependencyPath);
|
|
72
|
+
if (!parser)
|
|
73
|
+
return { edges: [] };
|
|
74
|
+
const root = resolve(context.root);
|
|
75
|
+
const edges = [];
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
let content;
|
|
78
|
+
try {
|
|
79
|
+
content = await readFile(resolve(root, file), "utf8");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (content.length > MAX_CSS_SOURCE_BYTES)
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
const tree = parser.parse(content);
|
|
88
|
+
collectFileEdges(normalizeGraphPath(file), tree.rootNode, context.pathLookup, edges);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Degrade to the floor for this file.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { edges };
|
|
95
|
+
}
|
|
96
|
+
export const cssAnalyzer = {
|
|
97
|
+
id: "css",
|
|
98
|
+
dependency: "web-tree-sitter",
|
|
99
|
+
supports,
|
|
100
|
+
analyze,
|
|
101
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { graphEdge, normalizeGraphPath } from "../graphPathUtils.js";
|
|
4
|
+
import { getTreeSitterParser } from "./treeSitter.js";
|
|
5
|
+
import { resolveResourceUrl } from "./resourceUrl.js";
|
|
6
|
+
// Above the regex floor's html-resource-link so the merge prefers the parsed
|
|
7
|
+
// edge for the same (from, to).
|
|
8
|
+
const HTML_RESOURCE_EDGE_CONFIDENCE = 0.96;
|
|
9
|
+
const MAX_HTML_SOURCE_BYTES = 512 * 1024;
|
|
10
|
+
const HTML_EXTENSIONS = [".html", ".htm"];
|
|
11
|
+
// tag → the attribute carrying its resource reference.
|
|
12
|
+
const RESOURCE_ATTRIBUTE = {
|
|
13
|
+
script: "src",
|
|
14
|
+
link: "href",
|
|
15
|
+
img: "src",
|
|
16
|
+
};
|
|
17
|
+
function supports(file) {
|
|
18
|
+
const lower = normalizeGraphPath(file).toLowerCase();
|
|
19
|
+
return HTML_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
20
|
+
}
|
|
21
|
+
function attributeValue(tag, name) {
|
|
22
|
+
for (const attribute of tag.descendantsOfType("attribute")) {
|
|
23
|
+
const attributeName = attribute
|
|
24
|
+
.descendantsOfType("attribute_name")[0]
|
|
25
|
+
?.text?.toLowerCase();
|
|
26
|
+
if (attributeName !== name)
|
|
27
|
+
continue;
|
|
28
|
+
const value = attribute.descendantsOfType("attribute_value")[0]?.text;
|
|
29
|
+
if (value && value.trim().length > 0)
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
function collectFileEdges(fromPath, root, pathLookup, edges) {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const tag of root.descendantsOfType("start_tag")) {
|
|
37
|
+
const tagName = tag.descendantsOfType("tag_name")[0]?.text?.toLowerCase();
|
|
38
|
+
if (!tagName)
|
|
39
|
+
continue;
|
|
40
|
+
const attribute = RESOURCE_ATTRIBUTE[tagName];
|
|
41
|
+
if (!attribute)
|
|
42
|
+
continue;
|
|
43
|
+
const url = attributeValue(tag, attribute);
|
|
44
|
+
if (!url)
|
|
45
|
+
continue;
|
|
46
|
+
const target = resolveResourceUrl(fromPath, url, pathLookup);
|
|
47
|
+
if (!target || target === fromPath || seen.has(target))
|
|
48
|
+
continue;
|
|
49
|
+
seen.add(target);
|
|
50
|
+
edges.push(graphEdge({
|
|
51
|
+
from: fromPath,
|
|
52
|
+
to: target,
|
|
53
|
+
kind: "html-resource",
|
|
54
|
+
confidence: HTML_RESOURCE_EDGE_CONFIDENCE,
|
|
55
|
+
reason: `HTML <${tagName} ${attribute}> references '${target}'.`,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function analyze(files, context) {
|
|
60
|
+
if (files.length === 0)
|
|
61
|
+
return { edges: [] };
|
|
62
|
+
const parser = await getTreeSitterParser("html", context.dependencyPath);
|
|
63
|
+
if (!parser)
|
|
64
|
+
return { edges: [] };
|
|
65
|
+
const root = resolve(context.root);
|
|
66
|
+
const edges = [];
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
let content;
|
|
69
|
+
try {
|
|
70
|
+
content = await readFile(resolve(root, file), "utf8");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (content.length > MAX_HTML_SOURCE_BYTES)
|
|
76
|
+
continue;
|
|
77
|
+
try {
|
|
78
|
+
const tree = parser.parse(content);
|
|
79
|
+
collectFileEdges(normalizeGraphPath(file), tree.rootNode, context.pathLookup, edges);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Degrade to the floor for this file.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { edges };
|
|
86
|
+
}
|
|
87
|
+
export const htmlAnalyzer = {
|
|
88
|
+
id: "html",
|
|
89
|
+
dependency: "web-tree-sitter",
|
|
90
|
+
supports,
|
|
91
|
+
analyze,
|
|
92
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { GraphEdge } from "@audit-tools/shared";
|
|
2
|
+
export declare const TS_IMPORT_EDGE_CONFIDENCE = 0.99;
|
|
3
|
+
export declare const TS_REEXPORT_EDGE_CONFIDENCE = 0.99;
|
|
4
|
+
export declare const TS_EXTENDS_EDGE_CONFIDENCE = 0.97;
|
|
5
|
+
export declare const TS_IMPLEMENTS_EDGE_CONFIDENCE = 0.97;
|
|
6
|
+
export declare const TS_CALL_EDGE_CONFIDENCE = 0.9;
|
|
7
|
+
/**
|
|
8
|
+
* Merge analyzer edges into an existing floor bucket (e.g. `graphs.imports`).
|
|
9
|
+
* Within a known relationship group, edges sharing (from, to) collapse to the
|
|
10
|
+
* highest-confidence one (ties favour the later/analyzer edge); ungrouped kinds
|
|
11
|
+
* keep their per-kind identity. Self-edges are dropped. Result is deduped and
|
|
12
|
+
* sorted, matching the floor's ordering contract.
|
|
13
|
+
*/
|
|
14
|
+
export declare function mergeAnalyzerEdges(floor: GraphEdge[], analyzer: GraphEdge[]): GraphEdge[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Analyzer edge confidences are set above their regex-floor counterparts so the
|
|
2
|
+
// group-aware merge below prefers the compiler-derived edge for the same
|
|
3
|
+
// (from, to) relationship. Floor import kinds sit at 0.95 (see graph.ts).
|
|
4
|
+
export const TS_IMPORT_EDGE_CONFIDENCE = 0.99;
|
|
5
|
+
export const TS_REEXPORT_EDGE_CONFIDENCE = 0.99;
|
|
6
|
+
export const TS_EXTENDS_EDGE_CONFIDENCE = 0.97;
|
|
7
|
+
export const TS_IMPLEMENTS_EDGE_CONFIDENCE = 0.97;
|
|
8
|
+
export const TS_CALL_EDGE_CONFIDENCE = 0.9;
|
|
9
|
+
/**
|
|
10
|
+
* Kinds that represent the same underlying relationship collapse together during
|
|
11
|
+
* a merge (highest confidence wins). Kinds with no group keep their distinct
|
|
12
|
+
* (from, to, kind) identity — so floor-only relationships such as
|
|
13
|
+
* `heuristic-container-edge` or `heuristic-auth-session-link` are never dropped
|
|
14
|
+
* by an analyzer that happens to connect the same two nodes a different way.
|
|
15
|
+
*/
|
|
16
|
+
const EDGE_GROUP = {
|
|
17
|
+
// import relationship
|
|
18
|
+
esm: "import",
|
|
19
|
+
"re-export": "import",
|
|
20
|
+
"dynamic-import": "import",
|
|
21
|
+
commonjs: "import",
|
|
22
|
+
"ts-import": "import",
|
|
23
|
+
"ts-reexport": "import",
|
|
24
|
+
// Python imports: the tree-sitter analyzer (py-*) supersedes the regex floor
|
|
25
|
+
// (python-*) for the same (from, to).
|
|
26
|
+
"python-import": "import",
|
|
27
|
+
"python-from-import": "import",
|
|
28
|
+
"py-import": "import",
|
|
29
|
+
"py-from-import": "import",
|
|
30
|
+
// HTML resource references: the tree-sitter analyzer (html-resource)
|
|
31
|
+
// supersedes the regex floor (html-resource-link).
|
|
32
|
+
"html-resource-link": "html-resource",
|
|
33
|
+
"html-resource": "html-resource",
|
|
34
|
+
// inheritance relationship
|
|
35
|
+
"ts-extends": "inheritance",
|
|
36
|
+
"ts-implements": "inheritance",
|
|
37
|
+
// call relationship
|
|
38
|
+
"ts-call": "call",
|
|
39
|
+
};
|
|
40
|
+
function edgeGroupOf(edge) {
|
|
41
|
+
return edge.kind ? EDGE_GROUP[edge.kind] : undefined;
|
|
42
|
+
}
|
|
43
|
+
function confidenceOf(edge) {
|
|
44
|
+
return typeof edge.confidence === "number" && Number.isFinite(edge.confidence)
|
|
45
|
+
? edge.confidence
|
|
46
|
+
: 0;
|
|
47
|
+
}
|
|
48
|
+
function groupedKey(edge, group) {
|
|
49
|
+
return `${edge.from}\0${edge.to}\0${group}`;
|
|
50
|
+
}
|
|
51
|
+
function ungroupedKey(edge) {
|
|
52
|
+
return `${edge.from}\0${edge.to}\0${edge.kind ?? ""}`;
|
|
53
|
+
}
|
|
54
|
+
function sortEdges(edges) {
|
|
55
|
+
return edges.sort((a, b) => a.from.localeCompare(b.from) ||
|
|
56
|
+
a.to.localeCompare(b.to) ||
|
|
57
|
+
(a.kind ?? "").localeCompare(b.kind ?? ""));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Merge analyzer edges into an existing floor bucket (e.g. `graphs.imports`).
|
|
61
|
+
* Within a known relationship group, edges sharing (from, to) collapse to the
|
|
62
|
+
* highest-confidence one (ties favour the later/analyzer edge); ungrouped kinds
|
|
63
|
+
* keep their per-kind identity. Self-edges are dropped. Result is deduped and
|
|
64
|
+
* sorted, matching the floor's ordering contract.
|
|
65
|
+
*/
|
|
66
|
+
export function mergeAnalyzerEdges(floor, analyzer) {
|
|
67
|
+
const grouped = new Map();
|
|
68
|
+
const ungrouped = new Map();
|
|
69
|
+
for (const edge of [...floor, ...analyzer]) {
|
|
70
|
+
if (edge.from === edge.to)
|
|
71
|
+
continue;
|
|
72
|
+
const group = edgeGroupOf(edge);
|
|
73
|
+
if (group) {
|
|
74
|
+
const key = groupedKey(edge, group);
|
|
75
|
+
const existing = grouped.get(key);
|
|
76
|
+
if (!existing || confidenceOf(edge) >= confidenceOf(existing)) {
|
|
77
|
+
grouped.set(key, edge);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
ungrouped.set(ungroupedKey(edge), edge);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return sortEdges([...grouped.values(), ...ungrouped.values()]);
|
|
85
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { graphEdge, normalizeGraphPath } from "../graphPathUtils.js";
|
|
4
|
+
import { isPythonSourcePath, resolvePythonFromImportTargets, resolvePythonImportTarget, } from "../graphPythonImports.js";
|
|
5
|
+
import { getTreeSitterParser } from "./treeSitter.js";
|
|
6
|
+
// Set above the regex floor's 0.95 so the merge prefers the AST-resolved edge
|
|
7
|
+
// for the same (from, to). Resolution itself is shared with the floor, so the
|
|
8
|
+
// only difference is parse-grade extraction.
|
|
9
|
+
const PY_IMPORT_EDGE_CONFIDENCE = 0.97;
|
|
10
|
+
const MAX_PYTHON_SOURCE_BYTES = 512 * 1024;
|
|
11
|
+
function supports(file) {
|
|
12
|
+
return isPythonSourcePath(file);
|
|
13
|
+
}
|
|
14
|
+
/** The bare module text for one `import` name (unwrapping `x.y as z`). */
|
|
15
|
+
function importModuleText(nameNode) {
|
|
16
|
+
const moduleNode = nameNode.type === "aliased_import"
|
|
17
|
+
? nameNode.childForFieldName("name")
|
|
18
|
+
: nameNode;
|
|
19
|
+
const text = moduleNode?.text?.trim();
|
|
20
|
+
return text && text.length > 0 ? text : undefined;
|
|
21
|
+
}
|
|
22
|
+
function collectFileEdges(fromPath, root, pathLookup, edges) {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const push = (target, kind, specifier) => {
|
|
25
|
+
if (target === fromPath)
|
|
26
|
+
return;
|
|
27
|
+
const key = `${kind}\0${target}`;
|
|
28
|
+
if (seen.has(key))
|
|
29
|
+
return;
|
|
30
|
+
seen.add(key);
|
|
31
|
+
edges.push(graphEdge({
|
|
32
|
+
from: fromPath,
|
|
33
|
+
to: target,
|
|
34
|
+
kind,
|
|
35
|
+
confidence: PY_IMPORT_EDGE_CONFIDENCE,
|
|
36
|
+
reason: `tree-sitter resolved Python import '${specifier}' to '${target}'.`,
|
|
37
|
+
}));
|
|
38
|
+
};
|
|
39
|
+
for (const node of root.descendantsOfType("import_statement")) {
|
|
40
|
+
for (const nameNode of childrenForField(node, "name")) {
|
|
41
|
+
const specifier = importModuleText(nameNode);
|
|
42
|
+
if (!specifier)
|
|
43
|
+
continue;
|
|
44
|
+
const target = resolvePythonImportTarget(fromPath, specifier, pathLookup);
|
|
45
|
+
if (target)
|
|
46
|
+
push(target, "py-import", specifier);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const node of root.descendantsOfType("import_from_statement")) {
|
|
50
|
+
const moduleNode = node.childForFieldName("module_name");
|
|
51
|
+
const moduleSpecifier = moduleNode?.text?.trim();
|
|
52
|
+
if (!moduleSpecifier)
|
|
53
|
+
continue;
|
|
54
|
+
const importedNames = childrenForField(node, "name")
|
|
55
|
+
.map((nameNode) => importModuleText(nameNode))
|
|
56
|
+
.filter((name) => Boolean(name));
|
|
57
|
+
for (const { specifier, target } of resolvePythonFromImportTargets(fromPath, moduleSpecifier, importedNames, pathLookup)) {
|
|
58
|
+
push(target, "py-from-import", specifier);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** web-tree-sitter Node#childrenForFieldName, guarded for older runtimes. */
|
|
63
|
+
function childrenForField(node, field) {
|
|
64
|
+
const fn = node.childrenForFieldName;
|
|
65
|
+
if (typeof fn === "function") {
|
|
66
|
+
return fn.call(node, field) ?? [];
|
|
67
|
+
}
|
|
68
|
+
const single = node.childForFieldName(field);
|
|
69
|
+
return single ? [single] : [];
|
|
70
|
+
}
|
|
71
|
+
async function analyze(files, context) {
|
|
72
|
+
if (files.length === 0)
|
|
73
|
+
return { edges: [] };
|
|
74
|
+
const parser = await getTreeSitterParser("python", context.dependencyPath);
|
|
75
|
+
if (!parser)
|
|
76
|
+
return { edges: [] };
|
|
77
|
+
const root = resolve(context.root);
|
|
78
|
+
const edges = [];
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
let content;
|
|
81
|
+
try {
|
|
82
|
+
content = await readFile(resolve(root, file), "utf8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (content.length > MAX_PYTHON_SOURCE_BYTES)
|
|
88
|
+
continue;
|
|
89
|
+
try {
|
|
90
|
+
const tree = parser.parse(content);
|
|
91
|
+
collectFileEdges(normalizeGraphPath(file), tree.rootNode, context.pathLookup, edges);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// A parse failure on one file degrades to the floor for that file.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { edges };
|
|
98
|
+
}
|
|
99
|
+
export const pythonAnalyzer = {
|
|
100
|
+
id: "python",
|
|
101
|
+
dependency: "web-tree-sitter",
|
|
102
|
+
supports,
|
|
103
|
+
analyze,
|
|
104
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type AnalyzerSetting } from "@audit-tools/shared";
|
|
2
|
+
import type { AnalyzerPlanEntry, LanguageAnalyzer } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Registered language analyzers, in within-phase order (seam → TS/JS →
|
|
5
|
+
* Python → HTML → CSS). SQL is a registry stub (recognises `.sql`, emits no
|
|
6
|
+
* edges yet). The tree-sitter analyzers (Python/HTML/CSS) load their grammar
|
|
7
|
+
* from the optional `web-tree-sitter` dependency and degrade to the regex
|
|
8
|
+
* floor when it cannot be resolved.
|
|
9
|
+
*/
|
|
10
|
+
export declare const ANALYZER_REGISTRY: LanguageAnalyzer[];
|
|
11
|
+
export declare function getAnalyzerById(id: string): LanguageAnalyzer | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Deterministically resolve, without installing anything, how each registered
|
|
14
|
+
* analyzer would run for this repo. The conversation-first CLI uses this to
|
|
15
|
+
* decide whether to propose an install before the enrichment executor runs.
|
|
16
|
+
*
|
|
17
|
+
* Resolution rules:
|
|
18
|
+
* - 0 supported in-scope files → `not_applicable`
|
|
19
|
+
* - setting `skip` → `skip`
|
|
20
|
+
* - dependency resolves (repo|cache) → `repo` | `cache`
|
|
21
|
+
* - dependency absent → `absent` (executor installs only
|
|
22
|
+
* for `ephemeral`/`permanent`; `auto` may prompt; `repo` falls to the floor)
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveAnalyzerPlan(root: string, analyzers: Record<string, AnalyzerSetting> | undefined, includedFiles: string[], options?: {
|
|
25
|
+
cacheRoot?: string;
|
|
26
|
+
}): AnalyzerPlanEntry[];
|
|
27
|
+
/**
|
|
28
|
+
* A plan entry whose dependency is absent and whose setting is `auto` (or unset)
|
|
29
|
+
* with in-scope files — the only case that warrants proposing an install in the
|
|
30
|
+
* conversation-first flow. (ephemeral/permanent install silently; repo/skip fall
|
|
31
|
+
* to the floor silently.)
|
|
32
|
+
*/
|
|
33
|
+
export declare function needsInstallDecision(entry: AnalyzerPlanEntry): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { resolveAnalyzerDep, } from "@audit-tools/shared";
|
|
2
|
+
import { typescriptAnalyzer } from "./typescript.js";
|
|
3
|
+
import { pythonAnalyzer } from "./python.js";
|
|
4
|
+
import { htmlAnalyzer } from "./html.js";
|
|
5
|
+
import { cssAnalyzer } from "./css.js";
|
|
6
|
+
import { sqlAnalyzer } from "./sql.js";
|
|
7
|
+
/**
|
|
8
|
+
* Registered language analyzers, in within-phase order (seam → TS/JS →
|
|
9
|
+
* Python → HTML → CSS). SQL is a registry stub (recognises `.sql`, emits no
|
|
10
|
+
* edges yet). The tree-sitter analyzers (Python/HTML/CSS) load their grammar
|
|
11
|
+
* from the optional `web-tree-sitter` dependency and degrade to the regex
|
|
12
|
+
* floor when it cannot be resolved.
|
|
13
|
+
*/
|
|
14
|
+
export const ANALYZER_REGISTRY = [
|
|
15
|
+
typescriptAnalyzer,
|
|
16
|
+
pythonAnalyzer,
|
|
17
|
+
htmlAnalyzer,
|
|
18
|
+
cssAnalyzer,
|
|
19
|
+
sqlAnalyzer,
|
|
20
|
+
];
|
|
21
|
+
export function getAnalyzerById(id) {
|
|
22
|
+
return ANALYZER_REGISTRY.find((analyzer) => analyzer.id === id);
|
|
23
|
+
}
|
|
24
|
+
function settingFor(analyzers, id) {
|
|
25
|
+
return analyzers?.[id] ?? "auto";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Deterministically resolve, without installing anything, how each registered
|
|
29
|
+
* analyzer would run for this repo. The conversation-first CLI uses this to
|
|
30
|
+
* decide whether to propose an install before the enrichment executor runs.
|
|
31
|
+
*
|
|
32
|
+
* Resolution rules:
|
|
33
|
+
* - 0 supported in-scope files → `not_applicable`
|
|
34
|
+
* - setting `skip` → `skip`
|
|
35
|
+
* - dependency resolves (repo|cache) → `repo` | `cache`
|
|
36
|
+
* - dependency absent → `absent` (executor installs only
|
|
37
|
+
* for `ephemeral`/`permanent`; `auto` may prompt; `repo` falls to the floor)
|
|
38
|
+
*/
|
|
39
|
+
export function resolveAnalyzerPlan(root, analyzers, includedFiles, options = {}) {
|
|
40
|
+
const depOptions = options.cacheRoot ? { cacheRoot: options.cacheRoot } : {};
|
|
41
|
+
return ANALYZER_REGISTRY.map((analyzer) => {
|
|
42
|
+
const setting = settingFor(analyzers, analyzer.id);
|
|
43
|
+
const supportedCount = includedFiles.filter((file) => analyzer.supports(file)).length;
|
|
44
|
+
if (supportedCount === 0) {
|
|
45
|
+
return {
|
|
46
|
+
id: analyzer.id,
|
|
47
|
+
dependency: analyzer.dependency,
|
|
48
|
+
setting,
|
|
49
|
+
resolution: "not_applicable",
|
|
50
|
+
supportedCount,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (setting === "skip") {
|
|
54
|
+
return {
|
|
55
|
+
id: analyzer.id,
|
|
56
|
+
dependency: analyzer.dependency,
|
|
57
|
+
setting,
|
|
58
|
+
resolution: "skip",
|
|
59
|
+
supportedCount,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (!analyzer.dependency) {
|
|
63
|
+
// No dependency required: always available.
|
|
64
|
+
return {
|
|
65
|
+
id: analyzer.id,
|
|
66
|
+
dependency: analyzer.dependency,
|
|
67
|
+
setting,
|
|
68
|
+
resolution: "repo",
|
|
69
|
+
supportedCount,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const resolved = resolveAnalyzerDep(analyzer.dependency, root, depOptions);
|
|
73
|
+
if (resolved.via === "repo" || resolved.via === "cache") {
|
|
74
|
+
return {
|
|
75
|
+
id: analyzer.id,
|
|
76
|
+
dependency: analyzer.dependency,
|
|
77
|
+
setting,
|
|
78
|
+
resolution: resolved.via,
|
|
79
|
+
path: resolved.path,
|
|
80
|
+
supportedCount,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
id: analyzer.id,
|
|
85
|
+
dependency: analyzer.dependency,
|
|
86
|
+
setting,
|
|
87
|
+
resolution: "absent",
|
|
88
|
+
supportedCount,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A plan entry whose dependency is absent and whose setting is `auto` (or unset)
|
|
94
|
+
* with in-scope files — the only case that warrants proposing an install in the
|
|
95
|
+
* conversation-first flow. (ephemeral/permanent install silently; repo/skip fall
|
|
96
|
+
* to the floor silently.)
|
|
97
|
+
*/
|
|
98
|
+
export function needsInstallDecision(entry) {
|
|
99
|
+
return entry.resolution === "absent" && entry.setting === "auto";
|
|
100
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an HTML/CSS resource reference (script src, link href, @import, url())
|
|
3
|
+
* to a repo-relative file path, or undefined. Root-relative URLs ("/assets/x")
|
|
4
|
+
* resolve from the repo root; everything else is relative to the referencing
|
|
5
|
+
* file. Query strings and fragments are stripped before resolution.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveResourceUrl(fromPath: string, url: string, pathLookup: Map<string, string>): string | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { normalizeGraphPath, resolveCandidate } from "../graphPathUtils.js";
|
|
3
|
+
// Protocol (http:, data:, mailto:, …), protocol-relative (//), and fragment (#)
|
|
4
|
+
// URLs point outside the repository and are never resolved to a local file.
|
|
5
|
+
const EXTERNAL_URL_PATTERN = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
6
|
+
/**
|
|
7
|
+
* Resolve an HTML/CSS resource reference (script src, link href, @import, url())
|
|
8
|
+
* to a repo-relative file path, or undefined. Root-relative URLs ("/assets/x")
|
|
9
|
+
* resolve from the repo root; everything else is relative to the referencing
|
|
10
|
+
* file. Query strings and fragments are stripped before resolution.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveResourceUrl(fromPath, url, pathLookup) {
|
|
13
|
+
const trimmed = url.trim();
|
|
14
|
+
if (trimmed.length === 0 || EXTERNAL_URL_PATTERN.test(trimmed)) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const withoutQuery = trimmed.split(/[?#]/, 1)[0] ?? "";
|
|
18
|
+
if (withoutQuery.length === 0) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const candidate = withoutQuery.startsWith("/")
|
|
22
|
+
? withoutQuery.slice(1)
|
|
23
|
+
: posix.join(posix.dirname(normalizeGraphPath(fromPath)), withoutQuery);
|
|
24
|
+
return resolveCandidate(candidate, pathLookup);
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { normalizeGraphPath } from "../graphPathUtils.js";
|
|
2
|
+
/**
|
|
3
|
+
* SQL is intentionally a registry stub (per the refactor plan): the seam
|
|
4
|
+
* recognises `.sql` files but emits no edges yet. Registering it keeps the
|
|
5
|
+
* capability surface honest — `analyzer_capability.json` records SQL as
|
|
6
|
+
* applicable-but-empty rather than silently invisible — and leaves a single
|
|
7
|
+
* place to add cross-file resolution (views/foreign keys) later.
|
|
8
|
+
*/
|
|
9
|
+
function supports(file) {
|
|
10
|
+
return normalizeGraphPath(file).toLowerCase().endsWith(".sql");
|
|
11
|
+
}
|
|
12
|
+
function analyze() {
|
|
13
|
+
return { edges: [] };
|
|
14
|
+
}
|
|
15
|
+
export const sqlAnalyzer = {
|
|
16
|
+
id: "sql",
|
|
17
|
+
supports,
|
|
18
|
+
analyze,
|
|
19
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared web-tree-sitter loader for the Tier-S parser analyzers (Python / HTML /
|
|
3
|
+
* CSS). web-tree-sitter is a pure-WASM runtime — no native compilation — so a
|
|
4
|
+
* grammar parses identically on every platform; grammars ship as `.wasm` files
|
|
5
|
+
* in `tree-sitter-wasms` (out/tree-sitter-<lang>.wasm).
|
|
6
|
+
*
|
|
7
|
+
* Everything here degrades to `undefined` on any failure (missing dependency,
|
|
8
|
+
* missing grammar, init error) so a caller simply keeps the deterministic regex
|
|
9
|
+
* floor. The runtime is initialised once and parsed grammars are cached.
|
|
10
|
+
*/
|
|
11
|
+
export interface TsNode {
|
|
12
|
+
type: string;
|
|
13
|
+
text: string;
|
|
14
|
+
namedChildren: TsNode[];
|
|
15
|
+
childForFieldName(field: string): TsNode | null;
|
|
16
|
+
descendantsOfType(type: string | string[]): TsNode[];
|
|
17
|
+
}
|
|
18
|
+
export interface TsTree {
|
|
19
|
+
rootNode: TsNode;
|
|
20
|
+
}
|
|
21
|
+
export interface TsLanguage {
|
|
22
|
+
__brand?: "tree-sitter-language";
|
|
23
|
+
}
|
|
24
|
+
export interface TsParser {
|
|
25
|
+
setLanguage(language: TsLanguage): void;
|
|
26
|
+
parse(source: string): TsTree;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Obtain a parser bound to `grammar` (e.g. "python", "html", "css"), or
|
|
30
|
+
* `undefined` if web-tree-sitter or the grammar wasm cannot be loaded.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getTreeSitterParser(grammar: string, dependencyPath?: string): Promise<TsParser | undefined>;
|
|
33
|
+
/** Test seam: reset the memoised runtime/grammar caches. */
|
|
34
|
+
export declare function __resetTreeSitterForTests(): void;
|