code-auditor-mcp 3.4.12 → 3.4.14
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/CHANGELOG.md +74 -1
- package/dist/analyzers/__verify_self_probe.d.ts +2 -0
- package/dist/analyzers/__verify_self_probe.d.ts.map +1 -0
- package/dist/analyzers/__verify_self_probe.js +5 -0
- package/dist/analyzers/__verify_self_probe.js.map +1 -0
- package/dist/analyzers/analyzerUtils.d.ts +34 -1
- package/dist/analyzers/analyzerUtils.d.ts.map +1 -1
- package/dist/analyzers/analyzerUtils.js +61 -22
- package/dist/analyzers/analyzerUtils.js.map +1 -1
- package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts +18 -23
- package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/cross-language/APIContractAnalyzer.js +198 -186
- package/dist/analyzers/cross-language/APIContractAnalyzer.js.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts +78 -55
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js +244 -243
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.d.ts +27 -37
- package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.js +314 -275
- package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +17 -46
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +620 -523
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
- package/dist/analyzers/documentationAnalyzer.d.ts +7 -2
- package/dist/analyzers/documentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/documentationAnalyzer.js +259 -198
- package/dist/analyzers/documentationAnalyzer.js.map +1 -1
- package/dist/analyzers/invariantsAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/invariantsAnalyzer.js +57 -50
- package/dist/analyzers/invariantsAnalyzer.js.map +1 -1
- package/dist/analyzers/orm/adapterRegistry.d.ts +10 -0
- package/dist/analyzers/orm/adapterRegistry.d.ts.map +1 -1
- package/dist/analyzers/orm/adapterRegistry.js +10 -0
- package/dist/analyzers/orm/adapterRegistry.js.map +1 -1
- package/dist/analyzers/orm/drizzleAdapter.d.ts +22 -0
- package/dist/analyzers/orm/drizzleAdapter.d.ts.map +1 -1
- package/dist/analyzers/orm/drizzleAdapter.js +42 -44
- package/dist/analyzers/orm/drizzleAdapter.js.map +1 -1
- package/dist/analyzers/orm/prismaAdapter.d.ts +30 -0
- package/dist/analyzers/orm/prismaAdapter.d.ts.map +1 -1
- package/dist/analyzers/orm/prismaAdapter.js +30 -0
- package/dist/analyzers/orm/prismaAdapter.js.map +1 -1
- package/dist/analyzers/provenance.d.ts +62 -2
- package/dist/analyzers/provenance.d.ts.map +1 -1
- package/dist/analyzers/provenance.js +443 -349
- package/dist/analyzers/provenance.js.map +1 -1
- package/dist/analyzers/reactAnalyzer.d.ts +11 -0
- package/dist/analyzers/reactAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/reactAnalyzer.js +188 -125
- package/dist/analyzers/reactAnalyzer.js.map +1 -1
- package/dist/analyzers/ruleRegistry.d.ts +110 -1
- package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
- package/dist/analyzers/ruleRegistry.js +1714 -124
- package/dist/analyzers/ruleRegistry.js.map +1 -1
- package/dist/analyzers/ruleTiming.d.ts +54 -0
- package/dist/analyzers/ruleTiming.d.ts.map +1 -0
- package/dist/analyzers/ruleTiming.js +71 -0
- package/dist/analyzers/ruleTiming.js.map +1 -0
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts +13 -0
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.js +362 -283
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +26 -68
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js +430 -391
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +17 -202
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +1073 -1121
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts +3 -49
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +414 -340
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +48 -5
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +262 -134
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +23 -214
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +139 -1521
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +78 -79
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js +613 -480
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts +307 -0
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/codeAnalysis.js +965 -0
- package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -0
- package/dist/analyzers/universal/schema/config.d.ts +31 -0
- package/dist/analyzers/universal/schema/config.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/config.js +57 -0
- package/dist/analyzers/universal/schema/config.js.map +1 -0
- package/dist/analyzers/universal/schema/discovery.d.ts +167 -0
- package/dist/analyzers/universal/schema/discovery.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/discovery.js +616 -0
- package/dist/analyzers/universal/schema/discovery.js.map +1 -0
- package/dist/analyzers/universal/schema/jsonSchema.d.ts +27 -0
- package/dist/analyzers/universal/schema/jsonSchema.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/jsonSchema.js +368 -0
- package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -0
- package/dist/analyzers/universal/schema/migrations.d.ts +90 -0
- package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/migrations.js +238 -0
- package/dist/analyzers/universal/schema/migrations.js.map +1 -0
- package/dist/analyzers/universal/schema/types.d.ts +131 -0
- package/dist/analyzers/universal/schema/types.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/types.js +10 -0
- package/dist/analyzers/universal/schema/types.js.map +1 -0
- package/dist/analyzers/universal/schema/violations.d.ts +44 -0
- package/dist/analyzers/universal/schema/violations.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/violations.js +54 -0
- package/dist/analyzers/universal/schema/violations.js.map +1 -0
- package/dist/auditRunner.d.ts.map +1 -1
- package/dist/auditRunner.js +89 -13
- package/dist/auditRunner.js.map +1 -1
- package/dist/cli.js +315 -40
- package/dist/cli.js.map +1 -1
- package/dist/config/configLoader.d.ts.map +1 -1
- package/dist/config/configLoader.js +9 -7
- package/dist/config/configLoader.js.map +1 -1
- package/dist/config/defaults.d.ts +12 -3
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +16 -6
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/effectiveConfig.d.ts +75 -0
- package/dist/config/effectiveConfig.d.ts.map +1 -0
- package/dist/config/effectiveConfig.js +213 -0
- package/dist/config/effectiveConfig.js.map +1 -0
- package/dist/config/pathProfiles.d.ts +7 -4
- package/dist/config/pathProfiles.d.ts.map +1 -1
- package/dist/config/pathProfiles.js +8 -10
- package/dist/config/pathProfiles.js.map +1 -1
- package/dist/config/thresholdRationales.d.ts +45 -0
- package/dist/config/thresholdRationales.d.ts.map +1 -0
- package/dist/config/thresholdRationales.js +67 -0
- package/dist/config/thresholdRationales.js.map +1 -0
- package/dist/conventions/conventionMiner.d.ts.map +1 -1
- package/dist/conventions/conventionMiner.js +6 -2
- package/dist/conventions/conventionMiner.js.map +1 -1
- package/dist/enforcement/diffGate.d.ts +55 -0
- package/dist/enforcement/diffGate.d.ts.map +1 -0
- package/dist/enforcement/diffGate.js +136 -0
- package/dist/enforcement/diffGate.js.map +1 -0
- package/dist/enforcement/gate.d.ts +36 -0
- package/dist/enforcement/gate.d.ts.map +1 -0
- package/dist/enforcement/gate.js +72 -0
- package/dist/enforcement/gate.js.map +1 -0
- package/dist/enforcement/suppressions.d.ts +41 -0
- package/dist/enforcement/suppressions.d.ts.map +1 -0
- package/dist/enforcement/suppressions.js +170 -0
- package/dist/enforcement/suppressions.js.map +1 -0
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +4 -1
- package/dist/fingerprint.js.map +1 -1
- package/dist/invariants/ruleEngine.d.ts +3 -4
- package/dist/invariants/ruleEngine.d.ts.map +1 -1
- package/dist/invariants/ruleEngine.js +2 -2
- package/dist/invariants/ruleEngine.js.map +1 -1
- package/dist/languages/LanguageOrchestrator.d.ts +14 -0
- package/dist/languages/LanguageOrchestrator.d.ts.map +1 -1
- package/dist/languages/LanguageOrchestrator.js +93 -55
- package/dist/languages/LanguageOrchestrator.js.map +1 -1
- package/dist/languages/LanguageRegistry.d.ts +8 -0
- package/dist/languages/LanguageRegistry.d.ts.map +1 -1
- package/dist/languages/LanguageRegistry.js +8 -0
- package/dist/languages/LanguageRegistry.js.map +1 -1
- package/dist/languages/RuntimeManager.d.ts +82 -60
- package/dist/languages/RuntimeManager.d.ts.map +1 -1
- package/dist/languages/RuntimeManager.js +404 -349
- package/dist/languages/RuntimeManager.js.map +1 -1
- package/dist/languages/UniversalAnalyzer.d.ts +51 -6
- package/dist/languages/UniversalAnalyzer.d.ts.map +1 -1
- package/dist/languages/UniversalAnalyzer.js +127 -88
- package/dist/languages/UniversalAnalyzer.js.map +1 -1
- package/dist/languages/adapterBridge.d.ts +36 -0
- package/dist/languages/adapterBridge.d.ts.map +1 -1
- package/dist/languages/adapterBridge.js +89 -35
- package/dist/languages/adapterBridge.js.map +1 -1
- package/dist/languages/go/GoAdapter.d.ts +66 -23
- package/dist/languages/go/GoAdapter.d.ts.map +1 -1
- package/dist/languages/go/GoAdapter.js +553 -474
- package/dist/languages/go/GoAdapter.js.map +1 -1
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts +33 -4
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts.map +1 -1
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.js +101 -66
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.js.map +1 -1
- package/dist/languages/tree-sitter/converter.d.ts +18 -0
- package/dist/languages/tree-sitter/converter.d.ts.map +1 -1
- package/dist/languages/tree-sitter/converter.js +18 -0
- package/dist/languages/tree-sitter/converter.js.map +1 -1
- package/dist/languages/tree-sitter/parser.d.ts +39 -2
- package/dist/languages/tree-sitter/parser.d.ts.map +1 -1
- package/dist/languages/tree-sitter/parser.js +93 -12
- package/dist/languages/tree-sitter/parser.js.map +1 -1
- package/dist/languages/types.d.ts +67 -2
- package/dist/languages/types.d.ts.map +1 -1
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts +253 -72
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts.map +1 -1
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js +1419 -923
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js.map +1 -1
- package/dist/mcp-tools-shared.d.ts.map +1 -1
- package/dist/mcp-tools-shared.js.map +1 -1
- package/dist/pipeline.d.ts +13 -5
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +381 -200
- package/dist/pipeline.js.map +1 -1
- package/dist/pipelineAdapters.d.ts +3 -8
- package/dist/pipelineAdapters.d.ts.map +1 -1
- package/dist/pipelineAdapters.js +149 -94
- package/dist/pipelineAdapters.js.map +1 -1
- package/dist/presets/presets.d.ts +54 -0
- package/dist/presets/presets.d.ts.map +1 -0
- package/dist/presets/presets.js +181 -0
- package/dist/presets/presets.js.map +1 -0
- package/dist/reporting/sarifReportGenerator.d.ts.map +1 -1
- package/dist/reporting/sarifReportGenerator.js +6 -3
- package/dist/reporting/sarifReportGenerator.js.map +1 -1
- package/dist/ruleAliases.d.ts +67 -0
- package/dist/ruleAliases.d.ts.map +1 -0
- package/dist/ruleAliases.js +102 -0
- package/dist/ruleAliases.js.map +1 -0
- package/dist/styles/cssAstExtractor.d.ts.map +1 -1
- package/dist/styles/cssAstExtractor.js +78 -18
- package/dist/styles/cssAstExtractor.js.map +1 -1
- package/dist/styles/styleIndexer.d.ts +6 -0
- package/dist/styles/styleIndexer.d.ts.map +1 -1
- package/dist/styles/styleIndexer.js +26 -4
- package/dist/styles/styleIndexer.js.map +1 -1
- package/dist/styles/tailwindConfigLoader.d.ts +8 -6
- package/dist/styles/tailwindConfigLoader.d.ts.map +1 -1
- package/dist/styles/tailwindConfigLoader.js +61 -15
- package/dist/styles/tailwindConfigLoader.js.map +1 -1
- package/dist/styles/tailwindProbe.d.ts +31 -0
- package/dist/styles/tailwindProbe.d.ts.map +1 -1
- package/dist/styles/tailwindProbe.js +145 -4
- package/dist/styles/tailwindProbe.js.map +1 -1
- package/dist/styles/tailwindUtilityExpander.d.ts.map +1 -1
- package/dist/styles/tailwindUtilityExpander.js +13 -1
- package/dist/styles/tailwindUtilityExpander.js.map +1 -1
- package/dist/types.d.ts +181 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/fileDiscovery.d.ts +9 -0
- package/dist/utils/fileDiscovery.d.ts.map +1 -1
- package/dist/utils/fileDiscovery.js +21 -0
- package/dist/utils/fileDiscovery.js.map +1 -1
- package/package.json +5 -3
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/hook-audit.sh +25 -7
- package/plugin/skills/code-auditor/SKILL.md +5 -5
|
@@ -24,20 +24,135 @@ import { UniversalAnalyzer } from '../../languages/UniversalAnalyzer.js';
|
|
|
24
24
|
import { VALIDATOR_PACKAGES } from '../provenance.js';
|
|
25
25
|
import { makeVisitorStatus } from '../../pipeline.js';
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
|
+
// Call-graph helpers — pure functions with no `this` state, kept off the
|
|
28
|
+
// analyzer class to bound its aggregate cyclomatic complexity.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* BFS through the call graph (graph_cache) up to maxDepth to check if
|
|
32
|
+
* any path from startFuncId reaches a validator function ID.
|
|
33
|
+
*/
|
|
34
|
+
function bfsReachesValidator(indexHandle, startFuncId, validatorIds, maxDepth) {
|
|
35
|
+
const visited = new Set();
|
|
36
|
+
let currentLevel = [startFuncId];
|
|
37
|
+
for (let d = 0; d < maxDepth; d++) {
|
|
38
|
+
const nextLevel = [];
|
|
39
|
+
for (const funcId of currentLevel) {
|
|
40
|
+
if (validatorIds.has(funcId))
|
|
41
|
+
return true;
|
|
42
|
+
if (visited.has(funcId))
|
|
43
|
+
continue;
|
|
44
|
+
visited.add(funcId);
|
|
45
|
+
// Get callees from graph_cache call edges
|
|
46
|
+
const callees = indexHandle
|
|
47
|
+
.query(`SELECT neighbor_key FROM graph_cache
|
|
48
|
+
WHERE graph_type = 'call' AND node_key = ?`, [String(funcId)]);
|
|
49
|
+
for (const callee of callees) {
|
|
50
|
+
const calleeId = parseInt(callee.neighbor_key, 10);
|
|
51
|
+
if (!isNaN(calleeId) && !visited.has(calleeId)) {
|
|
52
|
+
nextLevel.push(calleeId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
currentLevel = nextLevel;
|
|
57
|
+
}
|
|
58
|
+
// Check any remaining nodes at the final level
|
|
59
|
+
for (const funcId of currentLevel) {
|
|
60
|
+
if (validatorIds.has(funcId))
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Expand a writer's directly-written table set with the tables written by
|
|
67
|
+
* functions it calls (depth-1 callee expansion via graph_cache). Requires the
|
|
68
|
+
* call-graph infrastructure (graph_cache + functions tables, populated by
|
|
69
|
+
* code-audit index sync); without it, returns the direct write set unchanged.
|
|
70
|
+
*/
|
|
71
|
+
function expandWrittenTables(indexHandle, key, initialTables, graph) {
|
|
72
|
+
const allTables = new Set(initialTables);
|
|
73
|
+
const { fnIdLookup, hasGraphData } = graph;
|
|
74
|
+
if (!fnIdLookup || !hasGraphData)
|
|
75
|
+
return allTables;
|
|
76
|
+
const funcId = fnIdLookup.get(key);
|
|
77
|
+
if (funcId === undefined)
|
|
78
|
+
return allTables;
|
|
79
|
+
const calleeRows = indexHandle
|
|
80
|
+
.query(`SELECT neighbor_key FROM graph_cache
|
|
81
|
+
WHERE graph_type = 'call' AND node_key = ?`, [String(funcId)]);
|
|
82
|
+
for (const callee of calleeRows) {
|
|
83
|
+
const calleeFuncs = indexHandle
|
|
84
|
+
.query(`SELECT name, file_path FROM functions WHERE id = ?`, [parseInt(callee.neighbor_key, 10)]);
|
|
85
|
+
for (const cf of calleeFuncs) {
|
|
86
|
+
const calleeTables = indexHandle
|
|
87
|
+
.query(`SELECT DISTINCT table_name FROM schema_usage
|
|
88
|
+
WHERE usage_type IN ('insert', 'update', 'delete', 'create')
|
|
89
|
+
AND function_name = ? AND file_path = ?`, [cf.name, cf.file_path]);
|
|
90
|
+
for (const ct of calleeTables) {
|
|
91
|
+
allTables.add(ct.table_name);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return allTables;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* BFS outward from test-file function IDs through the call graph (graph_cache)
|
|
99
|
+
* up to maxDepth, collecting every function ID reached. Used for the
|
|
100
|
+
* static-reach fallback: a high-risk function not in this set is uncovered.
|
|
101
|
+
*/
|
|
102
|
+
function collectReachableIds(indexHandle, startIds, maxDepth) {
|
|
103
|
+
const reachableIds = new Set();
|
|
104
|
+
for (const startId of startIds) {
|
|
105
|
+
const visited = new Set();
|
|
106
|
+
let currentLevel = [startId];
|
|
107
|
+
for (let d = 0; d < maxDepth; d++) {
|
|
108
|
+
const nextLevel = [];
|
|
109
|
+
for (const funcId of currentLevel) {
|
|
110
|
+
if (visited.has(funcId))
|
|
111
|
+
continue;
|
|
112
|
+
visited.add(funcId);
|
|
113
|
+
reachableIds.add(funcId);
|
|
114
|
+
const callees = indexHandle
|
|
115
|
+
.query(`SELECT neighbor_key FROM graph_cache
|
|
116
|
+
WHERE graph_type = 'call' AND node_key = ?`, [String(funcId)]);
|
|
117
|
+
for (const callee of callees) {
|
|
118
|
+
const calleeId = parseInt(callee.neighbor_key, 10);
|
|
119
|
+
if (!isNaN(calleeId) && !visited.has(calleeId)) {
|
|
120
|
+
nextLevel.push(calleeId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
currentLevel = nextLevel;
|
|
125
|
+
}
|
|
126
|
+
// Check the final level's nodes that never got expanded.
|
|
127
|
+
for (const funcId of currentLevel) {
|
|
128
|
+
if (!reachableIds.has(funcId))
|
|
129
|
+
reachableIds.add(funcId);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return reachableIds;
|
|
133
|
+
}
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
27
135
|
// Analyzer
|
|
28
136
|
// ---------------------------------------------------------------------------
|
|
137
|
+
const ANALYZER_NAME = 'cross-domain';
|
|
138
|
+
/**
|
|
139
|
+
* Cross domain analyzer.
|
|
140
|
+
*/
|
|
29
141
|
export class CrossDomainAnalyzer extends UniversalAnalyzer {
|
|
30
|
-
name =
|
|
142
|
+
name = ANALYZER_NAME;
|
|
31
143
|
description = 'Detects cross-domain issues (schema lifecycle, validation bypass, coverage gaps)';
|
|
32
144
|
category = 'architecture';
|
|
33
145
|
/**
|
|
34
146
|
* Full override: query the code index DB for cross-domain findings.
|
|
35
147
|
* The base-class per-file AST loop is bypassed — all detection is
|
|
36
148
|
* post-analysis across the indexed data.
|
|
149
|
+
* @param config
|
|
150
|
+
* @param files
|
|
151
|
+
* @param options
|
|
152
|
+
* @returns
|
|
37
153
|
*/
|
|
38
154
|
async analyze(files, config = {}, options = {}) {
|
|
39
155
|
const startTime = Date.now();
|
|
40
|
-
const violations = [];
|
|
41
156
|
const indexHandle = config.indexHandle;
|
|
42
157
|
if (!indexHandle) {
|
|
43
158
|
return {
|
|
@@ -45,586 +160,568 @@ export class CrossDomainAnalyzer extends UniversalAnalyzer {
|
|
|
45
160
|
errors: [{ file: '', error: 'Failed to open code index database' }],
|
|
46
161
|
status: makeVisitorStatus(0),
|
|
47
162
|
executionTime: Date.now() - startTime,
|
|
48
|
-
analyzerName:
|
|
163
|
+
analyzerName: ANALYZER_NAME,
|
|
49
164
|
metrics: { filesAnalyzed: 0, totalViolations: 0, executionTime: Date.now() - startTime },
|
|
50
165
|
};
|
|
51
166
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
167
|
+
const filePathClause = this.resolveFilePathClause(config);
|
|
168
|
+
const violations = this.runDetectors(indexHandle, config, filePathClause);
|
|
169
|
+
const uniqueFiles = this.countDistinctFiles(indexHandle, filePathClause);
|
|
170
|
+
return {
|
|
171
|
+
violations,
|
|
172
|
+
errors: [],
|
|
173
|
+
status: makeVisitorStatus(uniqueFiles || files.length),
|
|
174
|
+
executionTime: Date.now() - startTime,
|
|
175
|
+
analyzerName: ANALYZER_NAME,
|
|
176
|
+
metrics: {
|
|
177
|
+
filesAnalyzed: uniqueFiles || files.length,
|
|
178
|
+
totalViolations: violations.length,
|
|
57
179
|
executionTime: Date.now() - startTime,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Project root scoping: in test environments the in-memory DB singleton is
|
|
185
|
+
* shared across tests, so schema_usage entries from previous test cases leak
|
|
186
|
+
* into subsequent queries. Build a file-path LIKE clause scoped to root.
|
|
187
|
+
*/
|
|
188
|
+
resolveFilePathClause(config) {
|
|
65
189
|
const projectRoot = config.projectRoot;
|
|
66
190
|
const resolvedRoot = projectRoot ? path.resolve(projectRoot) : undefined;
|
|
67
|
-
|
|
68
|
-
? {
|
|
191
|
+
return resolvedRoot
|
|
192
|
+
? { clause: 'AND file_path LIKE ?', param: `${resolvedRoot}%` }
|
|
69
193
|
: undefined;
|
|
194
|
+
}
|
|
195
|
+
/** Run the R1/R3/R4 detectors and collect their violations. */
|
|
196
|
+
runDetectors(indexHandle, config, filePathClause) {
|
|
197
|
+
const violations = [];
|
|
70
198
|
// R1 — Schema lifecycle detectors
|
|
71
199
|
const lifecycle = config.schemaLifecycle ?? {};
|
|
72
200
|
if (lifecycle.enableWrittenNeverRead !== false) {
|
|
73
|
-
violations.push(...
|
|
201
|
+
violations.push(...detectWrittenNeverRead(indexHandle, filePathClause));
|
|
74
202
|
}
|
|
75
203
|
if (lifecycle.enableReadNeverWritten !== false) {
|
|
76
|
-
violations.push(...
|
|
204
|
+
violations.push(...detectReadNeverWritten(indexHandle, filePathClause));
|
|
77
205
|
}
|
|
78
206
|
if (lifecycle.enableTransactionBoundaryRisk !== false) {
|
|
79
207
|
const txnTableMax = lifecycle.txnTableMax ?? 4;
|
|
80
|
-
violations.push(...
|
|
208
|
+
violations.push(...detectTransactionBoundaryRisk(indexHandle, txnTableMax, filePathClause));
|
|
81
209
|
}
|
|
82
210
|
// R3 — Validation-bypass detection
|
|
83
211
|
const bypass = config.validatorBypass;
|
|
84
212
|
if (bypass) {
|
|
85
|
-
violations.push(...
|
|
213
|
+
violations.push(...detectValidationBypass(indexHandle, bypass, filePathClause));
|
|
86
214
|
}
|
|
87
215
|
// R4 — Coverage by importance
|
|
88
216
|
const coverage = config.coverage;
|
|
89
217
|
if (coverage) {
|
|
90
|
-
violations.push(...
|
|
218
|
+
violations.push(...detectUncoveredRisk(indexHandle, coverage, filePathClause));
|
|
91
219
|
}
|
|
92
|
-
|
|
93
|
-
|
|
220
|
+
return violations;
|
|
221
|
+
}
|
|
222
|
+
/** Count distinct files with schema_usage entries. */
|
|
223
|
+
countDistinctFiles(indexHandle, filePathClause) {
|
|
224
|
+
const rows = indexHandle
|
|
94
225
|
.query(filePathClause
|
|
95
226
|
? `SELECT COUNT(DISTINCT file_path) as cnt FROM schema_usage WHERE 1=1 ${filePathClause.clause}`
|
|
96
227
|
: 'SELECT COUNT(DISTINCT file_path) as cnt FROM schema_usage', (filePathClause ? [filePathClause.param] : []));
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
violations,
|
|
100
|
-
errors: [],
|
|
101
|
-
status: makeVisitorStatus(uniqueFiles || files.length),
|
|
102
|
-
executionTime: Date.now() - startTime,
|
|
103
|
-
analyzerName: 'cross-domain',
|
|
104
|
-
metrics: {
|
|
105
|
-
filesAnalyzed: uniqueFiles || files.length,
|
|
106
|
-
totalViolations: violations.length,
|
|
107
|
-
executionTime: Date.now() - startTime,
|
|
108
|
-
},
|
|
109
|
-
};
|
|
228
|
+
return rows[0]?.cnt ?? 0;
|
|
110
229
|
}
|
|
111
230
|
/** No-op — all detection is DB-based. */
|
|
112
231
|
async analyzeAST() {
|
|
113
232
|
return [];
|
|
114
233
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
234
|
+
}
|
|
235
|
+
// ── R1: Written-Never-Read ──────────────────────────────────────────────
|
|
236
|
+
/**
|
|
237
|
+
* Detect tables that are written to (INSERT/UPDATE/DELETE/CREATE) but
|
|
238
|
+
* never read from (SELECT). These might be dead writes or missed read paths.
|
|
239
|
+
*/
|
|
240
|
+
function detectWrittenNeverRead(indexHandle, filePath) {
|
|
241
|
+
const violations = [];
|
|
242
|
+
const fpWhere = filePath ? filePath.clause : '';
|
|
243
|
+
const rows = indexHandle
|
|
244
|
+
.query(`SELECT DISTINCT table_name, file_path, function_name, line, usage_type
|
|
245
|
+
FROM schema_usage
|
|
246
|
+
WHERE usage_type IN ('insert', 'update', 'delete', 'create')
|
|
247
|
+
${fpWhere}
|
|
248
|
+
AND table_name NOT IN (
|
|
249
|
+
SELECT DISTINCT table_name FROM schema_usage WHERE usage_type = 'select' ${fpWhere}
|
|
250
|
+
)
|
|
251
|
+
ORDER BY table_name, file_path`, (filePath ? [filePath.param, filePath.param] : []));
|
|
252
|
+
// Deduplicate by table_name — one violation per table, anchored to
|
|
253
|
+
// the first writing file encountered.
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
if (seen.has(row.table_name))
|
|
257
|
+
continue;
|
|
258
|
+
seen.add(row.table_name);
|
|
259
|
+
violations.push({
|
|
260
|
+
file: row.file_path,
|
|
261
|
+
line: row.line,
|
|
262
|
+
column: 0,
|
|
263
|
+
severity: 'suggestion',
|
|
264
|
+
message: `Table '${row.table_name}' is written (${row.usage_type}) but never read (SELECT). Consider removing unused writes or adding read paths.`,
|
|
265
|
+
rule: 'cross-domain/written-never-read',
|
|
266
|
+
analyzer: ANALYZER_NAME,
|
|
267
|
+
functionName: row.function_name,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return violations;
|
|
271
|
+
}
|
|
272
|
+
// ── R1: Read-Never-Written ──────────────────────────────────────────────
|
|
273
|
+
/**
|
|
274
|
+
* Detect tables that are read from (SELECT) but never written to
|
|
275
|
+
* (INSERT/UPDATE/DELETE/CREATE). These may be external/managed tables
|
|
276
|
+
* or indicate missing write coverage.
|
|
277
|
+
*/
|
|
278
|
+
function detectReadNeverWritten(indexHandle, filePath) {
|
|
279
|
+
const violations = [];
|
|
280
|
+
const fpWhere = filePath ? filePath.clause : '';
|
|
281
|
+
const rows = indexHandle
|
|
282
|
+
.query(`SELECT DISTINCT table_name, file_path, function_name, line, usage_type
|
|
283
|
+
FROM schema_usage
|
|
284
|
+
WHERE usage_type = 'select'
|
|
285
|
+
${fpWhere}
|
|
286
|
+
AND table_name NOT IN (
|
|
287
|
+
SELECT DISTINCT table_name FROM schema_usage
|
|
288
|
+
WHERE usage_type IN ('insert', 'update', 'delete', 'create') ${fpWhere}
|
|
289
|
+
)
|
|
290
|
+
ORDER BY table_name, file_path`, (filePath ? [filePath.param, filePath.param] : []));
|
|
291
|
+
const seen = new Set();
|
|
292
|
+
for (const row of rows) {
|
|
293
|
+
if (seen.has(row.table_name))
|
|
294
|
+
continue;
|
|
295
|
+
seen.add(row.table_name);
|
|
296
|
+
violations.push({
|
|
297
|
+
file: row.file_path,
|
|
298
|
+
line: row.line,
|
|
299
|
+
column: 0,
|
|
300
|
+
severity: 'suggestion',
|
|
301
|
+
message: `Table '${row.table_name}' is read (SELECT) but never written (INSERT/UPDATE/DELETE). This may be an external/managed table, or indicate missing write coverage.`,
|
|
302
|
+
rule: 'cross-domain/read-never-written',
|
|
303
|
+
analyzer: ANALYZER_NAME,
|
|
304
|
+
functionName: row.function_name,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return violations;
|
|
308
|
+
}
|
|
309
|
+
// ── R1: Transaction-Boundary Risk ───────────────────────────────────────
|
|
310
|
+
/**
|
|
311
|
+
* Detect functions that write to ≥ txnTableMax distinct tables, including
|
|
312
|
+
* tables written by depth-1 callees via the call graph (graph_cache).
|
|
313
|
+
*
|
|
314
|
+
* This surfaces functions that may have transaction-boundary risk:
|
|
315
|
+
* writing to too many tables in a single logical operation can lead to
|
|
316
|
+
* long-running transactions, lock contention, and partial-failure complexity.
|
|
317
|
+
*/
|
|
318
|
+
function detectTransactionBoundaryRisk(indexHandle, txnTableMax, filePath) {
|
|
319
|
+
const fpWhere = filePath ? filePath.clause : '';
|
|
320
|
+
const fpParams = filePath ? [filePath.param] : [];
|
|
321
|
+
// Query schema_usage directly (no JOIN on functions) so this detector
|
|
322
|
+
// works whether or not deepSync has populated the functions table.
|
|
323
|
+
const writerRows = indexHandle
|
|
324
|
+
.query(`SELECT su.function_name, su.file_path, su.table_name, MIN(su.line) as line
|
|
325
|
+
FROM schema_usage su
|
|
326
|
+
WHERE su.usage_type IN ('insert', 'update', 'delete', 'create')
|
|
327
|
+
${fpWhere}
|
|
328
|
+
GROUP BY su.function_name, su.file_path, su.table_name
|
|
329
|
+
ORDER BY su.function_name, su.file_path`, fpParams);
|
|
330
|
+
if (writerRows.length === 0)
|
|
331
|
+
return [];
|
|
332
|
+
const funcWrites = groupWriterTables(writerRows);
|
|
333
|
+
const graph = resolveCallGraphContext(indexHandle);
|
|
334
|
+
return flagTransactionBoundaryWrites(funcWrites, indexHandle, graph, txnTableMax);
|
|
335
|
+
}
|
|
336
|
+
/** Group written tables by (file_path, function_name) key. */
|
|
337
|
+
function groupWriterTables(writerRows) {
|
|
338
|
+
const funcWrites = new Map();
|
|
339
|
+
for (const row of writerRows) {
|
|
340
|
+
const key = `${row.file_path}::${row.function_name}`;
|
|
341
|
+
const entry = funcWrites.get(key);
|
|
342
|
+
if (entry) {
|
|
343
|
+
entry.tables.add(row.table_name);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
funcWrites.set(key, {
|
|
347
|
+
filePath: row.file_path,
|
|
141
348
|
line: row.line,
|
|
142
|
-
|
|
143
|
-
severity: 'suggestion',
|
|
144
|
-
message: `Table '${row.table_name}' is written (${row.usage_type}) but never read (SELECT). Consider removing unused writes or adding read paths.`,
|
|
145
|
-
rule: 'cross-domain/written-never-read',
|
|
146
|
-
analyzer: this.name,
|
|
147
|
-
functionName: row.function_name,
|
|
349
|
+
tables: new Set([row.table_name]),
|
|
148
350
|
});
|
|
149
351
|
}
|
|
150
|
-
return violations;
|
|
151
352
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
${fpWhere}
|
|
166
|
-
AND table_name NOT IN (
|
|
167
|
-
SELECT DISTINCT table_name FROM schema_usage
|
|
168
|
-
WHERE usage_type IN ('insert', 'update', 'delete', 'create') ${fpWhere}
|
|
169
|
-
)
|
|
170
|
-
ORDER BY table_name, file_path`, (filePath ? [filePath.param, filePath.param] : []));
|
|
171
|
-
const seen = new Set();
|
|
172
|
-
for (const row of rows) {
|
|
173
|
-
if (seen.has(row.table_name))
|
|
174
|
-
continue;
|
|
175
|
-
seen.add(row.table_name);
|
|
353
|
+
return funcWrites;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Flag functions whose depth-1-expanded write set reaches txnTableMax.
|
|
357
|
+
* When graph_cache is unpopulated, expandWrittenTables degrades gracefully
|
|
358
|
+
* to reporting direct writes only.
|
|
359
|
+
*/
|
|
360
|
+
function flagTransactionBoundaryWrites(funcWrites, indexHandle, graph, txnTableMax) {
|
|
361
|
+
const violations = [];
|
|
362
|
+
for (const [key, funcData] of funcWrites) {
|
|
363
|
+
const allTables = expandWrittenTables(indexHandle, key, funcData.tables, graph);
|
|
364
|
+
if (allTables.size >= txnTableMax) {
|
|
365
|
+
const tableList = [...allTables].sort().join(', ');
|
|
176
366
|
violations.push({
|
|
177
|
-
file:
|
|
178
|
-
line:
|
|
367
|
+
file: funcData.filePath,
|
|
368
|
+
line: funcData.line,
|
|
179
369
|
column: 0,
|
|
180
370
|
severity: 'suggestion',
|
|
181
|
-
message: `
|
|
182
|
-
rule: 'cross-domain/
|
|
183
|
-
analyzer:
|
|
184
|
-
functionName:
|
|
371
|
+
message: `Function writes to ${allTables.size} distinct tables (threshold: ${txnTableMax}): ${tableList}. This may indicate transaction-boundary risk — consider splitting writes across smaller transactional scopes.`,
|
|
372
|
+
rule: 'cross-domain/transaction-boundary',
|
|
373
|
+
analyzer: ANALYZER_NAME,
|
|
374
|
+
functionName: key.split('::')[1],
|
|
185
375
|
});
|
|
186
376
|
}
|
|
187
|
-
return violations;
|
|
188
377
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const funcWrites = new Map();
|
|
216
|
-
for (const row of writerRows) {
|
|
217
|
-
const key = `${row.file_path}::${row.function_name}`;
|
|
218
|
-
const entry = funcWrites.get(key);
|
|
219
|
-
if (entry) {
|
|
220
|
-
entry.tables.add(row.table_name);
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
funcWrites.set(key, {
|
|
224
|
-
filePath: row.file_path,
|
|
225
|
-
line: row.line,
|
|
226
|
-
tables: new Set([row.table_name]),
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
// ── 2. Attempt callee expansion via graph_cache when the infrastructure
|
|
231
|
-
// is populated (requires code-audit index sync). When the functions or
|
|
232
|
-
// graph_cache tables are empty/missing, we fall back to reporting on
|
|
233
|
-
// direct writes only — the basic signal is still useful without the
|
|
234
|
-
// call-graph context.
|
|
235
|
-
const hasGraphData = (() => {
|
|
236
|
-
try {
|
|
237
|
-
const row = indexHandle.query("SELECT COUNT(*) AS n FROM graph_cache WHERE graph_type = 'call'");
|
|
238
|
-
const cnt = row[0];
|
|
239
|
-
return (cnt?.n ?? 0) > 0;
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
return false;
|
|
243
|
-
}
|
|
244
|
-
})();
|
|
245
|
-
// Build a lookup of function IDs from the functions table (only if it
|
|
246
|
-
// has data — i.e., deepSync has been run).
|
|
247
|
-
let fnIdLookup = null;
|
|
248
|
-
if (hasGraphData) {
|
|
249
|
-
try {
|
|
250
|
-
const fnRows = indexHandle.query('SELECT id, name, file_path FROM functions');
|
|
251
|
-
if (fnRows.length > 0) {
|
|
252
|
-
fnIdLookup = new Map();
|
|
253
|
-
for (const r of fnRows) {
|
|
254
|
-
fnIdLookup.set(`${r.file_path}::${r.name}`, r.id);
|
|
255
|
-
}
|
|
378
|
+
return violations;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Resolve the call-graph infrastructure available for callee expansion:
|
|
382
|
+
* whether graph_cache has any 'call' edges, and a function key → id lookup
|
|
383
|
+
* built from the functions table (populated only by deepSync). Any failure
|
|
384
|
+
* degrades gracefully to direct-write-only detection.
|
|
385
|
+
*/
|
|
386
|
+
function resolveCallGraphContext(indexHandle) {
|
|
387
|
+
let hasGraphData = false;
|
|
388
|
+
try {
|
|
389
|
+
const row = indexHandle.query("SELECT COUNT(*) AS n FROM graph_cache WHERE graph_type = 'call'");
|
|
390
|
+
const cnt = row[0];
|
|
391
|
+
hasGraphData = (cnt?.n ?? 0) > 0;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
hasGraphData = false;
|
|
395
|
+
}
|
|
396
|
+
let fnIdLookup = null;
|
|
397
|
+
if (hasGraphData) {
|
|
398
|
+
try {
|
|
399
|
+
const fnRows = indexHandle.query('SELECT id, name, file_path FROM functions');
|
|
400
|
+
if (fnRows.length > 0) {
|
|
401
|
+
fnIdLookup = new Map();
|
|
402
|
+
for (const r of fnRows) {
|
|
403
|
+
fnIdLookup.set(`${r.file_path}::${r.name}`, r.id);
|
|
256
404
|
}
|
|
257
405
|
}
|
|
258
|
-
catch {
|
|
259
|
-
// functions table might not exist or be unpopulated
|
|
260
|
-
}
|
|
261
406
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// Depth-1 callee expansion: only when graph_cache and functions tables
|
|
265
|
-
// are both populated. Without deepSync, this is skipped gracefully.
|
|
266
|
-
if (fnIdLookup && hasGraphData) {
|
|
267
|
-
const funcId = fnIdLookup.get(key);
|
|
268
|
-
if (funcId !== undefined) {
|
|
269
|
-
const calleeRows = indexHandle
|
|
270
|
-
.query(`SELECT neighbor_key FROM graph_cache
|
|
271
|
-
WHERE graph_type = 'call' AND node_key = ?`, [String(funcId)]);
|
|
272
|
-
for (const callee of calleeRows) {
|
|
273
|
-
const calleeFuncs = indexHandle
|
|
274
|
-
.query(`SELECT name, file_path FROM functions WHERE id = ?`, [parseInt(callee.neighbor_key, 10)]);
|
|
275
|
-
for (const cf of calleeFuncs) {
|
|
276
|
-
const calleeTables = indexHandle
|
|
277
|
-
.query(`SELECT DISTINCT table_name FROM schema_usage
|
|
278
|
-
WHERE usage_type IN ('insert', 'update', 'delete', 'create')
|
|
279
|
-
AND function_name = ? AND file_path = ?`, [cf.name, cf.file_path]);
|
|
280
|
-
for (const ct of calleeTables) {
|
|
281
|
-
allTables.add(ct.table_name);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
if (allTables.size >= txnTableMax) {
|
|
288
|
-
const tableList = [...allTables].sort().join(', ');
|
|
289
|
-
violations.push({
|
|
290
|
-
file: funcData.filePath,
|
|
291
|
-
line: funcData.line,
|
|
292
|
-
column: 0,
|
|
293
|
-
severity: 'suggestion',
|
|
294
|
-
message: `Function writes to ${allTables.size} distinct tables (threshold: ${txnTableMax}): ${tableList}. This may indicate transaction-boundary risk — consider splitting writes across smaller transactional scopes.`,
|
|
295
|
-
rule: 'cross-domain/transaction-boundary',
|
|
296
|
-
analyzer: this.name,
|
|
297
|
-
functionName: key.split('::')[1],
|
|
298
|
-
});
|
|
299
|
-
}
|
|
407
|
+
catch {
|
|
408
|
+
// functions table might not exist or be unpopulated
|
|
300
409
|
}
|
|
410
|
+
}
|
|
411
|
+
return { hasGraphData, fnIdLookup };
|
|
412
|
+
}
|
|
413
|
+
// ── R3: Validation-Bypass ────────────────────────────────────────────────
|
|
414
|
+
/**
|
|
415
|
+
* Detect write functions that don't reach a validator, when a majority of
|
|
416
|
+
* peer writers in the same directory do (BFS ≤ configurable depth).
|
|
417
|
+
*
|
|
418
|
+
* Validator identification (priority order):
|
|
419
|
+
* 1. User-configured validators list
|
|
420
|
+
* 2. Provenanced: exported functions in files importing VALIDATOR_PACKAGES
|
|
421
|
+
* 3. Heuristic fallback: name GLOB 'validate*' OR 'assert*' (when both
|
|
422
|
+
* above are silent)
|
|
423
|
+
*/
|
|
424
|
+
function detectValidationBypass(indexHandle, config, filePath) {
|
|
425
|
+
const violations = [];
|
|
426
|
+
const { validators: userValidators = [], modeShare = 0.8, minCorpus = 20, depth = 3, } = config;
|
|
427
|
+
const validatorIds = buildValidatorIds(indexHandle, userValidators);
|
|
428
|
+
if (validatorIds.size === 0)
|
|
429
|
+
return violations;
|
|
430
|
+
const fpAliasWhere = filePath ? `AND su.${filePath.clause.slice(4)}` : '';
|
|
431
|
+
const writers = indexHandle
|
|
432
|
+
.query(`SELECT DISTINCT su.function_name, su.file_path, su.line, f.id as function_id
|
|
433
|
+
FROM schema_usage su
|
|
434
|
+
JOIN functions f ON f.name = su.function_name
|
|
435
|
+
AND f.file_path = su.file_path
|
|
436
|
+
WHERE su.usage_type IN ('insert', 'update', 'delete', 'create')
|
|
437
|
+
${fpAliasWhere}
|
|
438
|
+
ORDER BY su.file_path, su.function_name`, (filePath ? [filePath.param] : []));
|
|
439
|
+
if (writers.length === 0)
|
|
301
440
|
return violations;
|
|
441
|
+
const writerCoverage = computeWriterCoverage(writers, validatorIds, indexHandle, depth);
|
|
442
|
+
violations.push(...flagUncoveredWriters(writers, writerCoverage, { minCorpus, modeShare, depth }));
|
|
443
|
+
return violations;
|
|
444
|
+
}
|
|
445
|
+
/** BFS from each writer to check validator reach, deduplicated by key. */
|
|
446
|
+
function computeWriterCoverage(writers, validatorIds, indexHandle, depth) {
|
|
447
|
+
const writerCoverage = new Map();
|
|
448
|
+
for (const w of writers) {
|
|
449
|
+
const key = `${w.file_path}::${w.function_name}`;
|
|
450
|
+
if (writerCoverage.has(key))
|
|
451
|
+
continue; // deduplicate
|
|
452
|
+
const covered = bfsReachesValidator(indexHandle, w.function_id, validatorIds, depth);
|
|
453
|
+
writerCoverage.set(key, {
|
|
454
|
+
covered,
|
|
455
|
+
line: w.line,
|
|
456
|
+
funcName: w.function_name,
|
|
457
|
+
});
|
|
302
458
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
const vPath = v.substring(0, hashIdx);
|
|
324
|
-
const vName = v.substring(hashIdx + 1);
|
|
325
|
-
const rows = indexHandle
|
|
326
|
-
.query('SELECT id FROM functions WHERE name = ? AND file_path = ?', [vName, vPath]);
|
|
327
|
-
for (const r of rows)
|
|
328
|
-
validatorIds.add(r.id);
|
|
329
|
-
}
|
|
330
|
-
else {
|
|
331
|
-
const rows = indexHandle
|
|
332
|
-
.query('SELECT id FROM functions WHERE name = ?', [v]);
|
|
333
|
-
for (const r of rows)
|
|
334
|
-
validatorIds.add(r.id);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
// 1b. Provenanced validators: exported functions whose OWN used_imports
|
|
338
|
-
// includes a validator package. Per-identifier check — a function in
|
|
339
|
-
// a zod-importing file only qualifies if its own body uses the
|
|
340
|
-
// validator package, not just because it cohabits the file.
|
|
341
|
-
if (validatorIds.size === 0) {
|
|
342
|
-
const likeClauses = [...VALIDATOR_PACKAGES].map(() => 'used_imports LIKE ?');
|
|
343
|
-
const likeParams = [...VALIDATOR_PACKAGES].map((pkg) => `%"${pkg}"%`);
|
|
344
|
-
const validatorFuncs = indexHandle
|
|
345
|
-
.query(`SELECT id FROM functions
|
|
346
|
-
WHERE used_imports IS NOT NULL
|
|
347
|
-
AND (${likeClauses.join(' OR ')})
|
|
348
|
-
AND is_exported = 1`, likeParams);
|
|
349
|
-
for (const f of validatorFuncs)
|
|
350
|
-
validatorIds.add(f.id);
|
|
351
|
-
}
|
|
352
|
-
// 1c. Heuristic fallback: name-based matching (only when provenance
|
|
353
|
-
// found nothing AND no user-configured validators exist).
|
|
354
|
-
if (validatorIds.size === 0 && userValidators.length === 0) {
|
|
355
|
-
const heuristicFuncs = indexHandle
|
|
356
|
-
.query(`SELECT id FROM functions
|
|
357
|
-
WHERE (name GLOB 'validate*' OR name GLOB 'assert*')
|
|
358
|
-
AND is_exported = 1`);
|
|
359
|
-
for (const f of heuristicFuncs)
|
|
360
|
-
validatorIds.add(f.id);
|
|
361
|
-
}
|
|
362
|
-
if (validatorIds.size === 0)
|
|
363
|
-
return violations;
|
|
364
|
-
// ── 2. Find all writer functions from schema_usage ─────────────────────
|
|
365
|
-
const fpAliasWhere = filePath
|
|
366
|
-
? `AND su.${filePath.clause.slice(4)}`
|
|
367
|
-
: '';
|
|
368
|
-
const writers = indexHandle
|
|
369
|
-
.query(`SELECT DISTINCT su.function_name, su.file_path, su.line, f.id as function_id
|
|
370
|
-
FROM schema_usage su
|
|
371
|
-
JOIN functions f ON f.name = su.function_name
|
|
372
|
-
AND f.file_path = su.file_path
|
|
373
|
-
WHERE su.usage_type IN ('insert', 'update', 'delete', 'create')
|
|
374
|
-
${fpAliasWhere}
|
|
375
|
-
ORDER BY su.file_path, su.function_name`, (filePath ? [filePath.param] : []));
|
|
376
|
-
if (writers.length === 0)
|
|
377
|
-
return violations;
|
|
378
|
-
const writerCoverage = new Map();
|
|
379
|
-
for (const w of writers) {
|
|
380
|
-
const key = `${w.file_path}::${w.function_name}`;
|
|
381
|
-
if (writerCoverage.has(key))
|
|
382
|
-
continue; // deduplicate
|
|
383
|
-
const covered = this.bfsReachesValidator(indexHandle, w.function_id, validatorIds, depth);
|
|
384
|
-
writerCoverage.set(key, {
|
|
385
|
-
covered,
|
|
386
|
-
line: w.line,
|
|
387
|
-
funcName: w.function_name,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
const dirWriters = new Map();
|
|
391
|
-
const dirSeen = new Set(); // deduplicate writers with multiple schema_usage rows
|
|
392
|
-
for (const w of writers) {
|
|
393
|
-
const dir = path.dirname(w.file_path);
|
|
394
|
-
const key = `${w.file_path}::${w.function_name}`;
|
|
395
|
-
const dirKey = `${dir}::${key}`;
|
|
396
|
-
if (dirSeen.has(dirKey))
|
|
397
|
-
continue;
|
|
398
|
-
dirSeen.add(dirKey);
|
|
399
|
-
const cov = writerCoverage.get(key);
|
|
400
|
-
if (!cov)
|
|
401
|
-
continue;
|
|
402
|
-
if (!dirWriters.has(dir))
|
|
403
|
-
dirWriters.set(dir, []);
|
|
404
|
-
dirWriters.get(dir).push({
|
|
405
|
-
key,
|
|
406
|
-
covered: cov.covered,
|
|
407
|
-
line: cov.line,
|
|
408
|
-
funcName: cov.funcName,
|
|
409
|
-
filePath: w.file_path,
|
|
410
|
-
});
|
|
459
|
+
return writerCoverage;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Build the validator function ID set in priority order: user-configured
|
|
463
|
+
* validators, then provenanced validators (exported functions whose own
|
|
464
|
+
* used_imports includes a validator package), then a name-based heuristic
|
|
465
|
+
* fallback only when both prior sources are silent.
|
|
466
|
+
*/
|
|
467
|
+
function buildValidatorIds(indexHandle, userValidators) {
|
|
468
|
+
const validatorIds = new Set();
|
|
469
|
+
// 1a. User-configured validators (format: "funcName" or "path#funcName")
|
|
470
|
+
for (const v of userValidators) {
|
|
471
|
+
const hashIdx = v.indexOf('#');
|
|
472
|
+
if (hashIdx >= 0) {
|
|
473
|
+
const vPath = v.substring(0, hashIdx);
|
|
474
|
+
const vName = v.substring(hashIdx + 1);
|
|
475
|
+
const rows = indexHandle
|
|
476
|
+
.query('SELECT id FROM functions WHERE name = ? AND file_path = ?', [vName, vPath]);
|
|
477
|
+
for (const r of rows)
|
|
478
|
+
validatorIds.add(r.id);
|
|
411
479
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const ratio = coveredCount / dirWriterList.length;
|
|
418
|
-
if (ratio >= modeShare) {
|
|
419
|
-
for (const w of dirWriterList) {
|
|
420
|
-
if (w.covered)
|
|
421
|
-
continue;
|
|
422
|
-
violations.push({
|
|
423
|
-
file: w.filePath,
|
|
424
|
-
line: w.line,
|
|
425
|
-
column: 0,
|
|
426
|
-
severity: 'suggestion',
|
|
427
|
-
message: `Function '${w.funcName}' is not validated. ` +
|
|
428
|
-
`${coveredCount}/${dirWriterList.length} peer writers in '${dir}' ` +
|
|
429
|
-
`reach a validator but this function does not (BFS depth ≤ ${depth}). ` +
|
|
430
|
-
`Consider adding input validation.`,
|
|
431
|
-
rule: 'cross-domain/validation-bypass',
|
|
432
|
-
analyzer: this.name,
|
|
433
|
-
functionName: w.funcName,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
}
|
|
480
|
+
else {
|
|
481
|
+
const rows = indexHandle
|
|
482
|
+
.query('SELECT id FROM functions WHERE name = ?', [v]);
|
|
483
|
+
for (const r of rows)
|
|
484
|
+
validatorIds.add(r.id);
|
|
437
485
|
}
|
|
438
|
-
return violations;
|
|
439
486
|
}
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
487
|
+
// 1b. Provenanced validators: exported functions whose OWN used_imports
|
|
488
|
+
// includes a validator package. Per-identifier check — a function in
|
|
489
|
+
// a zod-importing file only qualifies if its own body uses the
|
|
490
|
+
// validator package, not just because it cohabits the file.
|
|
491
|
+
if (validatorIds.size === 0) {
|
|
492
|
+
const likeClauses = [...VALIDATOR_PACKAGES].map(() => 'used_imports LIKE ?');
|
|
493
|
+
const likeParams = [...VALIDATOR_PACKAGES].map((pkg) => `%"${pkg}"%`);
|
|
494
|
+
const validatorFuncs = indexHandle
|
|
495
|
+
.query(`SELECT id FROM functions
|
|
496
|
+
WHERE used_imports IS NOT NULL
|
|
497
|
+
AND (${likeClauses.join(' OR ')})
|
|
498
|
+
AND is_exported = 1`, likeParams);
|
|
499
|
+
for (const f of validatorFuncs)
|
|
500
|
+
validatorIds.add(f.id);
|
|
501
|
+
}
|
|
502
|
+
// 1c. Heuristic fallback: name-based matching (only when provenance
|
|
503
|
+
// found nothing AND no user-configured validators exist).
|
|
504
|
+
if (validatorIds.size === 0 && userValidators.length === 0) {
|
|
505
|
+
const heuristicFuncs = indexHandle
|
|
506
|
+
.query(`SELECT id FROM functions
|
|
507
|
+
WHERE (name GLOB 'validate*' OR name GLOB 'assert*')
|
|
508
|
+
AND is_exported = 1`);
|
|
509
|
+
for (const f of heuristicFuncs)
|
|
510
|
+
validatorIds.add(f.id);
|
|
511
|
+
}
|
|
512
|
+
return validatorIds;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Group writers by directory, then flag uncovered writers only in
|
|
516
|
+
* directories where a mode-share of peers reach a validator.
|
|
517
|
+
*/
|
|
518
|
+
function flagUncoveredWriters(writers, writerCoverage, opts) {
|
|
519
|
+
const { minCorpus, modeShare, depth } = opts;
|
|
520
|
+
const dirWriters = groupWritersByDirectory(writers, writerCoverage);
|
|
521
|
+
return flagUnvalidatedWriters(dirWriters, minCorpus, modeShare, depth);
|
|
522
|
+
}
|
|
523
|
+
/** Group writers by directory, deduplicating multi-row schema_usage entries. */
|
|
524
|
+
function groupWritersByDirectory(writers, writerCoverage) {
|
|
525
|
+
const dirWriters = new Map();
|
|
526
|
+
const dirSeen = new Set();
|
|
527
|
+
for (const w of writers) {
|
|
528
|
+
const dir = path.dirname(w.file_path);
|
|
529
|
+
const key = `${w.file_path}::${w.function_name}`;
|
|
530
|
+
const dirKey = `${dir}::${key}`;
|
|
531
|
+
if (dirSeen.has(dirKey))
|
|
532
|
+
continue;
|
|
533
|
+
dirSeen.add(dirKey);
|
|
534
|
+
const cov = writerCoverage.get(key);
|
|
535
|
+
if (!cov)
|
|
536
|
+
continue;
|
|
537
|
+
if (!dirWriters.has(dir))
|
|
538
|
+
dirWriters.set(dir, []);
|
|
539
|
+
dirWriters.get(dir).push({
|
|
540
|
+
key,
|
|
541
|
+
covered: cov.covered,
|
|
542
|
+
line: cov.line,
|
|
543
|
+
funcName: cov.funcName,
|
|
544
|
+
filePath: w.file_path,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return dirWriters;
|
|
548
|
+
}
|
|
549
|
+
/** Flag uncovered writers in validator-dense directories. */
|
|
550
|
+
function flagUnvalidatedWriters(dirWriters, minCorpus, modeShare, depth) {
|
|
551
|
+
const violations = [];
|
|
552
|
+
for (const [dir, dirWriterList] of dirWriters) {
|
|
553
|
+
if (dirWriterList.length < minCorpus)
|
|
554
|
+
continue;
|
|
555
|
+
const coveredCount = dirWriterList.filter((w) => w.covered).length;
|
|
556
|
+
const ratio = coveredCount / dirWriterList.length;
|
|
557
|
+
if (ratio >= modeShare) {
|
|
558
|
+
for (const w of dirWriterList) {
|
|
559
|
+
if (w.covered)
|
|
560
|
+
continue;
|
|
476
561
|
violations.push({
|
|
477
|
-
file:
|
|
478
|
-
line:
|
|
562
|
+
file: w.filePath,
|
|
563
|
+
line: w.line,
|
|
479
564
|
column: 0,
|
|
480
565
|
severity: 'suggestion',
|
|
481
|
-
message: `
|
|
482
|
-
|
|
483
|
-
(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
sourceFormat,
|
|
489
|
-
...(staleWarning ? { staleImport: true } : {}),
|
|
566
|
+
message: `Function '${w.funcName}' is not validated. ` +
|
|
567
|
+
`${coveredCount}/${dirWriterList.length} peer writers in '${dir}' ` +
|
|
568
|
+
`reach a validator but this function does not (BFS depth ≤ ${depth}). ` +
|
|
569
|
+
`Consider adding input validation.`,
|
|
570
|
+
rule: 'cross-domain/validation-bypass',
|
|
571
|
+
analyzer: ANALYZER_NAME,
|
|
572
|
+
functionName: w.funcName,
|
|
490
573
|
});
|
|
491
574
|
}
|
|
492
575
|
}
|
|
493
|
-
else {
|
|
494
|
-
// Static-reach fallback: use the call graph to determine which
|
|
495
|
-
// exported high-risk functions are reachable from test files
|
|
496
|
-
const fpWhere = filePath ? 'AND f.file_path LIKE ?' : '';
|
|
497
|
-
const params = [topRiskDecile];
|
|
498
|
-
if (filePath)
|
|
499
|
-
params.push(filePath.param);
|
|
500
|
-
const highRiskFns = indexHandle.query(`WITH ranked AS (
|
|
501
|
-
SELECT
|
|
502
|
-
f.name,
|
|
503
|
-
f.file_path,
|
|
504
|
-
f.line_number,
|
|
505
|
-
f.id,
|
|
506
|
-
COALESCE(hs.score, 0.0) as risk_score,
|
|
507
|
-
PERCENT_RANK() OVER (ORDER BY COALESCE(hs.score, 0.0) DESC) as pct
|
|
508
|
-
FROM functions f
|
|
509
|
-
LEFT JOIN hotspot_scores hs ON hs.target = (f.file_path || ':' || f.name)
|
|
510
|
-
AND hs.type = 'function'
|
|
511
|
-
WHERE f.is_exported = 1
|
|
512
|
-
${fpWhere}
|
|
513
|
-
)
|
|
514
|
-
SELECT id, name, file_path, line_number, risk_score
|
|
515
|
-
FROM ranked
|
|
516
|
-
WHERE pct <= ?
|
|
517
|
-
ORDER BY risk_score DESC`, params);
|
|
518
|
-
if (highRiskFns.length === 0)
|
|
519
|
-
return violations;
|
|
520
|
-
// Find test-file functions to use as BFS starting points.
|
|
521
|
-
const testGlobs = coverage.testGlobs ?? ['**/*.test.*', '**/*.spec.*', '**/__tests__/**'];
|
|
522
|
-
const testGlobPatterns = testGlobs.map((g) => g.replace(/\*\*/g, '%').replace(/\*/g, '%'));
|
|
523
|
-
let testFileClause = '';
|
|
524
|
-
const testFileParams = [];
|
|
525
|
-
if (testGlobPatterns.length > 0) {
|
|
526
|
-
testFileClause = testGlobPatterns
|
|
527
|
-
.map((_p, i) => `file_path LIKE ?`)
|
|
528
|
-
.join(' OR ');
|
|
529
|
-
for (const p of testGlobPatterns)
|
|
530
|
-
testFileParams.push(p);
|
|
531
|
-
}
|
|
532
|
-
const testFuncIds = new Set();
|
|
533
|
-
if (testFileClause) {
|
|
534
|
-
const testFunctions = indexHandle
|
|
535
|
-
.query(`SELECT id FROM functions WHERE ${testFileClause}`, testFileParams);
|
|
536
|
-
for (const tf of testFunctions)
|
|
537
|
-
testFuncIds.add(tf.id);
|
|
538
|
-
}
|
|
539
|
-
// BFS from each test-file function through call graph (reverse: test
|
|
540
|
-
// calls code under test). We traverse the call edges: if testFunc → X,
|
|
541
|
-
// then X is reachable.
|
|
542
|
-
const reachableIds = new Set();
|
|
543
|
-
const maxDepth = coverage.staticReachDepth ?? 2;
|
|
544
|
-
if (testFuncIds.size > 0) {
|
|
545
|
-
for (const startId of testFuncIds) {
|
|
546
|
-
const visited = new Set();
|
|
547
|
-
let currentLevel = [startId];
|
|
548
|
-
for (let d = 0; d < maxDepth; d++) {
|
|
549
|
-
const nextLevel = [];
|
|
550
|
-
for (const funcId of currentLevel) {
|
|
551
|
-
if (visited.has(funcId))
|
|
552
|
-
continue;
|
|
553
|
-
visited.add(funcId);
|
|
554
|
-
reachableIds.add(funcId);
|
|
555
|
-
const callees = indexHandle
|
|
556
|
-
.query(`SELECT neighbor_key FROM graph_cache
|
|
557
|
-
WHERE graph_type = 'call' AND node_key = ?`, [String(funcId)]);
|
|
558
|
-
for (const callee of callees) {
|
|
559
|
-
const calleeId = parseInt(callee.neighbor_key, 10);
|
|
560
|
-
if (!isNaN(calleeId) && !visited.has(calleeId)) {
|
|
561
|
-
nextLevel.push(calleeId);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
currentLevel = nextLevel;
|
|
566
|
-
}
|
|
567
|
-
// Check last level
|
|
568
|
-
for (const funcId of currentLevel) {
|
|
569
|
-
if (!reachableIds.has(funcId))
|
|
570
|
-
reachableIds.add(funcId);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
// Flag high-risk functions not in the reachable set
|
|
575
|
-
for (const fn of highRiskFns) {
|
|
576
|
-
if (!reachableIds.has(fn.id)) {
|
|
577
|
-
violations.push({
|
|
578
|
-
file: fn.file_path,
|
|
579
|
-
line: fn.line_number,
|
|
580
|
-
column: 0,
|
|
581
|
-
severity: 'suggestion',
|
|
582
|
-
message: `Exported function '${fn.name}' (risk ${fn.risk_score.toFixed(3)}) is not reachable from known test files. ` +
|
|
583
|
-
`Add test coverage or import measured coverage with 'code-audit coverage --import <path>'.`,
|
|
584
|
-
rule: 'cross-domain/uncovered-risk',
|
|
585
|
-
analyzer: this.name,
|
|
586
|
-
functionName: fn.name,
|
|
587
|
-
basis: 'static-reach',
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
return violations;
|
|
593
576
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
577
|
+
return violations;
|
|
578
|
+
}
|
|
579
|
+
// ── R4: Coverage by Importance ──────────────────────────────────────────
|
|
580
|
+
/**
|
|
581
|
+
* Detect exported, high-risk functions with no test coverage.
|
|
582
|
+
* Uses the getUntestedTopDecile() query that joins functions with
|
|
583
|
+
* hotspot_scores and coverage_data to find untested high-risk functions.
|
|
584
|
+
*
|
|
585
|
+
* Static-reach fallback: when no measured coverage exists, falls back
|
|
586
|
+
* to static reach analysis from test files (BFS from test-file functions
|
|
587
|
+
* into the call graph).
|
|
588
|
+
*/
|
|
589
|
+
function detectUncoveredRisk(indexHandle, coverage, filePath) {
|
|
590
|
+
const topRiskDecile = coverage.topRiskDecile ?? 0.1;
|
|
591
|
+
// Check if any measured coverage exists
|
|
592
|
+
const measuredCount = indexHandle
|
|
593
|
+
.query("SELECT COUNT(*) AS cnt FROM coverage_data WHERE basis = 'measured'")[0].cnt;
|
|
594
|
+
if (measuredCount > 0) {
|
|
595
|
+
return detectMeasuredUncovered(indexHandle, topRiskDecile);
|
|
596
|
+
}
|
|
597
|
+
return detectStaticReachUncovered(indexHandle, coverage, filePath);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Flag exported high-risk functions with no measured test coverage, using
|
|
601
|
+
* lcov/istanbul-imported data with stale-import detection.
|
|
602
|
+
*/
|
|
603
|
+
function detectMeasuredUncovered(indexHandle, topRiskDecile) {
|
|
604
|
+
const violations = [];
|
|
605
|
+
const untested = indexHandle.getUntestedTopDecile(topRiskDecile);
|
|
606
|
+
// Determine source format from existing coverage entries
|
|
607
|
+
const sourceRow = indexHandle
|
|
608
|
+
.query("SELECT source, imported_at FROM coverage_data WHERE basis = 'measured' LIMIT 1")[0];
|
|
609
|
+
const sourceFormat = sourceRow?.source ?? 'unknown';
|
|
610
|
+
const importedAt = sourceRow?.imported_at ?? null;
|
|
611
|
+
// Stale-import detection: measured coverage predates last full index sync
|
|
612
|
+
let staleWarning = null;
|
|
613
|
+
if (importedAt) {
|
|
614
|
+
const lastSync = indexHandle.getMeta?.('last_full_sync_timestamp') ?? null;
|
|
615
|
+
if (lastSync && importedAt < lastSync) {
|
|
616
|
+
staleWarning =
|
|
617
|
+
` — WARNING: this coverage data may be stale (imported ${importedAt}, ` +
|
|
618
|
+
`last index sync was ${lastSync}). ` +
|
|
619
|
+
`Re-import with 'code-audit coverage --import <path>' for accurate results.`;
|
|
626
620
|
}
|
|
627
|
-
return false;
|
|
628
621
|
}
|
|
622
|
+
for (const fn of untested) {
|
|
623
|
+
violations.push({
|
|
624
|
+
file: fn.filePath,
|
|
625
|
+
line: fn.lineNumber ?? 1,
|
|
626
|
+
column: 0,
|
|
627
|
+
severity: 'suggestion',
|
|
628
|
+
message: `Exported function '${fn.functionName}' (risk ${fn.riskScore.toFixed(3)}) has no measured test coverage. ` +
|
|
629
|
+
`Top imported functions should have test coverage. Import coverage data with 'code-audit coverage --import <path>'.` +
|
|
630
|
+
(staleWarning ?? ''),
|
|
631
|
+
rule: 'cross-domain/uncovered-risk',
|
|
632
|
+
analyzer: ANALYZER_NAME,
|
|
633
|
+
functionName: fn.functionName,
|
|
634
|
+
basis: fn.basis,
|
|
635
|
+
sourceFormat,
|
|
636
|
+
...(staleWarning ? { staleImport: true } : {}),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return violations;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Static-reach fallback: flag exported high-risk functions that are not
|
|
643
|
+
* reachable from known test files via BFS through the call graph.
|
|
644
|
+
*/
|
|
645
|
+
function detectStaticReachUncovered(indexHandle, coverage, filePath) {
|
|
646
|
+
const topRiskDecile = coverage.topRiskDecile ?? 0.1;
|
|
647
|
+
const highRiskFns = queryHighRiskFunctions(indexHandle, topRiskDecile, filePath);
|
|
648
|
+
if (highRiskFns.length === 0)
|
|
649
|
+
return [];
|
|
650
|
+
const reachableIds = computeTestReachableIds(indexHandle, coverage);
|
|
651
|
+
return flagUnreachedHighRisk(highRiskFns, reachableIds);
|
|
652
|
+
}
|
|
653
|
+
/** Rank exported functions by hotspot score and take the top decile. */
|
|
654
|
+
function queryHighRiskFunctions(indexHandle, topRiskDecile, filePath) {
|
|
655
|
+
const fpWhere = filePath ? 'AND f.file_path LIKE ?' : '';
|
|
656
|
+
const params = [topRiskDecile];
|
|
657
|
+
if (filePath)
|
|
658
|
+
params.push(filePath.param);
|
|
659
|
+
return indexHandle.query(`WITH ranked AS (
|
|
660
|
+
SELECT
|
|
661
|
+
f.name,
|
|
662
|
+
f.file_path,
|
|
663
|
+
f.line_number,
|
|
664
|
+
f.id,
|
|
665
|
+
COALESCE(hs.score, 0.0) as risk_score,
|
|
666
|
+
PERCENT_RANK() OVER (ORDER BY COALESCE(hs.score, 0.0) DESC) as pct
|
|
667
|
+
FROM functions f
|
|
668
|
+
LEFT JOIN hotspot_scores hs ON hs.target = (f.file_path || ':' || f.name)
|
|
669
|
+
AND hs.type = 'function'
|
|
670
|
+
WHERE f.is_exported = 1
|
|
671
|
+
${fpWhere}
|
|
672
|
+
)
|
|
673
|
+
SELECT id, name, file_path, line_number, risk_score
|
|
674
|
+
FROM ranked
|
|
675
|
+
WHERE pct <= ?
|
|
676
|
+
ORDER BY risk_score DESC`, params);
|
|
677
|
+
}
|
|
678
|
+
/** Flag high-risk functions not in the reachable set. */
|
|
679
|
+
function flagUnreachedHighRisk(highRiskFns, reachableIds) {
|
|
680
|
+
const violations = [];
|
|
681
|
+
for (const fn of highRiskFns) {
|
|
682
|
+
if (reachableIds.has(fn.id))
|
|
683
|
+
continue;
|
|
684
|
+
violations.push({
|
|
685
|
+
file: fn.file_path,
|
|
686
|
+
line: fn.line_number,
|
|
687
|
+
column: 0,
|
|
688
|
+
severity: 'suggestion',
|
|
689
|
+
message: `Exported function '${fn.name}' (risk ${fn.risk_score.toFixed(3)}) is not reachable from known test files. ` +
|
|
690
|
+
`Add test coverage or import measured coverage with 'code-audit coverage --import <path>'.`,
|
|
691
|
+
rule: 'cross-domain/uncovered-risk',
|
|
692
|
+
analyzer: ANALYZER_NAME,
|
|
693
|
+
functionName: fn.name,
|
|
694
|
+
basis: 'static-reach',
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
return violations;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Compute the set of function IDs reachable from test-file functions via
|
|
701
|
+
* BFS through the call graph (reverse direction: test → code under test).
|
|
702
|
+
*/
|
|
703
|
+
function computeTestReachableIds(indexHandle, coverage) {
|
|
704
|
+
const testFuncIds = collectTestFuncIds(indexHandle, coverage);
|
|
705
|
+
if (testFuncIds.size === 0)
|
|
706
|
+
return new Set();
|
|
707
|
+
const maxDepth = coverage.staticReachDepth ?? 2;
|
|
708
|
+
return collectReachableIds(indexHandle, testFuncIds, maxDepth);
|
|
709
|
+
}
|
|
710
|
+
/** Find test-file function IDs to use as BFS starting points. */
|
|
711
|
+
function collectTestFuncIds(indexHandle, coverage) {
|
|
712
|
+
const testGlobs = coverage.testGlobs ?? ['**/*.test.*', '**/*.spec.*', '**/__tests__/**'];
|
|
713
|
+
const testGlobPatterns = testGlobs.map((g) => g.replace(/\*\*/g, '%').replace(/\*/g, '%'));
|
|
714
|
+
if (testGlobPatterns.length === 0)
|
|
715
|
+
return new Set();
|
|
716
|
+
const testFileClause = testGlobPatterns
|
|
717
|
+
.map(() => `file_path LIKE ?`)
|
|
718
|
+
.join(' OR ');
|
|
719
|
+
const testFileParams = [...testGlobPatterns];
|
|
720
|
+
const testFuncIds = new Set();
|
|
721
|
+
const testFunctions = indexHandle
|
|
722
|
+
.query(`SELECT id FROM functions WHERE ${testFileClause}`, testFileParams);
|
|
723
|
+
for (const tf of testFunctions)
|
|
724
|
+
testFuncIds.add(tf.id);
|
|
725
|
+
return testFuncIds;
|
|
629
726
|
}
|
|
630
727
|
//# sourceMappingURL=CrossDomainAnalyzer.js.map
|