code-auditor-mcp 3.4.12 → 3.4.13
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 +46 -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 +15 -5
- 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 +0 -2
- 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 +79 -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.js +10 -1
- 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/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/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
|
@@ -8,173 +8,49 @@
|
|
|
8
8
|
* R2.5: Legacy scan-all-strings path DELETED.
|
|
9
9
|
* R7: schema/unknown-table severity is "suggestion".
|
|
10
10
|
*/
|
|
11
|
-
import
|
|
12
|
-
import path from 'path';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
13
12
|
import { UniversalAnalyzer } from '../../languages/UniversalAnalyzer.js';
|
|
14
|
-
import
|
|
15
|
-
import { buildProvenanceContext,
|
|
16
|
-
import { OrmAdapterRegistry } from '../orm/index.js';
|
|
13
|
+
import { withRuleTiming } from '../ruleTiming.js';
|
|
14
|
+
import { buildProvenanceContext, } from '../provenance.js';
|
|
17
15
|
import { makeVisitorStatus } from '../../pipeline.js';
|
|
16
|
+
// Spec 34 — schema analyzer split (Step 0 reconciliation): shared types,
|
|
17
|
+
// SQL-context constants, and migration/DDL helpers now live in schema/
|
|
18
|
+
// submodules. Imported here for the class + standalone JSON-schema functions,
|
|
19
|
+
// then re-exported to preserve this file's public surface (pipelineAdapters.ts
|
|
20
|
+
// and UniversalDataAccessAnalyzer.ts import from this module).
|
|
21
|
+
import { parseMigrationOps, extractReExports, sqlFileHasDdl, extractMigrationOpsFromFile, } from './schema/migrations.js';
|
|
22
|
+
import { DB_RECEIVER_NAMES, DB_CALL_METHOD_NAMES, DB_BINDING_NAMES, DB_WRAPPER_NAMES, SQL_TAG_NAMES, DEFAULT_SCHEMA_CONFIG, } from './schema/config.js';
|
|
23
|
+
import { findTableReferences, checkMissingReferences, checkNamingConventions, checkQueryPatterns, checkSQLInjection, findClosestNodeAt, findEnclosingFunctionName, } from './schema/codeAnalysis.js';
|
|
24
|
+
import { discoverTablesFromMigrations, discoverTablesFromWrangler, discoverTablesFromSchemaFiles, discoverTablesFromOrmSchemas, passesFileGate, } from './schema/discovery.js';
|
|
25
|
+
export { parseMigrationOps, extractReExports, sqlFileHasDdl, extractMigrationOpsFromFile, DB_RECEIVER_NAMES, DB_CALL_METHOD_NAMES, DB_BINDING_NAMES, DB_WRAPPER_NAMES, SQL_TAG_NAMES, DEFAULT_SCHEMA_CONFIG, };
|
|
18
26
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* These are the ground-truth defaults. DEFAULT_SCHEMA_CONFIG references them,
|
|
22
|
-
* and every inline fallback dereferences them directly — so ?? narrowing works
|
|
23
|
-
* (TypeScript infers `string[]`, not `string[] | undefined` from the optional
|
|
24
|
-
* SchemaAnalyzerConfig fields).
|
|
25
|
-
*
|
|
26
|
-
* Trimmed to D1/Workers DB patterns only (4 receivers, 6 methods).
|
|
27
|
-
* Broader entries like 'connection'/'client'/'query'/'get'/'each' matched
|
|
28
|
-
* non-DB code (WebSocket, Map, jQuery, vector stores), causing phantom
|
|
29
|
-
* cross-domain lifecycle violations. See CHANGELOG 3.4.9 accuracy fix.
|
|
27
|
+
* Universal schema analyzer.
|
|
30
28
|
*/
|
|
31
|
-
export const DB_RECEIVER_NAMES = ['db', 'database', 'sql', 'stmt'];
|
|
32
|
-
export const DB_CALL_METHOD_NAMES = ['exec', 'prepare', 'batch', 'run', 'all', 'first'];
|
|
33
|
-
export const DB_BINDING_NAMES = ['env.DB'];
|
|
34
|
-
export const DB_WRAPPER_NAMES = ['d1Query', 'd1Exec'];
|
|
35
|
-
export const SQL_TAG_NAMES = ['sql', 'db'];
|
|
36
|
-
export const DEFAULT_SCHEMA_CONFIG = {
|
|
37
|
-
enableTableUsageTracking: true,
|
|
38
|
-
checkMissingReferences: true,
|
|
39
|
-
checkNamingConventions: true,
|
|
40
|
-
detectUnusedTables: false,
|
|
41
|
-
validateQueryPatterns: true,
|
|
42
|
-
maxQueriesPerFunction: 5,
|
|
43
|
-
requiredSchemas: [],
|
|
44
|
-
schemas: [],
|
|
45
|
-
validateJsonSchemas: true,
|
|
46
|
-
jsonSchemaVersion: 'draft-07',
|
|
47
|
-
allowedJsonTypes: ['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'],
|
|
48
|
-
schemaFilePatterns: ['*.schema.json', '*-schema.json'],
|
|
49
|
-
dataFilePatterns: ['*.data.json', '*.example.json'],
|
|
50
|
-
strictMode: false,
|
|
51
|
-
allowAdditionalProperties: true,
|
|
52
|
-
// Spec-17 R2 defaults
|
|
53
|
-
sqlTagNames: [...SQL_TAG_NAMES],
|
|
54
|
-
dbReceiverNames: [...DB_RECEIVER_NAMES],
|
|
55
|
-
dbCallMethods: [...DB_CALL_METHOD_NAMES],
|
|
56
|
-
dbBindingNames: [...DB_BINDING_NAMES],
|
|
57
|
-
dbWrapperNames: [...DB_WRAPPER_NAMES],
|
|
58
|
-
fileGateGlobs: ['**/*.sql', '**/migrations/**'],
|
|
59
|
-
schemaFiles: [],
|
|
60
|
-
};
|
|
61
29
|
export class UniversalSchemaAnalyzer extends UniversalAnalyzer {
|
|
62
30
|
name = 'schema';
|
|
63
31
|
description = 'Analyzes code against database schemas and validates JSON schemas';
|
|
64
32
|
category = 'database';
|
|
65
|
-
// Track references across files
|
|
66
|
-
tableReferences = new Map();
|
|
67
33
|
// Spec 25 B4 — Queue schema records for the pipeline to write after stage 2.
|
|
68
34
|
// Was: direct CodeIndexDB.getInstance() call in recordTableUsage.
|
|
69
35
|
_pendingSchemaRecords = { clearFiles: [], usages: [] };
|
|
70
|
-
projectRoot;
|
|
71
|
-
/**
|
|
72
|
-
* Strip SQL identifier delimiters: backticks or double-quotes.
|
|
73
|
-
*/
|
|
74
|
-
stripIdentifier(name) {
|
|
75
|
-
if ((name.startsWith('`') && name.endsWith('`')) ||
|
|
76
|
-
(name.startsWith('"') && name.endsWith('"'))) {
|
|
77
|
-
return name.slice(1, -1);
|
|
78
|
-
}
|
|
79
|
-
return name;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Parse a migration SQL source and apply stateful CREATE/DROP/RENAME
|
|
83
|
-
* operations to the given table set in migration order.
|
|
84
|
-
*/
|
|
85
|
-
processMigrationSource(source, tables) {
|
|
86
|
-
// Sequential state machine: apply CREATE/DROP/RENAME in statement order
|
|
87
|
-
// within each migration file. Fixes the rename-replay bug where CREATE
|
|
88
|
-
// after RENAME in the same file was silently deleted by the old three-pass
|
|
89
|
-
// approach (all CREATE then all DROP then all RENAME).
|
|
90
|
-
const ddlRe = /(CREATE)\s+(?:VIRTUAL\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(`[^`]+`|"[^"]+"|\w+)|(DROP)\s+TABLE\s+(?:IF\s+EXISTS\s+)?(`[^`]+`|"[^"]+"|\w+)|(ALTER)\s+TABLE\s+(`[^`]+`|"[^"]+"|\w+)\s+RENAME\s+TO\s+(`[^`]+`|"[^"]+"|\w+)/gi;
|
|
91
|
-
let match;
|
|
92
|
-
while ((match = ddlRe.exec(source)) !== null) {
|
|
93
|
-
const op = match[1] || match[3] || match[5];
|
|
94
|
-
if (op === 'CREATE') {
|
|
95
|
-
tables.add(this.stripIdentifier(match[2]));
|
|
96
|
-
}
|
|
97
|
-
else if (op === 'DROP') {
|
|
98
|
-
tables.delete(this.stripIdentifier(match[4]));
|
|
99
|
-
}
|
|
100
|
-
else if (op === 'ALTER') {
|
|
101
|
-
tables.delete(this.stripIdentifier(match[6]));
|
|
102
|
-
tables.add(this.stripIdentifier(match[7]));
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
36
|
/**
|
|
107
37
|
* Standalone analyze() override for backward compatibility with direct analyzer
|
|
108
38
|
* calls (e.g., tests and non-pipeline audit paths). All production analysis now
|
|
109
39
|
* flows through the pipeline visitors, but this method is preserved so tests
|
|
110
40
|
* that call analyzer.analyze([file], config) continue to work.
|
|
41
|
+
* @param config
|
|
42
|
+
* @param files
|
|
43
|
+
* @returns
|
|
111
44
|
*/
|
|
112
45
|
async analyze(files, config) {
|
|
113
46
|
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
114
47
|
const codeFiles = files.filter(f => !f.endsWith('.json'));
|
|
115
48
|
// Auto-discover known tables when no schemas are configured.
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const schemaFiles = config.schemaFiles;
|
|
122
|
-
const fromSchemaFiles = schemaFiles && schemaFiles.length > 0
|
|
123
|
-
? await this._discoverTablesFromSchemaFiles(schemaFiles, projectRoot)
|
|
124
|
-
: new Set();
|
|
125
|
-
const fromMigrations = await this._discoverTablesFromMigrations(projectRoot, config);
|
|
126
|
-
const fromOrm = await this._discoverTablesFromOrmSchemas(codeFiles);
|
|
127
|
-
const discovered = new Set([
|
|
128
|
-
...fromWrangler,
|
|
129
|
-
...fromSchemaFiles,
|
|
130
|
-
...fromMigrations,
|
|
131
|
-
...fromOrm,
|
|
132
|
-
]);
|
|
133
|
-
if (discovered.size > 0) {
|
|
134
|
-
config = {
|
|
135
|
-
...config,
|
|
136
|
-
schemas: [{
|
|
137
|
-
name: 'auto-discovered',
|
|
138
|
-
tables: [...discovered].map(name => ({ name, columns: [] })),
|
|
139
|
-
}],
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const codeResult = codeFiles.length > 0 ? await super.analyze(codeFiles, config) : {
|
|
144
|
-
violations: [],
|
|
145
|
-
executionTime: 0,
|
|
146
|
-
status: makeVisitorStatus(0),
|
|
147
|
-
analyzerName: this.name,
|
|
148
|
-
errors: [],
|
|
149
|
-
filesProcessed: 0,
|
|
150
|
-
};
|
|
151
|
-
// Adapt JSON handling to the pipeline-style analyzeJsonSchemas(Map) signature.
|
|
152
|
-
let jsonResult = {
|
|
153
|
-
violations: [],
|
|
154
|
-
executionTime: 0,
|
|
155
|
-
status: makeVisitorStatus(0),
|
|
156
|
-
analyzerName: this.name,
|
|
157
|
-
errors: [],
|
|
158
|
-
filesProcessed: 0,
|
|
159
|
-
};
|
|
160
|
-
if (jsonFiles.length > 0) {
|
|
161
|
-
const jsonContents = new Map();
|
|
162
|
-
for (const file of jsonFiles) {
|
|
163
|
-
try {
|
|
164
|
-
const raw = await fs.readFile(file, 'utf8');
|
|
165
|
-
let parsed = null;
|
|
166
|
-
try {
|
|
167
|
-
parsed = JSON.parse(raw);
|
|
168
|
-
}
|
|
169
|
-
catch { /* not valid JSON */ }
|
|
170
|
-
jsonContents.set(file, { parsed, raw });
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
// Skip unreadable files
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
jsonResult = this.analyzeJsonSchemas(jsonContents, config);
|
|
177
|
-
}
|
|
49
|
+
config = await resolveSchemasViaAutoDiscovery(config, codeFiles);
|
|
50
|
+
const codeResult = codeFiles.length > 0
|
|
51
|
+
? await super.analyze(codeFiles, config)
|
|
52
|
+
: emptySchemaResult(this.name);
|
|
53
|
+
const jsonResult = analyzeJsonFiles(jsonFiles, config, this.name);
|
|
178
54
|
return {
|
|
179
55
|
violations: [...codeResult.violations, ...jsonResult.violations],
|
|
180
56
|
executionTime: (codeResult.executionTime || 0) + (jsonResult.executionTime || 0),
|
|
@@ -190,396 +66,32 @@ export class UniversalSchemaAnalyzer extends UniversalAnalyzer {
|
|
|
190
66
|
async analyzeAST(ast, adapter, config, sourceCode) {
|
|
191
67
|
const violations = [];
|
|
192
68
|
const finalConfig = { ...DEFAULT_SCHEMA_CONFIG, ...config };
|
|
193
|
-
|
|
194
|
-
const detectionMode = config.detection?.mode ?? 'hybrid';
|
|
195
|
-
const provenanceContext = buildProvenanceContext(ast, adapter, sourceCode, {
|
|
196
|
-
mode: detectionMode,
|
|
197
|
-
dbReceiverNames: finalConfig.dbReceiverNames ?? DEFAULT_SCHEMA_CONFIG.dbReceiverNames,
|
|
198
|
-
dbBindingNames: finalConfig.dbBindingNames ?? DEFAULT_SCHEMA_CONFIG.dbBindingNames,
|
|
199
|
-
dbCallMethods: finalConfig.dbCallMethods ?? DEFAULT_SCHEMA_CONFIG.dbCallMethods,
|
|
200
|
-
dbWrapperNames: finalConfig.dbWrapperNames ?? DEFAULT_SCHEMA_CONFIG.dbWrapperNames,
|
|
201
|
-
});
|
|
69
|
+
const provenanceContext = buildSchemaProvenanceContext(ast, adapter, sourceCode, finalConfig);
|
|
202
70
|
// R2.2 — File gate: only analyze files with DB context (Spec 21: provenance-based)
|
|
203
|
-
if (!
|
|
71
|
+
if (!passesFileGate(ast.filePath, sourceCode, finalConfig, provenanceContext)) {
|
|
204
72
|
return violations;
|
|
205
73
|
}
|
|
206
|
-
// Get available schemas
|
|
207
74
|
const schemas = finalConfig.schemas || [];
|
|
208
|
-
const allTables =
|
|
209
|
-
for (const schema of schemas) {
|
|
210
|
-
for (const table of schema.tables) {
|
|
211
|
-
allTables.add(table.name);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
75
|
+
const allTables = collectAllTableNames(schemas);
|
|
214
76
|
if (finalConfig.requiredSchemas && finalConfig.requiredSchemas.length > 0 && schemas.length === 0) {
|
|
215
|
-
violations.push(this.createViolation(ast.filePath, { line: 1, column: 1 }, 'No database schemas loaded for analysis', 'warning', 'missing-schemas',
|
|
77
|
+
violations.push(this.createViolation(ast.filePath, { line: 1, column: 1 }, 'No database schemas loaded for analysis', { severity: 'warning', rule: 'missing-schemas', symbol: 'top-level:missing-schemas' }));
|
|
216
78
|
return violations;
|
|
217
79
|
}
|
|
218
80
|
// R2.1 — AST-based table reference extraction (replaces legacy regex scan-all-strings)
|
|
219
|
-
|
|
220
|
-
const tableRefs = this.findTableReferences(ast, adapter, sourceCode, finalConfig, provenanceContext, allTables);
|
|
81
|
+
const tableRefs = findTableReferences(ast, adapter, sourceCode, { config: finalConfig, provenanceContext, allTables });
|
|
221
82
|
// Spec 15 R1 — Record schema usage for cross-domain lifecycle analysis.
|
|
222
|
-
// Idempotent per-file: clear stale entries before inserting fresh references.
|
|
223
83
|
if (finalConfig.enableTableUsageTracking) {
|
|
224
84
|
this.recordTableUsage(ast, adapter, ast.filePath, tableRefs);
|
|
225
85
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const displayRatio = knownCount === 0 ? '∞' : (unknownCount / Math.max(knownCount, 1)).toFixed(1);
|
|
234
|
-
// Silently skip — in direct test mode we don't emit console warnings
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
for (const ref of unknownRefs) {
|
|
238
|
-
const suggestions = this.getNearestTableSuggestions(ref.table, allTables, 2);
|
|
239
|
-
const msg = suggestions.length > 0
|
|
240
|
-
? `Reference to unknown table '${ref.table}' (${ref.type}). Did you mean: ${suggestions.join(', ')}?`
|
|
241
|
-
: `Reference to unknown table '${ref.table}' (${ref.type})`;
|
|
242
|
-
violations.push(this.createViolation(ast.filePath, ref.location, msg, 'suggestion', 'unknown-table', undefined, ref.table));
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
// Check naming conventions
|
|
247
|
-
if (finalConfig.checkNamingConventions) {
|
|
248
|
-
violations.push(...this.checkNamingConventions(tableRefs, ast.filePath));
|
|
249
|
-
}
|
|
250
|
-
// Check query patterns
|
|
251
|
-
if (finalConfig.validateQueryPatterns) {
|
|
252
|
-
violations.push(...this.checkQueryPatterns(ast, adapter, sourceCode, finalConfig));
|
|
253
|
-
}
|
|
254
|
-
// Check for SQL injection patterns
|
|
255
|
-
violations.push(...this.checkSQLInjection(ast, adapter, sourceCode));
|
|
256
|
-
return violations;
|
|
257
|
-
}
|
|
258
|
-
// ---------------------------------------------------------------------------
|
|
259
|
-
// Auto-discovery helpers — used by the standalone analyze() override and
|
|
260
|
-
// the pre-pipeline discovery phase in auditRunner.
|
|
261
|
-
// ---------------------------------------------------------------------------
|
|
262
|
-
/**
|
|
263
|
-
* Walk project root recursively, returning files matching any of the given
|
|
264
|
-
* picomatch globs. Skips node_modules and dot-directories.
|
|
265
|
-
*/
|
|
266
|
-
async _walkFiles(root, globs) {
|
|
267
|
-
const results = [];
|
|
268
|
-
async function walk(dir) {
|
|
269
|
-
let names;
|
|
270
|
-
try {
|
|
271
|
-
names = await fs.readdir(dir);
|
|
272
|
-
}
|
|
273
|
-
catch {
|
|
274
|
-
return; // Skip unreadable directories
|
|
275
|
-
}
|
|
276
|
-
for (const name of names) {
|
|
277
|
-
const fullPath = path.join(dir, name);
|
|
278
|
-
// Skip node_modules and dot-directories
|
|
279
|
-
if (name === 'node_modules' || name.startsWith('.'))
|
|
280
|
-
continue;
|
|
281
|
-
let stat;
|
|
282
|
-
try {
|
|
283
|
-
stat = await fs.stat(fullPath);
|
|
284
|
-
}
|
|
285
|
-
catch {
|
|
286
|
-
continue; // Skip unstatable
|
|
287
|
-
}
|
|
288
|
-
if (stat.isDirectory()) {
|
|
289
|
-
await walk(fullPath);
|
|
290
|
-
}
|
|
291
|
-
else if (stat.isFile()) {
|
|
292
|
-
const relative = path.relative(root, fullPath);
|
|
293
|
-
const matched = globs.some(g => picomatch.isMatch(relative, g));
|
|
294
|
-
if (matched) {
|
|
295
|
-
results.push(fullPath);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
await walk(root);
|
|
301
|
-
return results;
|
|
302
|
-
}
|
|
303
|
-
async _discoverTablesFromMigrations(projectRoot, config) {
|
|
304
|
-
const tables = new Set();
|
|
305
|
-
const gateGlobs = config.fileGateGlobs ?? ['**/*.sql', '**/migrations/**'];
|
|
306
|
-
const walkedFiles = await this._walkFiles(projectRoot, gateGlobs);
|
|
307
|
-
walkedFiles.sort();
|
|
308
|
-
for (const file of walkedFiles) {
|
|
309
|
-
try {
|
|
310
|
-
const source = await fs.readFile(file, 'utf8');
|
|
311
|
-
this.processMigrationSource(source, tables);
|
|
312
|
-
}
|
|
313
|
-
catch {
|
|
314
|
-
// Skip unreadable files
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
return tables;
|
|
318
|
-
}
|
|
319
|
-
async _discoverTablesFromWrangler(projectRoot) {
|
|
320
|
-
const tables = new Set();
|
|
321
|
-
const wranglerPath = path.join(projectRoot, 'wrangler.toml');
|
|
322
|
-
let wranglerContent;
|
|
323
|
-
try {
|
|
324
|
-
wranglerContent = await fs.readFile(wranglerPath, 'utf8');
|
|
325
|
-
}
|
|
326
|
-
catch {
|
|
327
|
-
return tables; // No wrangler.toml
|
|
328
|
-
}
|
|
329
|
-
const migrationDirs = [];
|
|
330
|
-
let inD1Block = false;
|
|
331
|
-
for (const line of wranglerContent.split('\n')) {
|
|
332
|
-
const trimmed = line.trim();
|
|
333
|
-
if (/^\[\[d1_databases\]\]/i.test(trimmed)) {
|
|
334
|
-
inD1Block = true;
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
if (inD1Block && trimmed.startsWith('[')) {
|
|
338
|
-
inD1Block = false;
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
if (inD1Block) {
|
|
342
|
-
const m = trimmed.match(/^migrations_dir\s*=\s*['"](.+?)['"]/);
|
|
343
|
-
if (m) {
|
|
344
|
-
migrationDirs.push(m[1]);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
for (const migDir of migrationDirs) {
|
|
349
|
-
const absDir = path.resolve(projectRoot, migDir);
|
|
350
|
-
let entries;
|
|
351
|
-
try {
|
|
352
|
-
const dirents = await fs.readdir(absDir, { withFileTypes: true });
|
|
353
|
-
entries = dirents
|
|
354
|
-
.filter(e => e.isFile() && e.name.endsWith('.sql'))
|
|
355
|
-
.map(e => e.name)
|
|
356
|
-
.sort();
|
|
357
|
-
}
|
|
358
|
-
catch {
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
for (const entry of entries) {
|
|
362
|
-
const filePath = path.join(absDir, entry);
|
|
363
|
-
try {
|
|
364
|
-
const source = await fs.readFile(filePath, 'utf8');
|
|
365
|
-
this.processMigrationSource(source, tables);
|
|
366
|
-
}
|
|
367
|
-
catch {
|
|
368
|
-
// Skip unreadable files
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
return tables;
|
|
373
|
-
}
|
|
374
|
-
async _discoverTablesFromSchemaFiles(schemaFiles, projectRoot) {
|
|
375
|
-
const tables = new Set();
|
|
376
|
-
for (const file of schemaFiles) {
|
|
377
|
-
const absPath = path.resolve(projectRoot, file);
|
|
378
|
-
try {
|
|
379
|
-
const source = await fs.readFile(absPath, 'utf8');
|
|
380
|
-
const createRe = /CREATE\s+(?:VIRTUAL\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(`[^`]+`|"[^"]+"|\w+)/gi;
|
|
381
|
-
let match;
|
|
382
|
-
while ((match = createRe.exec(source)) !== null) {
|
|
383
|
-
tables.add(this.stripIdentifier(match[1]));
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
// Skip unreadable files
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
return tables;
|
|
391
|
-
}
|
|
392
|
-
async _discoverTablesFromOrmSchemas(files) {
|
|
393
|
-
const tables = new Set();
|
|
394
|
-
for (const file of files) {
|
|
395
|
-
const lowerFile = file.toLowerCase();
|
|
396
|
-
if (/\.(ts|tsx|js|jsx)$/i.test(file)) {
|
|
397
|
-
try {
|
|
398
|
-
const source = await fs.readFile(file, 'utf8');
|
|
399
|
-
if (/from\s+['"]drizzle-orm/.test(source)) {
|
|
400
|
-
const builderRegex = /(?:pgTable|mysqlTable|sqliteTable)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
401
|
-
let match;
|
|
402
|
-
while ((match = builderRegex.exec(source)) !== null) {
|
|
403
|
-
tables.add(match[1]);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
catch {
|
|
408
|
-
// Skip unreadable files
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
if (file.endsWith('schema.prisma') || file.endsWith('\\schema.prisma')) {
|
|
412
|
-
try {
|
|
413
|
-
const source = await fs.readFile(file, 'utf8');
|
|
414
|
-
const modelRegex = /model\s+(\w+)\s*\{/g;
|
|
415
|
-
let match;
|
|
416
|
-
while ((match = modelRegex.exec(source)) !== null) {
|
|
417
|
-
tables.add(match[1]);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
catch {
|
|
421
|
-
// Skip unreadable files
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
return tables;
|
|
426
|
-
}
|
|
427
|
-
// ---------------------------------------------------------------------------
|
|
428
|
-
// R2.2 — File gate
|
|
429
|
-
// ---------------------------------------------------------------------------
|
|
430
|
-
/**
|
|
431
|
-
* Pre-filter: only analyze files that show DB usage.
|
|
432
|
-
* Checks: .sql/migration glob, D1/SQL imports, env-binding patterns, DB calls.
|
|
433
|
-
*/
|
|
434
|
-
passesFileGate(filePath, sourceCode, config, provenanceContext) {
|
|
435
|
-
// Always pass .sql files and migration directories
|
|
436
|
-
const gateGlobs = config.fileGateGlobs ?? ['**/*.sql', '**/migrations/**'];
|
|
437
|
-
for (const glob of gateGlobs) {
|
|
438
|
-
if (picomatch.isMatch(filePath, glob)) {
|
|
439
|
-
return true;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
// Spec 21: Provenance-first DB detection — if any identifier is DB-provenanced,
|
|
443
|
-
// this file passes the gate. This replaces the regex patterns for import/environment/
|
|
444
|
-
// receiver.method checks in hybrid and provenance modes.
|
|
445
|
-
if (provenanceContext && provenanceContext.mode !== 'names') {
|
|
446
|
-
if (provenanceContext.dbProvenanced.size > 0) {
|
|
447
|
-
return true;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
// Legacy name-based detection — used in 'names' mode or when no provenance context
|
|
451
|
-
if (!provenanceContext || provenanceContext.mode === 'names') {
|
|
452
|
-
// Check for D1 or SQL API imports
|
|
453
|
-
const importPatterns = [
|
|
454
|
-
/import\s+.*\b(D1Database|D1PreparedStatement|D1Result)\b/,
|
|
455
|
-
/import\s+.*from\s+['"].*d1['"]/,
|
|
456
|
-
/import\s+.*from\s+['"].*pg['"]/,
|
|
457
|
-
/import\s+.*from\s+['"].*mysql['"]/,
|
|
458
|
-
/import\s+.*from\s+['"].*sqlite['"]/,
|
|
459
|
-
/import\s+.*from\s+['"].*knex['"]/,
|
|
460
|
-
/import\s+.*from\s+['"].*drizzle['"]/,
|
|
461
|
-
/import\s+.*from\s+['"].*prisma['"]/,
|
|
462
|
-
];
|
|
463
|
-
for (const pat of importPatterns) {
|
|
464
|
-
if (pat.test(sourceCode))
|
|
465
|
-
return true;
|
|
466
|
-
}
|
|
467
|
-
// Check for env-binding patterns (e.g., env.DB in Cloudflare Workers)
|
|
468
|
-
const bindingNames = config.dbBindingNames ?? [...DB_BINDING_NAMES];
|
|
469
|
-
for (const binding of bindingNames) {
|
|
470
|
-
if (sourceCode.includes(binding))
|
|
471
|
-
return true;
|
|
472
|
-
}
|
|
473
|
-
// Check for DB call patterns (receiver.method)
|
|
474
|
-
const receivers = config.dbReceiverNames ?? [...DB_RECEIVER_NAMES];
|
|
475
|
-
const methods = config.dbCallMethods ?? [...DB_CALL_METHOD_NAMES];
|
|
476
|
-
for (const receiver of receivers) {
|
|
477
|
-
for (const method of methods) {
|
|
478
|
-
const pattern = new RegExp(`\\b${escapeRegex(receiver)}\\.${escapeRegex(method)}\\s*\\(`);
|
|
479
|
-
if (pattern.test(sourceCode))
|
|
480
|
-
return true;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
// Check for SQL tagged template literals (syntax feature, not naming convention)
|
|
485
|
-
const sqlTags = config.sqlTagNames ?? [...SQL_TAG_NAMES];
|
|
486
|
-
for (const tag of sqlTags) {
|
|
487
|
-
const pattern = new RegExp(`\\b${escapeRegex(tag)}\`\\s*SELECT|\\b${escapeRegex(tag)}\`\\s*INSERT|\\b${escapeRegex(tag)}\`\\s*UPDATE|\\b${escapeRegex(tag)}\`\\s*DELETE|\\b${escapeRegex(tag)}\`\\s*CREATE`, 'i');
|
|
488
|
-
if (pattern.test(sourceCode))
|
|
489
|
-
return true;
|
|
490
|
-
}
|
|
491
|
-
return false;
|
|
492
|
-
}
|
|
493
|
-
// ---------------------------------------------------------------------------
|
|
494
|
-
// R2.1 — AST-based table reference extraction (replaces legacy regex)
|
|
495
|
-
// ---------------------------------------------------------------------------
|
|
496
|
-
/**
|
|
497
|
-
* Extract table references exclusively from SQL contexts in the AST.
|
|
498
|
-
* R2.1: Only tagged template SQL and DB-call patterns produce candidates.
|
|
499
|
-
* R2.3: Template expressions (${var}) resolved to wildcards.
|
|
500
|
-
*/
|
|
501
|
-
findTableReferences(ast, adapter, sourceCode, config, provenanceContext, allTables) {
|
|
502
|
-
const references = [];
|
|
503
|
-
const sqlTags = config.sqlTagNames ?? [...SQL_TAG_NAMES];
|
|
504
|
-
// (1) Tagged template SQL — e.g. sql`SELECT * FROM heroes`
|
|
505
|
-
// This is a syntax feature, not a naming convention — keep the sqlTagNames gate.
|
|
506
|
-
const taggedTemplates = adapter.findNodes(ast, {
|
|
507
|
-
custom: (node) => {
|
|
508
|
-
if (node.type !== 'call_expression')
|
|
509
|
-
return false;
|
|
510
|
-
// Callee must be an identifier matching sqlTagNames
|
|
511
|
-
const callee = this.getCallee(node, adapter, sourceCode);
|
|
512
|
-
if (!callee || !sqlTags.includes(callee))
|
|
513
|
-
return false;
|
|
514
|
-
// Must have a template string argument
|
|
515
|
-
return this.hasTemplateArgument(node, adapter);
|
|
516
|
-
},
|
|
86
|
+
appendSchemaViolations(violations, {
|
|
87
|
+
ast,
|
|
88
|
+
adapter,
|
|
89
|
+
sourceCode,
|
|
90
|
+
config: finalConfig,
|
|
91
|
+
tableRefs,
|
|
92
|
+
allTables,
|
|
517
93
|
});
|
|
518
|
-
|
|
519
|
-
const templateText = this.getTemplateText(callNode, adapter, sourceCode);
|
|
520
|
-
if (!templateText)
|
|
521
|
-
continue;
|
|
522
|
-
const location = this.getCallLocation(callNode);
|
|
523
|
-
const tableRefs = this.parseSqlTables(templateText, location, sourceCode, allTables);
|
|
524
|
-
references.push(...tableRefs);
|
|
525
|
-
}
|
|
526
|
-
// (2) DB-call patterns — e.g. db.exec("SELECT * FROM heroes")
|
|
527
|
-
// Spec 21: Replace name-based isDbMemberCall with provenance-based isDBProvenanced.
|
|
528
|
-
const dbCalls = adapter.findNodes(ast, {
|
|
529
|
-
custom: (node) => {
|
|
530
|
-
if (node.type !== 'call_expression')
|
|
531
|
-
return false;
|
|
532
|
-
// Spec 21: Use provenance when available, fall back to name-based check
|
|
533
|
-
if (provenanceContext && provenanceContext.mode !== 'names') {
|
|
534
|
-
return isDBProvenanced(node, adapter, sourceCode, provenanceContext, DB_CALL_METHODS);
|
|
535
|
-
}
|
|
536
|
-
// Legacy name-based check for names mode / no context
|
|
537
|
-
const callee = this.getCallee(node, adapter, sourceCode);
|
|
538
|
-
if (!callee)
|
|
539
|
-
return false;
|
|
540
|
-
const dbMethods = config.dbCallMethods ?? [...DB_CALL_METHOD_NAMES];
|
|
541
|
-
const dbReceivers = config.dbReceiverNames ?? [...DB_RECEIVER_NAMES];
|
|
542
|
-
return this.isDbMemberCall(node, callee, dbMethods, dbReceivers, adapter, sourceCode);
|
|
543
|
-
},
|
|
544
|
-
});
|
|
545
|
-
for (const callNode of dbCalls) {
|
|
546
|
-
const firstArg = this.getFirstStringArgument(callNode, adapter, sourceCode);
|
|
547
|
-
if (!firstArg)
|
|
548
|
-
continue;
|
|
549
|
-
const location = this.getCallLocation(callNode);
|
|
550
|
-
const tableRefs = this.parseSqlTables(firstArg, location, sourceCode, allTables);
|
|
551
|
-
references.push(...tableRefs);
|
|
552
|
-
}
|
|
553
|
-
// (3) .sql files and migration directories — scan the entire source
|
|
554
|
-
if (ast.filePath.endsWith('.sql') ||
|
|
555
|
-
ast.filePath.includes('/migrations/') ||
|
|
556
|
-
ast.filePath.includes('\\migrations\\')) {
|
|
557
|
-
const fileRefs = this.parseSqlTables(sourceCode, { line: 1, column: 1 }, sourceCode, allTables);
|
|
558
|
-
references.push(...fileRefs);
|
|
559
|
-
}
|
|
560
|
-
// (4) Spec 15 R2 — ORM-aware extraction (Drizzle + Prisma)
|
|
561
|
-
// Run ORM adapter extraction for files that match a registered adapter.
|
|
562
|
-
// This complements raw-SQL extraction by picking up ORM-specific patterns
|
|
563
|
-
// like db.select().from(users) and prisma.user.findMany().
|
|
564
|
-
const ormRegistry = OrmAdapterRegistry.getInstance();
|
|
565
|
-
const ormAdapter = ormRegistry.getAdapterForFile(ast.filePath);
|
|
566
|
-
if (ormAdapter) {
|
|
567
|
-
try {
|
|
568
|
-
const ormRefs = ormAdapter.extractTableReferences(ast, adapter, sourceCode);
|
|
569
|
-
for (const ormRef of ormRefs) {
|
|
570
|
-
references.push({
|
|
571
|
-
table: ormRef.table,
|
|
572
|
-
type: ormRef.type,
|
|
573
|
-
location: ormRef.location,
|
|
574
|
-
context: ormRef.context,
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
catch {
|
|
579
|
-
// ORM extraction is best-effort — failures don't block raw-SQL extraction.
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
return references;
|
|
94
|
+
return violations;
|
|
583
95
|
}
|
|
584
96
|
/**
|
|
585
97
|
* Spec 15 R1 — Record extracted table references to schema_usage for
|
|
@@ -589,15 +101,21 @@ export class UniversalSchemaAnalyzer extends UniversalAnalyzer {
|
|
|
589
101
|
* Idempotent per-file: stale entries are cleared before fresh references
|
|
590
102
|
* are inserted. For .sql/migration files, uses "schema-file" as the
|
|
591
103
|
* function name since there's no AST function context.
|
|
104
|
+
* @returns
|
|
105
|
+
* @param adapter
|
|
106
|
+
* @param ast
|
|
107
|
+
* @param filePath
|
|
108
|
+
* @param references
|
|
109
|
+
* @returns
|
|
592
110
|
*/
|
|
593
111
|
recordTableUsage(ast, adapter, filePath, references) {
|
|
594
112
|
try {
|
|
595
113
|
this._pendingSchemaRecords.clearFiles.push(filePath);
|
|
596
114
|
for (const ref of references) {
|
|
597
115
|
// Find enclosing function from the AST position
|
|
598
|
-
const node =
|
|
116
|
+
const node = findClosestNodeAt(ast.root, ref.location, adapter);
|
|
599
117
|
const functionName = node
|
|
600
|
-
?
|
|
118
|
+
? findEnclosingFunctionName(node, adapter)
|
|
601
119
|
: ast.filePath.endsWith('.sql') || ast.filePath.includes('/migrations/')
|
|
602
120
|
? 'schema-file'
|
|
603
121
|
: 'top-level';
|
|
@@ -616,1015 +134,115 @@ export class UniversalSchemaAnalyzer extends UniversalAnalyzer {
|
|
|
616
134
|
// Schema recording is best-effort — failures don't block analysis.
|
|
617
135
|
}
|
|
618
136
|
}
|
|
619
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Spec 25 B4 — Drain pending schema records for the pipeline to write.
|
|
139
|
+
* @returns
|
|
140
|
+
*/
|
|
620
141
|
getPendingSchemaRecords() {
|
|
621
142
|
const records = this._pendingSchemaRecords;
|
|
622
143
|
this._pendingSchemaRecords = { clearFiles: [], usages: [] };
|
|
623
144
|
return records;
|
|
624
145
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
// Template sentinel filter: resolveTemplateExpressions() replaces
|
|
680
|
-
// ${...} with __TMPL__. Strip these before alias extraction and
|
|
681
|
-
// before returning — __TMPL__ is never a real table name.
|
|
682
|
-
references = references.filter(ref => !ref.table.startsWith('__TMPL__'));
|
|
683
|
-
// Spec 22 R4.3: Filter out alias identifiers.
|
|
684
|
-
// "FROM x AS t" defines t as an alias; later references like "JOIN t.posts"
|
|
685
|
-
// would capture t via the JOIN regex. Scan for explicit AS aliases.
|
|
686
|
-
const aliasIds = this.extractAliasIdentifiers(cleaned);
|
|
687
|
-
if (aliasIds.size > 0) {
|
|
688
|
-
return references.filter(ref => !aliasIds.has(ref.table.toLowerCase()));
|
|
689
|
-
}
|
|
690
|
-
return references;
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Spec 22 R4.3: Extract alias identifiers from SQL text.
|
|
694
|
-
*
|
|
695
|
-
* Detects both explicit (`FROM x AS t`) and bare (`FROM x t`) aliases
|
|
696
|
-
* so they can be filtered from table-references in parseSqlTables().
|
|
697
|
-
* Without this, "JOIN t.posts" captures t via the JOIN regex when t is
|
|
698
|
-
* an alias for the real table x.
|
|
699
|
-
*/
|
|
700
|
-
extractAliasIdentifiers(sqlText) {
|
|
701
|
-
const aliases = new Set();
|
|
702
|
-
// CTE: WITH <name> AS ( — the CTE name is an alias, not a real table.
|
|
703
|
-
// Without this, "WITH fresh AS (SELECT ...)" causes 'fresh' to be
|
|
704
|
-
// captured by FROM/JOIN/subquery patterns and flagged as unknown-table.
|
|
705
|
-
const cteRe = /\bWITH\s+([\p{L}_][\p{L}\p{N}_]*)\s+AS\s*\(/giu;
|
|
706
|
-
let m;
|
|
707
|
-
while ((m = cteRe.exec(sqlText)) !== null) {
|
|
708
|
-
aliases.add(m[1].toLowerCase());
|
|
709
|
-
}
|
|
710
|
-
// Explicit: FROM/JOIN <table> AS <alias>
|
|
711
|
-
const explicitRe = /\b(?:FROM|JOIN)\s+[\p{L}_][\p{L}\p{N}_]*\s+AS\s+([\p{L}_][\p{L}\p{N}_]*)\b/giu;
|
|
712
|
-
while ((m = explicitRe.exec(sqlText)) !== null) {
|
|
713
|
-
aliases.add(m[1].toLowerCase());
|
|
714
|
-
}
|
|
715
|
-
// Subquery bare alias: FROM (SELECT ...) <alias>
|
|
716
|
-
// The '(' stops the bare FROM/JOIN regex below because \w+ can't match it.
|
|
717
|
-
// Pattern: FROM/JOIN \s* \( ... \) \s* <alias>
|
|
718
|
-
const subqueryRe = /\b(?:FROM|JOIN)\s*\([^)]*\)\s+([\p{L}_][\p{L}\p{N}_]*)\b/giu;
|
|
719
|
-
while ((m = subqueryRe.exec(sqlText)) !== null) {
|
|
720
|
-
const alias = m[1];
|
|
721
|
-
if (!this.isSqlKeyword(alias)) {
|
|
722
|
-
aliases.add(alias.toLowerCase());
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
// Bare: FROM/JOIN <table> <alias> (alias is a bare identifier, not a keyword)
|
|
726
|
-
// Pattern: keyword + table + word — the third word is the alias if it's
|
|
727
|
-
// not a SQL keyword and not followed by '.' (table.column reference).
|
|
728
|
-
const bareRe = /\b(?:FROM|JOIN)\s+([\p{L}_][\p{L}\p{N}_]*)\s+([\p{L}_][\p{L}\p{N}_]*)\b/giu;
|
|
729
|
-
while ((m = bareRe.exec(sqlText)) !== null) {
|
|
730
|
-
const alias = m[2];
|
|
731
|
-
// Don't add if it looks like a keyword or is followed by '.' (table ref)
|
|
732
|
-
if (!this.isSqlKeyword(alias)) {
|
|
733
|
-
const afterMatch = sqlText.substring(m.index + m[0].length);
|
|
734
|
-
if (!/^\s*\./.test(afterMatch)) {
|
|
735
|
-
aliases.add(alias.toLowerCase());
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
return aliases;
|
|
740
|
-
}
|
|
741
|
-
/**
|
|
742
|
-
* R2.3: Resolve template expressions in SQL text.
|
|
743
|
-
*
|
|
744
|
-
* Uses the sentinel `__TMPL__` instead of an empty string. An empty
|
|
745
|
-
* replacement produces whitespace artifacts (e.g. `FROM t WHERE`
|
|
746
|
-
* when `${tableName}` is stripped), which causes the bare-alias regex
|
|
747
|
-
* in extractAliasIdentifiers() to misalign: `t` lands in the table-name
|
|
748
|
-
* capture group instead of the alias group, and is never denylisted.
|
|
749
|
-
*
|
|
750
|
-
* `__TMPL__` keeps the token boundaries intact so alias extraction
|
|
751
|
-
* correctly identifies `t` as the alias. `__TMPL__` table references
|
|
752
|
-
* are filtered in parseSqlTables().
|
|
753
|
-
*/
|
|
754
|
-
resolveTemplateExpressions(text) {
|
|
755
|
-
return text.replace(/\$\{[^}]+\}/g, '__TMPL__');
|
|
756
|
-
}
|
|
757
|
-
// ---------------------------------------------------------------------------
|
|
758
|
-
// R2.4 — Levenshtein suggestions
|
|
759
|
-
// ---------------------------------------------------------------------------
|
|
760
|
-
/**
|
|
761
|
-
* Return known table names within edit distance ≤ maxDist.
|
|
762
|
-
*/
|
|
763
|
-
getNearestTableSuggestions(name, knownTables, maxDist) {
|
|
764
|
-
const results = [];
|
|
765
|
-
for (const known of knownTables) {
|
|
766
|
-
const dist = this.levenshteinDistance(name.toLowerCase(), known.toLowerCase());
|
|
767
|
-
if (dist <= maxDist) {
|
|
768
|
-
results.push({ table: known, distance: dist });
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
// Sort by distance ascending
|
|
772
|
-
results.sort((a, b) => a.distance - b.distance);
|
|
773
|
-
return results.slice(0, 3).map(r => `'${r.table}'`);
|
|
774
|
-
}
|
|
775
|
-
levenshteinDistance(a, b) {
|
|
776
|
-
const m = a.length;
|
|
777
|
-
const n = b.length;
|
|
778
|
-
// Optimize: early exit if length difference exceeds threshold
|
|
779
|
-
if (Math.abs(m - n) > 3)
|
|
780
|
-
return Infinity;
|
|
781
|
-
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
782
|
-
for (let i = 0; i <= m; i++)
|
|
783
|
-
dp[i][0] = i;
|
|
784
|
-
for (let j = 0; j <= n; j++)
|
|
785
|
-
dp[0][j] = j;
|
|
786
|
-
for (let i = 1; i <= m; i++) {
|
|
787
|
-
for (let j = 1; j <= n; j++) {
|
|
788
|
-
if (a[i - 1] === b[j - 1]) {
|
|
789
|
-
dp[i][j] = dp[i - 1][j - 1];
|
|
790
|
-
}
|
|
791
|
-
else {
|
|
792
|
-
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
return dp[m][n];
|
|
797
|
-
}
|
|
798
|
-
// ---------------------------------------------------------------------------
|
|
799
|
-
// Naming conventions
|
|
800
|
-
// ---------------------------------------------------------------------------
|
|
801
|
-
checkNamingConventions(references, filePath) {
|
|
802
|
-
const violations = [];
|
|
803
|
-
for (const ref of references) {
|
|
804
|
-
if (/[A-Z]/.test(ref.table) && !ref.table.endsWith('Table')) {
|
|
805
|
-
violations.push(this.createViolation(filePath, ref.location, `Table name '${ref.table}' should use snake_case convention`, 'suggestion', 'naming-convention', undefined, ref.table));
|
|
806
|
-
}
|
|
807
|
-
const reserved = ['user', 'order', 'group', 'table', 'column', 'index'];
|
|
808
|
-
if (reserved.includes(ref.table.toLowerCase())) {
|
|
809
|
-
violations.push(this.createViolation(filePath, ref.location, `Table name '${ref.table}' is a reserved word. Consider using a different name.`, 'warning', 'reserved-word', undefined, ref.table));
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
return violations;
|
|
813
|
-
}
|
|
814
|
-
// ---------------------------------------------------------------------------
|
|
815
|
-
// Query patterns
|
|
816
|
-
// ---------------------------------------------------------------------------
|
|
817
|
-
checkQueryPatterns(ast, adapter, sourceCode, config) {
|
|
818
|
-
const violations = [];
|
|
819
|
-
const functions = adapter.extractFunctions(ast);
|
|
820
|
-
for (const func of functions) {
|
|
821
|
-
const funcNode = this.findNodeByLocation(ast.root, func.location.start);
|
|
822
|
-
if (!funcNode)
|
|
823
|
-
continue;
|
|
824
|
-
const funcText = adapter.getNodeText(funcNode, sourceCode);
|
|
825
|
-
const queryCount = this.countQueries(funcText);
|
|
826
|
-
if (queryCount > (config.maxQueriesPerFunction || 5)) {
|
|
827
|
-
violations.push(this.createViolation(ast.filePath, func.location.start, `Function '${func.name}' has ${queryCount} queries, exceeding the maximum of ${config.maxQueriesPerFunction}`, 'warning', 'too-many-queries', undefined, func.name));
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
// N+1 query detection is handled by the data-access analyzer (loop-query rule).
|
|
831
|
-
// The two were consolidated in Spec-19 Corrective Batch Item 3 — see CHANGELOG.
|
|
832
|
-
return violations;
|
|
833
|
-
}
|
|
834
|
-
// ---------------------------------------------------------------------------
|
|
835
|
-
// SQL injection — R7: severity changed from critical to warning
|
|
836
|
-
// ---------------------------------------------------------------------------
|
|
837
|
-
// ---------------------------------------------------------------------------
|
|
838
|
-
// SQL injection — per-call-site with enclosing-function + ordinal symbols
|
|
839
|
-
// ---------------------------------------------------------------------------
|
|
840
|
-
checkSQLInjection(ast, adapter, sourceCode) {
|
|
841
|
-
const violations = [];
|
|
842
|
-
const symbolOrdinals = new Map();
|
|
843
|
-
// Use regex with global flag to find individual call sites
|
|
844
|
-
const dangerousPatterns = [
|
|
845
|
-
/query\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`/g,
|
|
846
|
-
/query\s*\(\s*['"][^'"]*['"]?\s*\+/g,
|
|
847
|
-
/execute\s*\(\s*['"][^'"]*['"]?\s*\+/g,
|
|
848
|
-
];
|
|
849
|
-
for (const pattern of dangerousPatterns) {
|
|
850
|
-
// Clone regex to reset state (global regexes track lastIndex)
|
|
851
|
-
const re = new RegExp(pattern.source, pattern.flags);
|
|
852
|
-
let match;
|
|
853
|
-
while ((match = re.exec(sourceCode)) !== null) {
|
|
854
|
-
const location = this.offsetToLocation(sourceCode, match.index, { line: 1, column: 1 });
|
|
855
|
-
// Find enclosing function from the AST at this position
|
|
856
|
-
const node = this.findClosestNodeAt(ast.root, location, adapter);
|
|
857
|
-
const enclosingFn = node ? this.findEnclosingFunctionName(node, adapter) : 'top-level';
|
|
858
|
-
const baseSymbol = `${enclosingFn}:sql-injection`;
|
|
859
|
-
const ordinal = (symbolOrdinals.get(baseSymbol) ?? 0) + 1;
|
|
860
|
-
symbolOrdinals.set(baseSymbol, ordinal);
|
|
861
|
-
const symbol = ordinal > 1 ? `${baseSymbol}:${ordinal}` : baseSymbol;
|
|
862
|
-
violations.push(this.createViolation(ast.filePath, location, 'Potential SQL injection vulnerability. Use parameterized queries.', 'suggestion', // Spec 11 R4 blanket demotion: all survivors → suggestion
|
|
863
|
-
'sql-injection', undefined, symbol));
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
return violations;
|
|
867
|
-
}
|
|
868
|
-
// ---------------------------------------------------------------------------
|
|
869
|
-
// AST helpers for R2.1
|
|
870
|
-
// ---------------------------------------------------------------------------
|
|
871
|
-
/**
|
|
872
|
-
* Extract the callee text from a call_expression node.
|
|
873
|
-
*/
|
|
874
|
-
getCallee(node, adapter, sourceCode) {
|
|
875
|
-
// For db.exec() → callee is "db.exec"
|
|
876
|
-
if (!node.children)
|
|
877
|
-
return null;
|
|
878
|
-
for (const child of node.children) {
|
|
879
|
-
const type = adapter.getNodeType(child);
|
|
880
|
-
if (type === 'identifier' ||
|
|
881
|
-
type === 'member_expression' ||
|
|
882
|
-
type === 'call_expression') {
|
|
883
|
-
return adapter.getNodeText(child, sourceCode).trim();
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
return null;
|
|
887
|
-
}
|
|
888
|
-
/**
|
|
889
|
-
* Check if call_expression has a template string argument.
|
|
890
|
-
*/
|
|
891
|
-
hasTemplateArgument(node, adapter) {
|
|
892
|
-
if (!node.children)
|
|
893
|
-
return false;
|
|
894
|
-
for (const child of node.children) {
|
|
895
|
-
const type = adapter.getNodeType(child);
|
|
896
|
-
if (type === 'template_string' || type === 'template_literal') {
|
|
897
|
-
return true;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
return false;
|
|
146
|
+
}
|
|
147
|
+
// ── Spec 34 — analyze()/analyzeAST() extraction helpers ────────────────
|
|
148
|
+
// Extracted from the two methods above to keep them under the 50-line
|
|
149
|
+
// function-length gate. Pure module-level functions (no `this`), consistent
|
|
150
|
+
// with the functional-analyzer pattern.
|
|
151
|
+
/**
|
|
152
|
+
* Auto-discover known tables when no schemas are configured, returning the
|
|
153
|
+
* (possibly augmented) config. When schemas are already present, returns the
|
|
154
|
+
* config unchanged.
|
|
155
|
+
*/
|
|
156
|
+
async function resolveSchemasViaAutoDiscovery(config, codeFiles) {
|
|
157
|
+
const schemas = config.schemas;
|
|
158
|
+
if (schemas && schemas.length > 0) {
|
|
159
|
+
return config;
|
|
160
|
+
}
|
|
161
|
+
const projectRoot = config.projectRoot || process.cwd();
|
|
162
|
+
const fromWrangler = await discoverTablesFromWrangler(projectRoot);
|
|
163
|
+
const schemaFiles = config.schemaFiles;
|
|
164
|
+
const fromSchemaFiles = schemaFiles && schemaFiles.length > 0
|
|
165
|
+
? await discoverTablesFromSchemaFiles(schemaFiles, projectRoot)
|
|
166
|
+
: new Set();
|
|
167
|
+
const fromMigrations = await discoverTablesFromMigrations(projectRoot, config);
|
|
168
|
+
const fromOrm = await discoverTablesFromOrmSchemas(codeFiles);
|
|
169
|
+
const discovered = new Set([
|
|
170
|
+
...fromWrangler,
|
|
171
|
+
...fromSchemaFiles,
|
|
172
|
+
...fromMigrations,
|
|
173
|
+
...fromOrm,
|
|
174
|
+
]);
|
|
175
|
+
if (discovered.size === 0) {
|
|
176
|
+
return config;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
...config,
|
|
180
|
+
schemas: [{
|
|
181
|
+
name: 'auto-discovered',
|
|
182
|
+
tables: [...discovered].map(name => ({ name, columns: [] })),
|
|
183
|
+
}],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function emptySchemaResult(analyzerName) {
|
|
187
|
+
return {
|
|
188
|
+
violations: [],
|
|
189
|
+
executionTime: 0,
|
|
190
|
+
status: makeVisitorStatus(0),
|
|
191
|
+
analyzerName,
|
|
192
|
+
errors: [],
|
|
193
|
+
filesProcessed: 0,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function analyzeJsonFiles(jsonFiles, config, analyzerName) {
|
|
197
|
+
if (jsonFiles.length === 0) {
|
|
198
|
+
return emptySchemaResult(analyzerName);
|
|
901
199
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
return null;
|
|
908
|
-
for (const child of node.children) {
|
|
909
|
-
const type = adapter.getNodeType(child);
|
|
910
|
-
if (type === 'template_string' || type === 'template_literal') {
|
|
911
|
-
return adapter.getNodeText(child, sourceCode).trim();
|
|
912
|
-
}
|
|
200
|
+
const readJson = (file) => {
|
|
201
|
+
try {
|
|
202
|
+
const raw = readFileSync(file, 'utf8');
|
|
203
|
+
const parsed = JSON.parse(raw);
|
|
204
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : null;
|
|
913
205
|
}
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
/**
|
|
917
|
-
* Get the first string/template argument from a call expression.
|
|
918
|
-
*/
|
|
919
|
-
getFirstStringArgument(node, adapter, sourceCode) {
|
|
920
|
-
if (!node.children)
|
|
206
|
+
catch {
|
|
921
207
|
return null;
|
|
922
|
-
// Look for 'arguments' child first
|
|
923
|
-
for (const child of node.children) {
|
|
924
|
-
const type = adapter.getNodeType(child);
|
|
925
|
-
if (type === 'arguments' && child.children) {
|
|
926
|
-
for (const arg of child.children) {
|
|
927
|
-
const argType = adapter.getNodeType(arg);
|
|
928
|
-
if (argType === 'string' ||
|
|
929
|
-
argType === 'template_string' ||
|
|
930
|
-
argType === 'template_literal') {
|
|
931
|
-
const text = adapter.getNodeText(arg, sourceCode).trim();
|
|
932
|
-
// Strip surrounding quotes from string literals
|
|
933
|
-
if ((text.startsWith("'") && text.endsWith("'")) ||
|
|
934
|
-
(text.startsWith('"') && text.endsWith('"')) ||
|
|
935
|
-
(text.startsWith('`') && text.endsWith('`'))) {
|
|
936
|
-
return text.slice(1, -1);
|
|
937
|
-
}
|
|
938
|
-
return text;
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
208
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
return node.location.start;
|
|
962
|
-
}
|
|
963
|
-
/**
|
|
964
|
-
* Convert a character offset to a line/column location.
|
|
965
|
-
*/
|
|
966
|
-
offsetToLocation(sourceCode, offset, base) {
|
|
967
|
-
if (offset < 0 || offset >= sourceCode.length)
|
|
968
|
-
return base;
|
|
969
|
-
const before = sourceCode.substring(0, offset);
|
|
970
|
-
const lineOffset = before.split('\n').length - 1;
|
|
971
|
-
const lastNewline = before.lastIndexOf('\n');
|
|
972
|
-
const column = lastNewline >= 0 ? offset - lastNewline : offset + 1;
|
|
973
|
-
// offset is absolute in sourceCode — lineOffset is 0-based, so +1 gives
|
|
974
|
-
// the correct 1-based line. base.line is the fallback guard only.
|
|
975
|
-
return { line: lineOffset + 1, column };
|
|
976
|
-
}
|
|
977
|
-
// ---------------------------------------------------------------------------
|
|
978
|
-
// General helpers
|
|
979
|
-
// ---------------------------------------------------------------------------
|
|
980
|
-
isSystemTable(table) {
|
|
981
|
-
const systemTables = [
|
|
982
|
-
'information_schema',
|
|
983
|
-
'pg_catalog',
|
|
984
|
-
'mysql',
|
|
985
|
-
'performance_schema',
|
|
986
|
-
'sys',
|
|
987
|
-
'sqlite_master',
|
|
988
|
-
'sqlite_sequence',
|
|
989
|
-
];
|
|
990
|
-
return systemTables.some(st => table.toLowerCase() === st || table.toLowerCase().startsWith(st + '.'));
|
|
991
|
-
}
|
|
992
|
-
/**
|
|
993
|
-
* Common SQL keywords and identifiers that are not real table names.
|
|
994
|
-
*/
|
|
995
|
-
isSqlKeyword(word) {
|
|
996
|
-
const keywords = new Set([
|
|
997
|
-
'select', 'from', 'where', 'join', 'inner', 'outer', 'left', 'right',
|
|
998
|
-
'full', 'cross', 'on', 'and', 'or', 'not', 'in', 'as', 'is', 'null',
|
|
999
|
-
'like', 'between', 'order', 'group', 'by', 'having', 'limit', 'offset',
|
|
1000
|
-
'union', 'all', 'distinct', 'case', 'when', 'then', 'else', 'end',
|
|
1001
|
-
'insert', 'into', 'values', 'update', 'set', 'delete', 'create',
|
|
1002
|
-
'table', 'alter', 'drop', 'index', 'view', 'if', 'exists', 'primary',
|
|
1003
|
-
'key', 'foreign', 'references', 'constraint', 'default', 'unique',
|
|
1004
|
-
'check', 'asc', 'desc', 'count', 'sum', 'avg', 'min', 'max',
|
|
1005
|
-
'integer', 'text', 'varchar', 'text', 'boolean', 'float', 'blob',
|
|
1006
|
-
'real', 'timestamp', 'date', 'time', 'datetime', 'serial', 'bigint',
|
|
1007
|
-
'the', 'a', 'an',
|
|
1008
|
-
]);
|
|
1009
|
-
return keywords.has(word.toLowerCase());
|
|
1010
|
-
}
|
|
1011
|
-
getQueryType(context) {
|
|
1012
|
-
const upper = context.toUpperCase();
|
|
1013
|
-
if (upper.includes('SELECT'))
|
|
1014
|
-
return 'select';
|
|
1015
|
-
if (upper.includes('INSERT'))
|
|
1016
|
-
return 'insert';
|
|
1017
|
-
if (upper.includes('UPDATE'))
|
|
1018
|
-
return 'update';
|
|
1019
|
-
if (upper.includes('DELETE'))
|
|
1020
|
-
return 'delete';
|
|
1021
|
-
if (upper.includes('CREATE'))
|
|
1022
|
-
return 'create';
|
|
1023
|
-
return 'reference';
|
|
1024
|
-
}
|
|
1025
|
-
countQueries(text) {
|
|
1026
|
-
const patterns = [
|
|
1027
|
-
/\.query\s*\(/g,
|
|
1028
|
-
/\.execute\s*\(/g,
|
|
1029
|
-
/SELECT\s+/gi,
|
|
1030
|
-
/INSERT\s+INTO/gi,
|
|
1031
|
-
/UPDATE\s+/gi,
|
|
1032
|
-
/DELETE\s+FROM/gi,
|
|
1033
|
-
];
|
|
1034
|
-
let count = 0;
|
|
1035
|
-
for (const pattern of patterns) {
|
|
1036
|
-
const matches = text.match(pattern);
|
|
1037
|
-
if (matches)
|
|
1038
|
-
count += matches.length;
|
|
1039
|
-
}
|
|
1040
|
-
return count;
|
|
1041
|
-
}
|
|
1042
|
-
findNodeByLocation(root, location) {
|
|
1043
|
-
const queue = [root];
|
|
1044
|
-
while (queue.length > 0) {
|
|
1045
|
-
const node = queue.shift();
|
|
1046
|
-
if (node.location.start.line === location.line &&
|
|
1047
|
-
node.location.start.column === location.column) {
|
|
1048
|
-
return node;
|
|
1049
|
-
}
|
|
1050
|
-
if (node.children) {
|
|
1051
|
-
queue.push(...node.children);
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
return null;
|
|
1055
|
-
}
|
|
1056
|
-
/**
|
|
1057
|
-
* Find the nearest AST node at a source location — walks the tree looking
|
|
1058
|
-
* for the deepest node that contains the given line/column.
|
|
1059
|
-
*/
|
|
1060
|
-
findClosestNodeAt(root, location, adapter) {
|
|
1061
|
-
let best = null;
|
|
1062
|
-
let bestDepth = -1;
|
|
1063
|
-
const walk = (node, depth) => {
|
|
1064
|
-
const start = node.location.start;
|
|
1065
|
-
const end = node.location.end;
|
|
1066
|
-
// Check if node contains the location
|
|
1067
|
-
if ((start.line < location.line ||
|
|
1068
|
-
(start.line === location.line && start.column <= location.column)) &&
|
|
1069
|
-
(end.line > location.line ||
|
|
1070
|
-
(end.line === location.line && end.column >= location.column))) {
|
|
1071
|
-
if (depth > bestDepth) {
|
|
1072
|
-
best = node;
|
|
1073
|
-
bestDepth = depth;
|
|
1074
|
-
}
|
|
1075
|
-
if (node.children) {
|
|
1076
|
-
for (const child of node.children) {
|
|
1077
|
-
walk(child, depth + 1);
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
};
|
|
1082
|
-
walk(root, 0);
|
|
1083
|
-
return best;
|
|
1084
|
-
}
|
|
1085
|
-
/**
|
|
1086
|
-
* Walk up the AST from a node to find the enclosing function or method name.
|
|
1087
|
-
* Matches the same scheme as UniversalDataAccessAnalyzer.findEnclosingFunctionName.
|
|
1088
|
-
*/
|
|
1089
|
-
findEnclosingFunctionName(node, adapter) {
|
|
1090
|
-
let current = node;
|
|
1091
|
-
while (current) {
|
|
1092
|
-
const type = adapter.getNodeType(current);
|
|
1093
|
-
if (type === 'arrow_function' ||
|
|
1094
|
-
type === 'function_declaration' ||
|
|
1095
|
-
type === 'function_expression' ||
|
|
1096
|
-
type === 'generator_function_declaration' ||
|
|
1097
|
-
type === 'generator_function_expression' ||
|
|
1098
|
-
type === 'method_definition') {
|
|
1099
|
-
const name = this.getNodeName(current, adapter);
|
|
1100
|
-
if (name)
|
|
1101
|
-
return name;
|
|
1102
|
-
}
|
|
1103
|
-
if (adapter.isMethod(current)) {
|
|
1104
|
-
const name = this.getNodeName(current, adapter);
|
|
1105
|
-
if (name)
|
|
1106
|
-
return name;
|
|
1107
|
-
}
|
|
1108
|
-
current = adapter.getParent(current);
|
|
1109
|
-
}
|
|
1110
|
-
return 'top-level';
|
|
1111
|
-
}
|
|
1112
|
-
/**
|
|
1113
|
-
* Extract a human-readable name from an AST node.
|
|
1114
|
-
* Matches the same scheme as UniversalDataAccessAnalyzer.getNodeName.
|
|
1115
|
-
*/
|
|
1116
|
-
getNodeName(node, adapter) {
|
|
1117
|
-
// Try explicit name/text on the converted ASTNode (some adapters set it)
|
|
1118
|
-
if (node.name && typeof node.name === 'string') {
|
|
1119
|
-
return node.name;
|
|
1120
|
-
}
|
|
1121
|
-
if (node.text && typeof node.text === 'string') {
|
|
1122
|
-
return node.text;
|
|
1123
|
-
}
|
|
1124
|
-
// Fall back to the raw tree-sitter node's text content (leaf identifiers etc.)
|
|
1125
|
-
const rawText = node.raw?.text;
|
|
1126
|
-
if (typeof rawText === 'string' && rawText.length > 0) {
|
|
1127
|
-
return rawText;
|
|
1128
|
-
}
|
|
1129
|
-
if (node.children) {
|
|
1130
|
-
for (const child of node.children) {
|
|
1131
|
-
const childType = adapter.getNodeType(child);
|
|
1132
|
-
if (childType === 'identifier' || childType === 'property_identifier') {
|
|
1133
|
-
const name = this.getNodeName(child, adapter);
|
|
1134
|
-
if (name)
|
|
1135
|
-
return name;
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
return '';
|
|
1140
|
-
}
|
|
1141
|
-
// ---------------------------------------------------------------------------
|
|
1142
|
-
// JSON Schema validation (unchanged from original)
|
|
1143
|
-
// ---------------------------------------------------------------------------
|
|
1144
|
-
/**
|
|
1145
|
-
* Analyze JSON schemas and validate data files against them.
|
|
1146
|
-
*
|
|
1147
|
-
* Pipeline-refactored: accepts a Map of pre-loaded JSON contents instead of
|
|
1148
|
-
* reading from the filesystem, so this can run inside a Stage 3 reducer.
|
|
1149
|
-
*
|
|
1150
|
-
* @param jsonContents Map from filePath → { parsed: pre-parsed object or null, raw: string }
|
|
1151
|
-
* @param config Schema analyzer configuration
|
|
1152
|
-
*/
|
|
1153
|
-
analyzeJsonSchemas(jsonContents, config) {
|
|
1154
|
-
const violations = [];
|
|
1155
|
-
const errors = [];
|
|
1156
|
-
let filesProcessed = 0;
|
|
1157
|
-
const startTime = Date.now();
|
|
1158
|
-
const finalConfig = { ...DEFAULT_SCHEMA_CONFIG, ...config };
|
|
1159
|
-
if (!finalConfig.validateJsonSchemas) {
|
|
1160
|
-
return { violations, errors, status: makeVisitorStatus(filesProcessed), executionTime: 0, analyzerName: this.name };
|
|
1161
|
-
}
|
|
1162
|
-
const files = Array.from(jsonContents.keys());
|
|
1163
|
-
const schemaFiles = this.identifySchemaFiles(files, finalConfig);
|
|
1164
|
-
const dataFiles = this.identifyDataFiles(files, finalConfig);
|
|
1165
|
-
const unknownJsonFiles = files.filter(f => !schemaFiles.includes(f) && !dataFiles.includes(f));
|
|
1166
|
-
// Helper: parse from Map content (with fallback)
|
|
1167
|
-
const getParsed = (filePath) => {
|
|
1168
|
-
const entry = jsonContents.get(filePath);
|
|
1169
|
-
if (!entry)
|
|
1170
|
-
return null;
|
|
1171
|
-
if (entry.parsed !== null)
|
|
1172
|
-
return entry.parsed;
|
|
1173
|
-
try {
|
|
1174
|
-
return JSON.parse(entry.raw);
|
|
1175
|
-
}
|
|
1176
|
-
catch {
|
|
1177
|
-
return null;
|
|
1178
|
-
}
|
|
1179
|
-
};
|
|
1180
|
-
const schemas = new Map();
|
|
1181
|
-
for (const file of schemaFiles) {
|
|
1182
|
-
try {
|
|
1183
|
-
const entry = jsonContents.get(file);
|
|
1184
|
-
const content = entry?.raw;
|
|
1185
|
-
if (!content) {
|
|
1186
|
-
violations.push({
|
|
1187
|
-
file,
|
|
1188
|
-
line: 1,
|
|
1189
|
-
column: 1,
|
|
1190
|
-
severity: 'warning',
|
|
1191
|
-
message: `Content not available for schema file`,
|
|
1192
|
-
rule: 'file-error',
|
|
1193
|
-
analyzer: 'schema'
|
|
1194
|
-
});
|
|
1195
|
-
filesProcessed++;
|
|
1196
|
-
continue;
|
|
1197
|
-
}
|
|
1198
|
-
const schema = entry.parsed ?? (() => { try {
|
|
1199
|
-
return JSON.parse(content);
|
|
1200
|
-
}
|
|
1201
|
-
catch {
|
|
1202
|
-
return null;
|
|
1203
|
-
} })();
|
|
1204
|
-
if (schema === null)
|
|
1205
|
-
throw new SyntaxError('JSON parse failed');
|
|
1206
|
-
schemas.set(file, schema);
|
|
1207
|
-
const fileViolations = this.validateJsonSchema(schema, file, finalConfig);
|
|
1208
|
-
violations.push(...fileViolations);
|
|
1209
|
-
filesProcessed++;
|
|
1210
|
-
}
|
|
1211
|
-
catch (error) {
|
|
1212
|
-
if (error instanceof SyntaxError) {
|
|
1213
|
-
violations.push({
|
|
1214
|
-
file,
|
|
1215
|
-
line: 1,
|
|
1216
|
-
column: 1,
|
|
1217
|
-
severity: 'warning',
|
|
1218
|
-
message: `Invalid JSON: ${error.message}`,
|
|
1219
|
-
rule: 'invalid-json',
|
|
1220
|
-
analyzer: 'schema'
|
|
1221
|
-
});
|
|
1222
|
-
}
|
|
1223
|
-
else {
|
|
1224
|
-
errors.push({
|
|
1225
|
-
file,
|
|
1226
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1227
|
-
});
|
|
1228
|
-
}
|
|
1229
|
-
filesProcessed++;
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
if (finalConfig.schemaDataPairs) {
|
|
1233
|
-
for (const pair of finalConfig.schemaDataPairs) {
|
|
1234
|
-
const schema = schemas.get(pair.schema) ?? getParsed(pair.schema);
|
|
1235
|
-
if (schema) {
|
|
1236
|
-
const dataFiles = Array.isArray(pair.data) ? pair.data : [pair.data];
|
|
1237
|
-
for (const dataFile of dataFiles) {
|
|
1238
|
-
if (files.includes(dataFile)) {
|
|
1239
|
-
const parsed = getParsed(dataFile);
|
|
1240
|
-
if (parsed !== null) {
|
|
1241
|
-
const dataViolations = [];
|
|
1242
|
-
this.validateAgainstSchema(parsed, schema, dataFile, dataViolations, finalConfig);
|
|
1243
|
-
violations.push(...dataViolations);
|
|
1244
|
-
}
|
|
1245
|
-
else {
|
|
1246
|
-
violations.push({
|
|
1247
|
-
file: dataFile,
|
|
1248
|
-
line: 1,
|
|
1249
|
-
column: 1,
|
|
1250
|
-
severity: 'warning',
|
|
1251
|
-
message: 'Invalid JSON in data file',
|
|
1252
|
-
rule: 'invalid-json',
|
|
1253
|
-
analyzer: 'schema'
|
|
1254
|
-
});
|
|
1255
|
-
}
|
|
1256
|
-
filesProcessed++;
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
else {
|
|
1263
|
-
for (const dataFile of [...dataFiles, ...unknownJsonFiles]) {
|
|
1264
|
-
const matchedSchema = this.findMatchingSchema(dataFile, schemas, finalConfig);
|
|
1265
|
-
if (matchedSchema) {
|
|
1266
|
-
const parsed = getParsed(dataFile);
|
|
1267
|
-
if (parsed !== null) {
|
|
1268
|
-
const dataViolations = [];
|
|
1269
|
-
this.validateAgainstSchema(parsed, matchedSchema, dataFile, dataViolations, finalConfig);
|
|
1270
|
-
violations.push(...dataViolations);
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
else if (unknownJsonFiles.includes(dataFile)) {
|
|
1274
|
-
const parsed = getParsed(dataFile);
|
|
1275
|
-
if (parsed === null) {
|
|
1276
|
-
violations.push({
|
|
1277
|
-
file: dataFile,
|
|
1278
|
-
line: 1,
|
|
1279
|
-
column: 1,
|
|
1280
|
-
severity: 'warning',
|
|
1281
|
-
message: `Invalid JSON: Parse error`,
|
|
1282
|
-
rule: 'invalid-json',
|
|
1283
|
-
analyzer: 'schema'
|
|
1284
|
-
});
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
filesProcessed++;
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
return {
|
|
1291
|
-
violations,
|
|
1292
|
-
errors,
|
|
1293
|
-
status: makeVisitorStatus(filesProcessed),
|
|
1294
|
-
executionTime: Date.now() - startTime,
|
|
1295
|
-
analyzerName: this.name,
|
|
1296
|
-
};
|
|
1297
|
-
}
|
|
1298
|
-
validateJsonSchema(schema, filePath, config) {
|
|
1299
|
-
const violations = [];
|
|
1300
|
-
if (!schema.$schema && config.jsonSchemaVersion) {
|
|
1301
|
-
violations.push({
|
|
1302
|
-
file: filePath,
|
|
1303
|
-
line: 1,
|
|
1304
|
-
column: 1,
|
|
1305
|
-
severity: 'suggestion',
|
|
1306
|
-
message: 'JSON Schema missing $schema declaration',
|
|
1307
|
-
rule: 'missing-schema-declaration',
|
|
1308
|
-
analyzer: 'schema'
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1311
|
-
this.validateSchemaTypes(schema, filePath, config, violations);
|
|
1312
|
-
if (schema.type === 'object' && schema.properties) {
|
|
1313
|
-
if (schema.required && Array.isArray(schema.required)) {
|
|
1314
|
-
for (const field of schema.required) {
|
|
1315
|
-
if (!schema.properties[field]) {
|
|
1316
|
-
violations.push({
|
|
1317
|
-
file: filePath,
|
|
1318
|
-
line: 1,
|
|
1319
|
-
column: 1,
|
|
1320
|
-
severity: 'warning',
|
|
1321
|
-
message: `Required field "${field}" not defined in properties`,
|
|
1322
|
-
rule: 'undefined-required-field',
|
|
1323
|
-
analyzer: 'schema'
|
|
1324
|
-
});
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
return violations;
|
|
1330
|
-
}
|
|
1331
|
-
validateSchemaTypes(schema, filePath, config, violations, path = '') {
|
|
1332
|
-
if (!schema || typeof schema !== 'object')
|
|
1333
|
-
return;
|
|
1334
|
-
if (schema.type && config.allowedJsonTypes) {
|
|
1335
|
-
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
1336
|
-
for (const type of types) {
|
|
1337
|
-
if (!config.allowedJsonTypes.includes(type)) {
|
|
1338
|
-
violations.push({
|
|
1339
|
-
file: filePath,
|
|
1340
|
-
line: 1,
|
|
1341
|
-
column: 1,
|
|
1342
|
-
severity: 'warning',
|
|
1343
|
-
message: `Invalid type "${type}" at ${path || 'root'}. Allowed types: ${config.allowedJsonTypes.join(', ')}`,
|
|
1344
|
-
rule: 'invalid-type',
|
|
1345
|
-
analyzer: 'schema'
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
if (schema.type === 'integer' || schema.type === 'number') {
|
|
1351
|
-
if (schema.minimum !== undefined && schema.maximum !== undefined) {
|
|
1352
|
-
if (schema.minimum > schema.maximum) {
|
|
1353
|
-
violations.push({
|
|
1354
|
-
file: filePath,
|
|
1355
|
-
line: 1,
|
|
1356
|
-
column: 1,
|
|
1357
|
-
severity: 'warning',
|
|
1358
|
-
message: `Invalid range at ${path}: minimum (${schema.minimum}) > maximum (${schema.maximum})`,
|
|
1359
|
-
rule: 'invalid-range',
|
|
1360
|
-
analyzer: 'schema'
|
|
1361
|
-
});
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
if (schema.properties) {
|
|
1366
|
-
for (const [key, value] of Object.entries(schema.properties)) {
|
|
1367
|
-
this.validateSchemaTypes(value, filePath, config, violations, `${path}.${key}`);
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
if (schema.items) {
|
|
1371
|
-
this.validateSchemaTypes(schema.items, filePath, config, violations, `${path}[items]`);
|
|
1372
|
-
}
|
|
1373
|
-
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
1374
|
-
this.validateSchemaTypes(schema.additionalProperties, filePath, config, violations, `${path}[additionalProperties]`);
|
|
209
|
+
};
|
|
210
|
+
return analyzeJsonSchemas(jsonFiles, readJson, config);
|
|
211
|
+
}
|
|
212
|
+
function buildSchemaProvenanceContext(ast, adapter, sourceCode, config) {
|
|
213
|
+
const detectionMode = config.detection?.mode ?? 'hybrid';
|
|
214
|
+
return buildProvenanceContext(ast, adapter, sourceCode, {
|
|
215
|
+
mode: detectionMode,
|
|
216
|
+
dbReceiverNames: config.dbReceiverNames ?? DEFAULT_SCHEMA_CONFIG.dbReceiverNames,
|
|
217
|
+
dbBindingNames: config.dbBindingNames ?? DEFAULT_SCHEMA_CONFIG.dbBindingNames,
|
|
218
|
+
dbCallMethods: config.dbCallMethods ?? DEFAULT_SCHEMA_CONFIG.dbCallMethods,
|
|
219
|
+
dbWrapperNames: config.dbWrapperNames ?? DEFAULT_SCHEMA_CONFIG.dbWrapperNames,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function collectAllTableNames(schemas) {
|
|
223
|
+
const allTables = new Set();
|
|
224
|
+
for (const schema of schemas ?? []) {
|
|
225
|
+
for (const table of schema.tables) {
|
|
226
|
+
allTables.add(table.name);
|
|
1375
227
|
}
|
|
1376
228
|
}
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
return regex.test(fileName);
|
|
1385
|
-
});
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
identifyDataFiles(files, config) {
|
|
1389
|
-
if (!config.dataFilePatterns)
|
|
1390
|
-
return [];
|
|
1391
|
-
return files.filter(file => {
|
|
1392
|
-
const fileName = file.split('/').pop() || '';
|
|
1393
|
-
return config.dataFilePatterns.some(pattern => {
|
|
1394
|
-
const regex = new RegExp(pattern.replace('*', '.*'));
|
|
1395
|
-
return regex.test(fileName);
|
|
1396
|
-
});
|
|
1397
|
-
});
|
|
229
|
+
return allTables;
|
|
230
|
+
}
|
|
231
|
+
function appendSchemaViolations(violations, ctx) {
|
|
232
|
+
const { ast, adapter, sourceCode, config, tableRefs, allTables } = ctx;
|
|
233
|
+
// Check for missing table references — R2.4: Levenshtein suggestions
|
|
234
|
+
if (config.checkMissingReferences) {
|
|
235
|
+
violations.push(...withRuleTiming('unknown-table', () => checkMissingReferences(tableRefs, allTables, ast.filePath)));
|
|
1398
236
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
const dataBaseName = dataFileName.replace(/\.(data|example|test)\.json$/, '');
|
|
1402
|
-
for (const [schemaFile, schema] of schemas) {
|
|
1403
|
-
const schemaFileName = schemaFile.split('/').pop() || '';
|
|
1404
|
-
const schemaBaseName = schemaFileName.replace(/[.-]?schema\.json$/, '');
|
|
1405
|
-
if (dataBaseName === schemaBaseName) {
|
|
1406
|
-
return schema;
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
if (schemas.size === 1) {
|
|
1410
|
-
return schemas.values().next().value;
|
|
1411
|
-
}
|
|
1412
|
-
return null;
|
|
237
|
+
if (config.checkNamingConventions) {
|
|
238
|
+
violations.push(...checkNamingConventions(tableRefs, ast.filePath));
|
|
1413
239
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
const actualType = Array.isArray(data) ? 'array' :
|
|
1417
|
-
data === null ? 'null' :
|
|
1418
|
-
typeof data;
|
|
1419
|
-
const allowedTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
1420
|
-
const isValidType = allowedTypes.some((type) => {
|
|
1421
|
-
if (type === 'integer') {
|
|
1422
|
-
return typeof data === 'number' && Number.isInteger(data);
|
|
1423
|
-
}
|
|
1424
|
-
return type === actualType;
|
|
1425
|
-
});
|
|
1426
|
-
if (!isValidType) {
|
|
1427
|
-
violations.push({
|
|
1428
|
-
file: filePath,
|
|
1429
|
-
line: 1,
|
|
1430
|
-
column: 1,
|
|
1431
|
-
severity: 'warning',
|
|
1432
|
-
message: `Type mismatch at ${path || 'root'}: expected ${allowedTypes.join(' | ')}, got ${actualType}`,
|
|
1433
|
-
rule: 'type-mismatch',
|
|
1434
|
-
analyzer: 'schema'
|
|
1435
|
-
});
|
|
1436
|
-
return;
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
if (schema.type === 'string' && typeof data === 'string') {
|
|
1440
|
-
if (schema.minLength !== undefined && data.length < schema.minLength) {
|
|
1441
|
-
violations.push({
|
|
1442
|
-
file: filePath,
|
|
1443
|
-
line: 1,
|
|
1444
|
-
column: 1,
|
|
1445
|
-
severity: 'warning',
|
|
1446
|
-
message: `String at ${path} too short: ${data.length} < ${schema.minLength}`,
|
|
1447
|
-
rule: 'string-too-short',
|
|
1448
|
-
analyzer: 'schema'
|
|
1449
|
-
});
|
|
1450
|
-
}
|
|
1451
|
-
if (schema.maxLength !== undefined && data.length > schema.maxLength) {
|
|
1452
|
-
violations.push({
|
|
1453
|
-
file: filePath,
|
|
1454
|
-
line: 1,
|
|
1455
|
-
column: 1,
|
|
1456
|
-
severity: 'warning',
|
|
1457
|
-
message: `String at ${path} too long: ${data.length} > ${schema.maxLength}`,
|
|
1458
|
-
rule: 'string-too-long',
|
|
1459
|
-
analyzer: 'schema'
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1462
|
-
if (schema.pattern) {
|
|
1463
|
-
const regex = new RegExp(schema.pattern);
|
|
1464
|
-
if (!regex.test(data)) {
|
|
1465
|
-
violations.push({
|
|
1466
|
-
file: filePath,
|
|
1467
|
-
line: 1,
|
|
1468
|
-
column: 1,
|
|
1469
|
-
severity: 'warning',
|
|
1470
|
-
message: `String at ${path} doesn't match pattern: ${schema.pattern}`,
|
|
1471
|
-
rule: 'pattern-mismatch',
|
|
1472
|
-
analyzer: 'schema'
|
|
1473
|
-
});
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
if (schema.format) {
|
|
1477
|
-
switch (schema.format) {
|
|
1478
|
-
case 'email':
|
|
1479
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data)) {
|
|
1480
|
-
violations.push({
|
|
1481
|
-
file: filePath,
|
|
1482
|
-
line: 1,
|
|
1483
|
-
column: 1,
|
|
1484
|
-
severity: 'warning',
|
|
1485
|
-
message: `Invalid email format at ${path}`,
|
|
1486
|
-
rule: 'invalid-format',
|
|
1487
|
-
analyzer: 'schema'
|
|
1488
|
-
});
|
|
1489
|
-
}
|
|
1490
|
-
break;
|
|
1491
|
-
case 'uuid':
|
|
1492
|
-
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(data)) {
|
|
1493
|
-
violations.push({
|
|
1494
|
-
file: filePath,
|
|
1495
|
-
line: 1,
|
|
1496
|
-
column: 1,
|
|
1497
|
-
severity: 'warning',
|
|
1498
|
-
message: `Invalid UUID format at ${path}`,
|
|
1499
|
-
rule: 'invalid-format',
|
|
1500
|
-
analyzer: 'schema'
|
|
1501
|
-
});
|
|
1502
|
-
}
|
|
1503
|
-
break;
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
if ((schema.type === 'number' || schema.type === 'integer') && typeof data === 'number') {
|
|
1508
|
-
if (schema.minimum !== undefined && data < schema.minimum) {
|
|
1509
|
-
violations.push({
|
|
1510
|
-
file: filePath,
|
|
1511
|
-
line: 1,
|
|
1512
|
-
column: 1,
|
|
1513
|
-
severity: 'warning',
|
|
1514
|
-
message: `Value at ${path} below minimum: ${data} < ${schema.minimum}`,
|
|
1515
|
-
rule: 'below-minimum',
|
|
1516
|
-
analyzer: 'schema'
|
|
1517
|
-
});
|
|
1518
|
-
}
|
|
1519
|
-
if (schema.maximum !== undefined && data > schema.maximum) {
|
|
1520
|
-
violations.push({
|
|
1521
|
-
file: filePath,
|
|
1522
|
-
line: 1,
|
|
1523
|
-
column: 1,
|
|
1524
|
-
severity: 'warning',
|
|
1525
|
-
message: `Value at ${path} above maximum: ${data} > ${schema.maximum}`,
|
|
1526
|
-
rule: 'above-maximum',
|
|
1527
|
-
analyzer: 'schema'
|
|
1528
|
-
});
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
if (schema.type === 'array' && Array.isArray(data)) {
|
|
1532
|
-
if (schema.minItems !== undefined && data.length < schema.minItems) {
|
|
1533
|
-
violations.push({
|
|
1534
|
-
file: filePath,
|
|
1535
|
-
line: 1,
|
|
1536
|
-
column: 1,
|
|
1537
|
-
severity: 'warning',
|
|
1538
|
-
message: `Array at ${path} has too few items: ${data.length} < ${schema.minItems}`,
|
|
1539
|
-
rule: 'too-few-items',
|
|
1540
|
-
analyzer: 'schema'
|
|
1541
|
-
});
|
|
1542
|
-
}
|
|
1543
|
-
if (schema.maxItems !== undefined && data.length > schema.maxItems) {
|
|
1544
|
-
violations.push({
|
|
1545
|
-
file: filePath,
|
|
1546
|
-
line: 1,
|
|
1547
|
-
column: 1,
|
|
1548
|
-
severity: 'warning',
|
|
1549
|
-
message: `Array at ${path} has too many items: ${data.length} > ${schema.maxItems}`,
|
|
1550
|
-
rule: 'too-many-items',
|
|
1551
|
-
analyzer: 'schema'
|
|
1552
|
-
});
|
|
1553
|
-
}
|
|
1554
|
-
if (schema.items) {
|
|
1555
|
-
data.forEach((item, index) => {
|
|
1556
|
-
this.validateAgainstSchema(item, schema.items, filePath, violations, config, `${path}[${index}]`);
|
|
1557
|
-
});
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
if (schema.type === 'object' && typeof data === 'object' && data !== null) {
|
|
1561
|
-
if (schema.required && Array.isArray(schema.required)) {
|
|
1562
|
-
for (const requiredField of schema.required) {
|
|
1563
|
-
if (!(requiredField in data)) {
|
|
1564
|
-
violations.push({
|
|
1565
|
-
file: filePath,
|
|
1566
|
-
line: 1,
|
|
1567
|
-
column: 1,
|
|
1568
|
-
severity: 'warning',
|
|
1569
|
-
message: `Missing required field "${requiredField}" at ${path}`,
|
|
1570
|
-
rule: 'missing-required-field',
|
|
1571
|
-
analyzer: 'schema'
|
|
1572
|
-
});
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
if (schema.properties) {
|
|
1577
|
-
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
1578
|
-
if (key in data) {
|
|
1579
|
-
this.validateAgainstSchema(data[key], propSchema, filePath, violations, config, path ? `${path}.${key}` : key);
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
if (schema.additionalProperties === false || (config.strictMode && !schema.additionalProperties)) {
|
|
1584
|
-
const definedKeys = new Set(Object.keys(schema.properties || {}));
|
|
1585
|
-
const actualKeys = Object.keys(data);
|
|
1586
|
-
for (const key of actualKeys) {
|
|
1587
|
-
if (!definedKeys.has(key)) {
|
|
1588
|
-
violations.push({
|
|
1589
|
-
file: filePath,
|
|
1590
|
-
line: 1,
|
|
1591
|
-
column: 1,
|
|
1592
|
-
severity: 'warning',
|
|
1593
|
-
message: `Unexpected property "${key}" at ${path}`,
|
|
1594
|
-
rule: 'unexpected-property',
|
|
1595
|
-
analyzer: 'schema'
|
|
1596
|
-
});
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
else if (typeof schema.additionalProperties === 'object') {
|
|
1601
|
-
const definedKeys = new Set(Object.keys(schema.properties || {}));
|
|
1602
|
-
for (const [key, value] of Object.entries(data)) {
|
|
1603
|
-
if (!definedKeys.has(key)) {
|
|
1604
|
-
this.validateAgainstSchema(value, schema.additionalProperties, filePath, violations, config, path ? `${path}.${key}` : key);
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
if (schema.enum && Array.isArray(schema.enum)) {
|
|
1610
|
-
if (!schema.enum.includes(data)) {
|
|
1611
|
-
violations.push({
|
|
1612
|
-
file: filePath,
|
|
1613
|
-
line: 1,
|
|
1614
|
-
column: 1,
|
|
1615
|
-
severity: 'warning',
|
|
1616
|
-
message: `Value at ${path} not in enum: ${JSON.stringify(data)}. Allowed: ${schema.enum.join(', ')}`,
|
|
1617
|
-
rule: 'enum-mismatch',
|
|
1618
|
-
analyzer: 'schema'
|
|
1619
|
-
});
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
240
|
+
if (config.validateQueryPatterns) {
|
|
241
|
+
violations.push(...checkQueryPatterns(ast, adapter, sourceCode, config));
|
|
1622
242
|
}
|
|
243
|
+
// Check for SQL injection patterns
|
|
244
|
+
violations.push(...checkSQLInjection(ast, adapter, sourceCode));
|
|
1623
245
|
}
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
*/
|
|
1627
|
-
function escapeRegex(s) {
|
|
1628
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1629
|
-
}
|
|
246
|
+
import { analyzeJsonSchemas } from './schema/jsonSchema.js';
|
|
247
|
+
export { analyzeJsonSchemas };
|
|
1630
248
|
//# sourceMappingURL=UniversalSchemaAnalyzer.js.map
|