@rehearsal-db/core 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -4
- package/COMPATIBILITY.md +41 -4
- package/README.md +8 -4
- package/dist/package.json +3 -1
- package/dist/src/cli/arguments.mjs +1 -0
- package/dist/src/cli/basic_command_handlers.mjs +4 -0
- package/dist/src/cli/editor_command_handlers.mjs +24 -0
- package/dist/src/project/configuration_contract.d.mts +1 -0
- package/dist/src/project/configuration_normalization.mjs +2 -1
- package/dist/src/project/configuration_rendering.mjs +3 -2
- package/dist/src/project/configuration_resolution.mjs +0 -2
- package/dist/src/project/project_detection.d.mts +2 -0
- package/dist/src/project/project_detection.mjs +1 -0
- package/dist/src/project/runtime_project_identity.d.mts +5 -0
- package/dist/src/project/runtime_project_identity.mjs +41 -0
- package/dist/src/project/setup.mjs +3 -1
- package/dist/src/runtime/migration_history.d.mts +5 -1
- package/dist/src/runtime/migration_history.mjs +24 -6
- package/dist/src/runtime/migration_impact.d.mts +125 -0
- package/dist/src/runtime/migration_impact.mjs +278 -0
- package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
- package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
- package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
- package/dist/src/runtime/migration_impact_plan.mjs +297 -0
- package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
- package/dist/src/runtime/migration_impact_queries.mjs +270 -0
- package/dist/src/runtime/migration_insights.d.mts +137 -0
- package/dist/src/runtime/migration_insights.mjs +143 -0
- package/dist/src/runtime/migration_insights_input.d.mts +13 -0
- package/dist/src/runtime/migration_insights_input.mjs +153 -0
- package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
- package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
- package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
- package/dist/src/runtime/migration_sql_scan.mjs +369 -0
- package/dist/src/runtime/plan.d.mts +2 -1
- package/dist/src/runtime/plan.mjs +4 -4
- package/dist/src/runtime/policy.d.mts +2 -0
- package/dist/src/runtime/policy.mjs +13 -1
- package/dist/src/runtime/privileges.d.mts +25 -0
- package/dist/src/runtime/privileges.mjs +198 -0
- package/dist/src/runtime/topology.d.mts +1 -1
- package/dist/src/runtime/topology.mjs +2 -2
- package/dist/src/shared/cancellation.mjs +1 -0
- package/dist/src/shared/held_path_ancestry.d.mts +7 -0
- package/dist/src/shared/held_path_ancestry.mjs +110 -0
- package/dist/src/shared/operation_guard.mjs +1 -0
- package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
- package/dist/src/targets/migration_impact_runtime.mjs +367 -0
- package/dist/src/targets/supabase.mjs +1 -0
- package/docs/README.md +20 -7
- package/docs/adapters.md +10 -0
- package/docs/architecture.md +75 -1
- package/docs/commands.md +169 -34
- package/docs/configuration.md +42 -9
- package/docs/getting-started.md +4 -4
- package/docs/index.md +1 -0
- package/docs/releasing.md +15 -11
- package/docs/roadmap.md +49 -9
- package/docs/runtime-lifecycle.md +15 -4
- package/docs/runtime-policies.md +145 -0
- package/docs/stability.md +8 -4
- package/docs/updating.md +73 -0
- package/docs/vscode.md +30 -19
- package/package.json +3 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { RehearsalConfigPathOptions } from "../project/configuration.mjs";
|
|
2
|
+
export declare const MIGRATION_INSIGHT_RULES: Readonly<{
|
|
3
|
+
drop_table: {
|
|
4
|
+
summary: string;
|
|
5
|
+
suggestion: string;
|
|
6
|
+
};
|
|
7
|
+
drop_column: {
|
|
8
|
+
summary: string;
|
|
9
|
+
suggestion: string;
|
|
10
|
+
};
|
|
11
|
+
truncate: {
|
|
12
|
+
summary: string;
|
|
13
|
+
suggestion: string;
|
|
14
|
+
};
|
|
15
|
+
broad_update: {
|
|
16
|
+
summary: string;
|
|
17
|
+
suggestion: string;
|
|
18
|
+
};
|
|
19
|
+
broad_delete: {
|
|
20
|
+
summary: string;
|
|
21
|
+
suggestion: string;
|
|
22
|
+
};
|
|
23
|
+
not_null: {
|
|
24
|
+
summary: string;
|
|
25
|
+
suggestion: string;
|
|
26
|
+
};
|
|
27
|
+
new_constraint: {
|
|
28
|
+
summary: string;
|
|
29
|
+
suggestion: string;
|
|
30
|
+
};
|
|
31
|
+
cascade: {
|
|
32
|
+
summary: string;
|
|
33
|
+
suggestion: string;
|
|
34
|
+
};
|
|
35
|
+
}>;
|
|
36
|
+
type Verdict = "static_warnings" | "no_detected_warning" | "unknown";
|
|
37
|
+
export declare const inspectMigrationInsights: (options?: RehearsalConfigPathOptions) => Promise<{
|
|
38
|
+
schemaVersion: 1;
|
|
39
|
+
inspectorVersion: string;
|
|
40
|
+
candidateSha256: string;
|
|
41
|
+
candidateCount: number;
|
|
42
|
+
verdict: Verdict;
|
|
43
|
+
summary: {
|
|
44
|
+
findingCount: number;
|
|
45
|
+
unknownCount: number;
|
|
46
|
+
};
|
|
47
|
+
targets: {
|
|
48
|
+
name: string;
|
|
49
|
+
provider: "postgresql" | "supabase";
|
|
50
|
+
baselineGenerationId: string;
|
|
51
|
+
candidateSha256: string;
|
|
52
|
+
entries: {
|
|
53
|
+
version: string;
|
|
54
|
+
filename: string;
|
|
55
|
+
sha256: string;
|
|
56
|
+
workspaceRelativePath: string | null;
|
|
57
|
+
verdict: Verdict;
|
|
58
|
+
coverage: "incomplete" | "recognized_lexical_rules";
|
|
59
|
+
unknownReasons: import("./migration_sql_lexer.mjs").ScanUnknownReason[];
|
|
60
|
+
findings: ({
|
|
61
|
+
rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
|
|
62
|
+
severity: "warning" | "danger";
|
|
63
|
+
line: number;
|
|
64
|
+
column: number;
|
|
65
|
+
summary: string;
|
|
66
|
+
suggestion: string;
|
|
67
|
+
} | {
|
|
68
|
+
rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
|
|
69
|
+
severity: "warning" | "danger";
|
|
70
|
+
line: number;
|
|
71
|
+
column: number;
|
|
72
|
+
summary: string;
|
|
73
|
+
suggestion: string;
|
|
74
|
+
} | {
|
|
75
|
+
rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
|
|
76
|
+
severity: "warning" | "danger";
|
|
77
|
+
line: number;
|
|
78
|
+
column: number;
|
|
79
|
+
summary: string;
|
|
80
|
+
suggestion: string;
|
|
81
|
+
} | {
|
|
82
|
+
rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
|
|
83
|
+
severity: "warning" | "danger";
|
|
84
|
+
line: number;
|
|
85
|
+
column: number;
|
|
86
|
+
summary: string;
|
|
87
|
+
suggestion: string;
|
|
88
|
+
} | {
|
|
89
|
+
rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
|
|
90
|
+
severity: "warning" | "danger";
|
|
91
|
+
line: number;
|
|
92
|
+
column: number;
|
|
93
|
+
summary: string;
|
|
94
|
+
suggestion: string;
|
|
95
|
+
} | {
|
|
96
|
+
rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
|
|
97
|
+
severity: "warning" | "danger";
|
|
98
|
+
line: number;
|
|
99
|
+
column: number;
|
|
100
|
+
summary: string;
|
|
101
|
+
suggestion: string;
|
|
102
|
+
} | {
|
|
103
|
+
rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
|
|
104
|
+
severity: "warning" | "danger";
|
|
105
|
+
line: number;
|
|
106
|
+
column: number;
|
|
107
|
+
summary: string;
|
|
108
|
+
suggestion: string;
|
|
109
|
+
} | {
|
|
110
|
+
rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
|
|
111
|
+
severity: "warning" | "danger";
|
|
112
|
+
line: number;
|
|
113
|
+
column: number;
|
|
114
|
+
summary: string;
|
|
115
|
+
suggestion: string;
|
|
116
|
+
})[];
|
|
117
|
+
}[];
|
|
118
|
+
}[];
|
|
119
|
+
limits: Readonly<{
|
|
120
|
+
maximumFiles: 10000;
|
|
121
|
+
maximumDirectoryEntries: 10000;
|
|
122
|
+
maximumCandidates: 1000;
|
|
123
|
+
maximumFileBytes: number;
|
|
124
|
+
maximumTotalBytes: number;
|
|
125
|
+
maximumFindings: 5000;
|
|
126
|
+
}>;
|
|
127
|
+
safety: {
|
|
128
|
+
readsSqlBodies: boolean;
|
|
129
|
+
executesSql: boolean;
|
|
130
|
+
startsEngine: boolean;
|
|
131
|
+
contactsDatabase: boolean;
|
|
132
|
+
includesSqlOrRowValues: boolean;
|
|
133
|
+
};
|
|
134
|
+
limitation: string;
|
|
135
|
+
}>;
|
|
136
|
+
export declare const renderMigrationInsights: (report: Awaited<ReturnType<typeof inspectMigrationInsights>>) => string;
|
|
137
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** Static warnings bound to the existing complete candidate plan, never a safety proof. */
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { REHEARSAL_VERSION, RehearsalError } from "../shared/diagnostics.mjs";
|
|
5
|
+
import { isMigrationWorkspacePath } from "./migration_failure_source.mjs";
|
|
6
|
+
import { createMigrationInsightsInput, MIGRATION_INSIGHTS_LIMITS, } from "./migration_insights_input.mjs";
|
|
7
|
+
import { scanMigrationSql } from "./migration_sql_scan.mjs";
|
|
8
|
+
import { inspectRehearsalMigrations } from "./plan.mjs";
|
|
9
|
+
import { inspectRuntimeTopologyCandidates } from "./topology.mjs";
|
|
10
|
+
export const MIGRATION_INSIGHT_RULES = Object.freeze({
|
|
11
|
+
drop_table: {
|
|
12
|
+
summary: "A table may be removed.",
|
|
13
|
+
suggestion: "Review dependent objects and preserve any required rows before dropping the table.",
|
|
14
|
+
},
|
|
15
|
+
drop_column: {
|
|
16
|
+
summary: "A column and its stored values may be removed.",
|
|
17
|
+
suggestion: "Review populated values and dependent application code before dropping the column.",
|
|
18
|
+
},
|
|
19
|
+
truncate: {
|
|
20
|
+
summary: "All rows in the selected table may be removed.",
|
|
21
|
+
suggestion: "Confirm the intended data loss and review identity, foreign-key and cascade behavior.",
|
|
22
|
+
},
|
|
23
|
+
broad_update: {
|
|
24
|
+
summary: "An UPDATE has no top-level WHERE clause.",
|
|
25
|
+
suggestion: "Review the intended row scope; a nested WHERE does not restrict the outer UPDATE.",
|
|
26
|
+
},
|
|
27
|
+
broad_delete: {
|
|
28
|
+
summary: "A DELETE has no top-level WHERE clause.",
|
|
29
|
+
suggestion: "Review the intended row scope; a nested WHERE does not restrict the outer DELETE.",
|
|
30
|
+
},
|
|
31
|
+
not_null: {
|
|
32
|
+
summary: "A NOT NULL requirement may reject existing rows.",
|
|
33
|
+
suggestion: "Check existing NULL values and apply a reviewed backfill before enforcing the requirement.",
|
|
34
|
+
},
|
|
35
|
+
new_constraint: {
|
|
36
|
+
summary: "A new constraint may reject existing rows.",
|
|
37
|
+
suggestion: "Check NULL, duplicate, orphan and CHECK violations as appropriate; SQL text alone cannot establish compliance.",
|
|
38
|
+
},
|
|
39
|
+
cascade: {
|
|
40
|
+
summary: "CASCADE can affect dependent objects or rows.",
|
|
41
|
+
suggestion: "Review the dependency graph; this static warning does not measure the cascade's actual reach.",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const verdict = (complete, warnings) => !complete ? "unknown" : warnings ? "static_warnings" : "no_detected_warning";
|
|
45
|
+
const inspectionError = (message) => new RehearsalError({
|
|
46
|
+
category: "configuration_invalid",
|
|
47
|
+
code: "CONFIGURATION_INVALID",
|
|
48
|
+
message,
|
|
49
|
+
refused: "No SQL was executed and no database or engine was contacted.",
|
|
50
|
+
suggestions: [
|
|
51
|
+
"Review the documented input limits and request a fresh complete migration inspection.",
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
export const inspectMigrationInsights = async (options = {}) => {
|
|
55
|
+
const projectRoot = await realpath(resolve(options.projectRoot ?? process.cwd()));
|
|
56
|
+
const input = createMigrationInsightsInput(projectRoot);
|
|
57
|
+
const candidates = await inspectRuntimeTopologyCandidates({ ...options, projectRoot }, (selected) => inspectRehearsalMigrations(selected, input.readInventory));
|
|
58
|
+
if (candidates.candidateCount > MIGRATION_INSIGHTS_LIMITS.maximumCandidates)
|
|
59
|
+
throw inspectionError("Migration Insights exceeds its bounded 1,000-candidate plan.");
|
|
60
|
+
let findingCount = 0;
|
|
61
|
+
let unknownCount = 0;
|
|
62
|
+
const targets = candidates.targets.map((target) => {
|
|
63
|
+
const entries = target.summary.candidates.map((candidate) => {
|
|
64
|
+
const path = join(target.paths.migrationDirectory, candidate.filename);
|
|
65
|
+
const source = input.sourceFor(path, candidate.sha256);
|
|
66
|
+
if (source === undefined)
|
|
67
|
+
throw inspectionError("Migration Insights could not retain its exact reviewed input. Refresh the plan.");
|
|
68
|
+
const scan = scanMigrationSql(source);
|
|
69
|
+
const remaining = MIGRATION_INSIGHTS_LIMITS.maximumFindings - findingCount;
|
|
70
|
+
const limited = scan.findings.length > remaining;
|
|
71
|
+
const findings = scan.findings
|
|
72
|
+
.slice(0, Math.max(0, remaining))
|
|
73
|
+
.map((finding) => ({
|
|
74
|
+
...finding,
|
|
75
|
+
...MIGRATION_INSIGHT_RULES[finding.rule],
|
|
76
|
+
}));
|
|
77
|
+
findingCount += findings.length;
|
|
78
|
+
const complete = scan.complete && !limited;
|
|
79
|
+
if (!complete)
|
|
80
|
+
unknownCount++;
|
|
81
|
+
const workspacePath = relative(projectRoot, path).split(sep).join("/");
|
|
82
|
+
return {
|
|
83
|
+
version: candidate.version,
|
|
84
|
+
filename: candidate.filename,
|
|
85
|
+
sha256: candidate.sha256,
|
|
86
|
+
workspaceRelativePath: isMigrationWorkspacePath(workspacePath)
|
|
87
|
+
? workspacePath
|
|
88
|
+
: null,
|
|
89
|
+
verdict: verdict(complete, findings.length),
|
|
90
|
+
coverage: complete
|
|
91
|
+
? "recognized_lexical_rules"
|
|
92
|
+
: "incomplete",
|
|
93
|
+
unknownReasons: [
|
|
94
|
+
...new Set([
|
|
95
|
+
...scan.unknownReasons,
|
|
96
|
+
...(limited ? ["input_limit"] : []),
|
|
97
|
+
]),
|
|
98
|
+
],
|
|
99
|
+
findings,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
name: target.name,
|
|
104
|
+
provider: target.config.runtime.target,
|
|
105
|
+
baselineGenerationId: target.summary.baselineGenerationId,
|
|
106
|
+
candidateSha256: target.summary.candidateSha256,
|
|
107
|
+
entries,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
schemaVersion: 1,
|
|
112
|
+
inspectorVersion: REHEARSAL_VERSION,
|
|
113
|
+
candidateSha256: candidates.candidateSha256,
|
|
114
|
+
candidateCount: candidates.candidateCount,
|
|
115
|
+
verdict: verdict(unknownCount === 0, findingCount),
|
|
116
|
+
summary: { findingCount, unknownCount },
|
|
117
|
+
targets,
|
|
118
|
+
limits: MIGRATION_INSIGHTS_LIMITS,
|
|
119
|
+
safety: {
|
|
120
|
+
readsSqlBodies: true,
|
|
121
|
+
executesSql: false,
|
|
122
|
+
startsEngine: false,
|
|
123
|
+
contactsDatabase: false,
|
|
124
|
+
includesSqlOrRowValues: false,
|
|
125
|
+
},
|
|
126
|
+
limitation: "Static lexical warnings only. No detected warning is not a safety or syntax-validity verdict; data impact, permissions, locks and production behavior are not measured.",
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
export const renderMigrationInsights = (report) => [
|
|
130
|
+
"MIGRATION INSIGHTS — STATIC, READ ONLY",
|
|
131
|
+
`${report.candidateCount} pending candidates · ${report.summary.findingCount} warnings · ${report.summary.unknownCount} incomplete analyses`,
|
|
132
|
+
`Exact candidate digest: ${report.candidateSha256}`,
|
|
133
|
+
...report.targets.flatMap((target) => target.entries.flatMap((entry) => [
|
|
134
|
+
`${target.name}/${entry.filename}: ${entry.verdict.replaceAll("_", " ")}`,
|
|
135
|
+
...entry.findings.map((finding) => ` ${finding.line}:${finding.column} ${finding.rule}: ${finding.summary} ${finding.suggestion}`),
|
|
136
|
+
...(entry.unknownReasons.length
|
|
137
|
+
? [
|
|
138
|
+
" Unsupported, malformed or bounded input remains unknown; review this file manually.",
|
|
139
|
+
]
|
|
140
|
+
: []),
|
|
141
|
+
])),
|
|
142
|
+
report.limitation,
|
|
143
|
+
].join("\n");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
2
|
+
export declare const MIGRATION_INSIGHTS_LIMITS: Readonly<{
|
|
3
|
+
maximumFiles: 10000;
|
|
4
|
+
maximumDirectoryEntries: 10000;
|
|
5
|
+
maximumCandidates: 1000;
|
|
6
|
+
maximumFileBytes: number;
|
|
7
|
+
maximumTotalBytes: number;
|
|
8
|
+
maximumFindings: 5000;
|
|
9
|
+
}>;
|
|
10
|
+
export declare const createMigrationInsightsInput: (projectRoot: string) => {
|
|
11
|
+
readInventory: typeof readMigrationFileInventory;
|
|
12
|
+
sourceFor: (path: string, sha256: string) => string | undefined;
|
|
13
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Exact, bounded project-local SQL input. Contents never enter public findings. */
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
8
|
+
import { RehearsalError } from "../shared/diagnostics.mjs";
|
|
9
|
+
import { holdPathAncestry, } from "../shared/held_path_ancestry.mjs";
|
|
10
|
+
export const MIGRATION_INSIGHTS_LIMITS = Object.freeze({
|
|
11
|
+
maximumFiles: 10000,
|
|
12
|
+
maximumDirectoryEntries: 10000,
|
|
13
|
+
maximumCandidates: 1000,
|
|
14
|
+
maximumFileBytes: 2 * 1024 * 1024,
|
|
15
|
+
maximumTotalBytes: 16 * 1024 * 1024,
|
|
16
|
+
maximumFindings: 5000,
|
|
17
|
+
});
|
|
18
|
+
const inside = (root, path) => {
|
|
19
|
+
const value = relative(root, path);
|
|
20
|
+
return !value.startsWith("..") && !isAbsolute(value);
|
|
21
|
+
};
|
|
22
|
+
const inputError = () => new RehearsalError({
|
|
23
|
+
category: "configuration_invalid",
|
|
24
|
+
code: "CONFIGURATION_INVALID",
|
|
25
|
+
message: "Migration Insights requires bounded, unchanged regular SQL files inside the project; symlinks and unsafe filenames are refused.",
|
|
26
|
+
refused: "No SQL was executed and no database or engine was contacted.",
|
|
27
|
+
suggestions: [
|
|
28
|
+
"Review project-local migration files and the documented Insights input limits, then request a fresh inspection.",
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
export const createMigrationInsightsInput = (projectRoot) => {
|
|
32
|
+
const contents = new Map();
|
|
33
|
+
let totalBytes = 0;
|
|
34
|
+
let totalFiles = 0;
|
|
35
|
+
const readInventory = async (directory) => {
|
|
36
|
+
let ancestry;
|
|
37
|
+
try {
|
|
38
|
+
const lexicalRoot = resolve(projectRoot);
|
|
39
|
+
const lexicalDirectory = resolve(fileURLToPath(directory));
|
|
40
|
+
if (!inside(lexicalRoot, lexicalDirectory))
|
|
41
|
+
throw inputError();
|
|
42
|
+
const root = await realpath(lexicalRoot);
|
|
43
|
+
const canonicalDirectory = await realpath(lexicalDirectory);
|
|
44
|
+
if (canonicalDirectory !==
|
|
45
|
+
resolve(root, relative(lexicalRoot, lexicalDirectory)))
|
|
46
|
+
throw inputError();
|
|
47
|
+
const held = await holdPathAncestry(canonicalDirectory);
|
|
48
|
+
ancestry = held;
|
|
49
|
+
await held.assertUnchanged();
|
|
50
|
+
const parentBefore = await lstat(canonicalDirectory);
|
|
51
|
+
if (!parentBefore.isDirectory() || parentBefore.isSymbolicLink())
|
|
52
|
+
throw inputError();
|
|
53
|
+
const inventory = await readMigrationFileInventory(pathToFileURL(`${held.path}${sep}`), {
|
|
54
|
+
maximumFiles: MIGRATION_INSIGHTS_LIMITS.maximumFiles,
|
|
55
|
+
maximumDirectoryEntries: MIGRATION_INSIGHTS_LIMITS.maximumDirectoryEntries,
|
|
56
|
+
readBytes: async (url, filename) => {
|
|
57
|
+
if (++totalFiles > MIGRATION_INSIGHTS_LIMITS.maximumFiles ||
|
|
58
|
+
Buffer.byteLength(filename) > 255 ||
|
|
59
|
+
/[\\/%:#?\u0000-\u001f\u007f-\u009f\u2028\u2029\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(filename))
|
|
60
|
+
throw inputError();
|
|
61
|
+
const suppliedPath = fileURLToPath(url);
|
|
62
|
+
if (suppliedPath !== join(held.path, filename) ||
|
|
63
|
+
basename(suppliedPath) !== filename)
|
|
64
|
+
throw inputError();
|
|
65
|
+
// Read through the canonical directory, never a caller's root alias.
|
|
66
|
+
const path = join(canonicalDirectory, filename);
|
|
67
|
+
try {
|
|
68
|
+
await held.assertUnchanged();
|
|
69
|
+
const leafBefore = await lstat(path);
|
|
70
|
+
if (!leafBefore.isFile() || leafBefore.isSymbolicLink())
|
|
71
|
+
throw inputError();
|
|
72
|
+
const handle = await open(path, constants.O_RDONLY |
|
|
73
|
+
constants.O_NOFOLLOW |
|
|
74
|
+
constants.O_NONBLOCK);
|
|
75
|
+
try {
|
|
76
|
+
const before = await handle.stat();
|
|
77
|
+
if (!before.isFile() ||
|
|
78
|
+
before.dev !== leafBefore.dev ||
|
|
79
|
+
before.ino !== leafBefore.ino ||
|
|
80
|
+
before.size > MIGRATION_INSIGHTS_LIMITS.maximumFileBytes ||
|
|
81
|
+
totalBytes + before.size >
|
|
82
|
+
MIGRATION_INSIGHTS_LIMITS.maximumTotalBytes)
|
|
83
|
+
throw inputError();
|
|
84
|
+
const buffer = Buffer.alloc(before.size + 1);
|
|
85
|
+
let offset = 0;
|
|
86
|
+
while (offset < buffer.length) {
|
|
87
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
88
|
+
if (!bytesRead)
|
|
89
|
+
break;
|
|
90
|
+
offset += bytesRead;
|
|
91
|
+
}
|
|
92
|
+
const after = await handle.stat();
|
|
93
|
+
const leafAfter = await lstat(path);
|
|
94
|
+
const parentAfter = await lstat(canonicalDirectory);
|
|
95
|
+
if (offset !== before.size ||
|
|
96
|
+
before.dev !== after.dev ||
|
|
97
|
+
before.ino !== after.ino ||
|
|
98
|
+
before.size !== after.size ||
|
|
99
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
100
|
+
before.ctimeMs !== after.ctimeMs ||
|
|
101
|
+
!leafAfter.isFile() ||
|
|
102
|
+
leafAfter.isSymbolicLink() ||
|
|
103
|
+
before.dev !== leafAfter.dev ||
|
|
104
|
+
before.ino !== leafAfter.ino ||
|
|
105
|
+
before.ctimeMs !== leafAfter.ctimeMs ||
|
|
106
|
+
parentAfter.isSymbolicLink() ||
|
|
107
|
+
parentBefore.dev !== parentAfter.dev ||
|
|
108
|
+
parentBefore.ino !== parentAfter.ino ||
|
|
109
|
+
(await realpath(dirname(path))) !== canonicalDirectory)
|
|
110
|
+
throw inputError();
|
|
111
|
+
await held.assertUnchanged();
|
|
112
|
+
const bytes = buffer.subarray(0, offset);
|
|
113
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
114
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
115
|
+
const prior = contents.get(path);
|
|
116
|
+
if (prior && prior.sha256 !== sha256)
|
|
117
|
+
throw inputError();
|
|
118
|
+
totalBytes += offset;
|
|
119
|
+
contents.set(path, { sha256, text });
|
|
120
|
+
return bytes;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await handle.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw inputError();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
await held.assertUnchanged();
|
|
132
|
+
return inventory;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw inputError();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
try {
|
|
139
|
+
await ancestry?.close();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw inputError();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
readInventory,
|
|
148
|
+
sourceFor: (path, sha256) => {
|
|
149
|
+
const retained = contents.get(resolve(path));
|
|
150
|
+
return retained?.sha256 === sha256 ? retained.text : undefined;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Bounded PostgreSQL lexical input, not a SQL parser or validator. */
|
|
2
|
+
export type ScanUnknownReason = "unsupported_sql" | "malformed_sql" | "input_limit";
|
|
3
|
+
export interface SqlToken {
|
|
4
|
+
kind: "word" | "identifier" | "literal" | "symbol";
|
|
5
|
+
value: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
depth: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const lexMigrationSql: (source: string) => {
|
|
11
|
+
tokens: SqlToken[];
|
|
12
|
+
unknownReasons: ScanUnknownReason[];
|
|
13
|
+
ambiguousQuotes: boolean;
|
|
14
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const maxBytes = 2 * 1024 * 1024;
|
|
2
|
+
const maxTokens = 20_000;
|
|
3
|
+
const maxDepth = 128;
|
|
4
|
+
const wordStart = (character) => /[A-Za-z_]/u.test(character) || character.charCodeAt(0) >= 128;
|
|
5
|
+
const wordPart = (character) => wordStart(character) || /[0-9$]/u.test(character);
|
|
6
|
+
export const lexMigrationSql = (source) => {
|
|
7
|
+
const tokens = [];
|
|
8
|
+
const reasons = new Set();
|
|
9
|
+
if (Buffer.byteLength(source, "utf8") > maxBytes)
|
|
10
|
+
return { tokens, unknownReasons: ["input_limit"], ambiguousQuotes: false };
|
|
11
|
+
if (source.includes("\0"))
|
|
12
|
+
reasons.add("malformed_sql");
|
|
13
|
+
let ambiguousQuotes = false;
|
|
14
|
+
let offset = 0;
|
|
15
|
+
let line = 1;
|
|
16
|
+
let column = 1;
|
|
17
|
+
const groups = [];
|
|
18
|
+
const advance = (end) => {
|
|
19
|
+
while (offset < end) {
|
|
20
|
+
const character = source[offset++];
|
|
21
|
+
if (character === "\r") {
|
|
22
|
+
if (source[offset] === "\n" && offset < end)
|
|
23
|
+
offset += 1;
|
|
24
|
+
line += 1;
|
|
25
|
+
column = 1;
|
|
26
|
+
}
|
|
27
|
+
else if (character === "\n") {
|
|
28
|
+
line += 1;
|
|
29
|
+
column = 1;
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
column += 1;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const emit = (kind, value, end, depth = groups.length) => {
|
|
36
|
+
if (tokens.length >= maxTokens) {
|
|
37
|
+
reasons.add("input_limit");
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
tokens.push({ kind, value, line, column, depth });
|
|
41
|
+
advance(end);
|
|
42
|
+
return true;
|
|
43
|
+
};
|
|
44
|
+
const quoted = (start, quote, escapes) => {
|
|
45
|
+
let cursor = start + 1;
|
|
46
|
+
while (cursor < source.length) {
|
|
47
|
+
if (source[cursor] === "\0")
|
|
48
|
+
reasons.add("malformed_sql");
|
|
49
|
+
if (source[cursor] === "\\") {
|
|
50
|
+
// Plain strings depend on standard_conforming_strings, which is not read.
|
|
51
|
+
if (quote === "'" && !escapes) {
|
|
52
|
+
reasons.add("unsupported_sql");
|
|
53
|
+
ambiguousQuotes = true;
|
|
54
|
+
}
|
|
55
|
+
if (escapes) {
|
|
56
|
+
cursor += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (source[cursor] === quote) {
|
|
61
|
+
if (source[cursor + 1] === quote)
|
|
62
|
+
cursor += 2;
|
|
63
|
+
else
|
|
64
|
+
return cursor + 1;
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
cursor += 1;
|
|
68
|
+
}
|
|
69
|
+
reasons.add("malformed_sql");
|
|
70
|
+
return source.length;
|
|
71
|
+
};
|
|
72
|
+
while (offset < source.length) {
|
|
73
|
+
const character = source[offset];
|
|
74
|
+
if (/[ \t\n\r\f\v]/u.test(character)) {
|
|
75
|
+
// Consume CRLF together so editor positions count a single newline.
|
|
76
|
+
advance(offset + (character === "\r" && source[offset + 1] === "\n" ? 2 : 1));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (source.startsWith("--", offset)) {
|
|
80
|
+
let end = offset + 2;
|
|
81
|
+
while (end < source.length && !/[\r\n]/u.test(source[end]))
|
|
82
|
+
end += 1;
|
|
83
|
+
advance(end);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (source.startsWith("/*", offset)) {
|
|
87
|
+
let nested = 1;
|
|
88
|
+
let cursor = offset + 2;
|
|
89
|
+
while (cursor < source.length && nested > 0) {
|
|
90
|
+
if (source.startsWith("/*", cursor)) {
|
|
91
|
+
nested += 1;
|
|
92
|
+
cursor += 2;
|
|
93
|
+
if (nested > maxDepth)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
else if (source.startsWith("*/", cursor)) {
|
|
97
|
+
nested -= 1;
|
|
98
|
+
cursor += 2;
|
|
99
|
+
}
|
|
100
|
+
else
|
|
101
|
+
cursor += 1;
|
|
102
|
+
}
|
|
103
|
+
if (nested > maxDepth) {
|
|
104
|
+
reasons.add("input_limit");
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
if (nested > 0)
|
|
108
|
+
reasons.add("malformed_sql");
|
|
109
|
+
advance(cursor);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (source.startsWith("*/", offset) || character === "\0") {
|
|
113
|
+
reasons.add("malformed_sql");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
if ((character === "E" || character === "e") &&
|
|
117
|
+
source[offset + 1] === "'") {
|
|
118
|
+
if (!emit("literal", "", quoted(offset + 1, "'", true)))
|
|
119
|
+
break;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (character === "'" || character === '"') {
|
|
123
|
+
const end = quoted(offset, character, false);
|
|
124
|
+
if (character === '"' && end === offset + 2)
|
|
125
|
+
reasons.add("malformed_sql");
|
|
126
|
+
if (!emit(character === "'" ? "literal" : "identifier", "", end))
|
|
127
|
+
break;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (character === "$") {
|
|
131
|
+
let tagEnd = offset + 1;
|
|
132
|
+
if (wordStart(source[tagEnd] ?? "")) {
|
|
133
|
+
tagEnd += 1;
|
|
134
|
+
while (tagEnd < source.length &&
|
|
135
|
+
(wordStart(source[tagEnd]) || /[0-9]/u.test(source[tagEnd])))
|
|
136
|
+
tagEnd += 1;
|
|
137
|
+
}
|
|
138
|
+
if (source[tagEnd] === "$") {
|
|
139
|
+
const delimiter = source.slice(offset, tagEnd + 1);
|
|
140
|
+
const close = source.indexOf(delimiter, tagEnd + 1);
|
|
141
|
+
if (close < 0)
|
|
142
|
+
reasons.add("malformed_sql");
|
|
143
|
+
if (!emit("literal", "", close < 0 ? source.length : close + delimiter.length))
|
|
144
|
+
break;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let parameterEnd = offset + 1;
|
|
148
|
+
while (/[0-9]/u.test(source[parameterEnd] ?? ""))
|
|
149
|
+
parameterEnd += 1;
|
|
150
|
+
if (parameterEnd > offset + 1) {
|
|
151
|
+
if (!emit("literal", "", parameterEnd))
|
|
152
|
+
break;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
reasons.add("unsupported_sql");
|
|
156
|
+
}
|
|
157
|
+
if (wordStart(character)) {
|
|
158
|
+
let end = offset + 1;
|
|
159
|
+
while (end < source.length && wordPart(source[end]))
|
|
160
|
+
end += 1;
|
|
161
|
+
if (!emit("word", source.slice(offset, end).toUpperCase(), end))
|
|
162
|
+
break;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (/[0-9]/u.test(character)) {
|
|
166
|
+
let end = offset + 1;
|
|
167
|
+
while (end < source.length && /[0-9A-Za-z_.]/u.test(source[end]))
|
|
168
|
+
end += 1;
|
|
169
|
+
if (!emit("literal", "", end))
|
|
170
|
+
break;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (character === "(" || character === "[") {
|
|
174
|
+
if (groups.length >= maxDepth) {
|
|
175
|
+
reasons.add("input_limit");
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
if (!emit("symbol", character, offset + 1))
|
|
179
|
+
break;
|
|
180
|
+
groups.push(character);
|
|
181
|
+
}
|
|
182
|
+
else if (character === ")" || character === "]") {
|
|
183
|
+
if (groups.pop() !== (character === ")" ? "(" : "[")) {
|
|
184
|
+
reasons.add("malformed_sql");
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (!emit("symbol", character, offset + 1))
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
if (character === ";" && groups.length > 0)
|
|
192
|
+
reasons.add("malformed_sql");
|
|
193
|
+
if (character === "\\" ||
|
|
194
|
+
character === "`" ||
|
|
195
|
+
character.charCodeAt(0) < 32)
|
|
196
|
+
reasons.add("unsupported_sql");
|
|
197
|
+
if (!emit("symbol", character, offset + 1))
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (groups.length > 0)
|
|
202
|
+
reasons.add("malformed_sql");
|
|
203
|
+
return { tokens, unknownReasons: [...reasons], ambiguousQuotes };
|
|
204
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ScanUnknownReason } from "./migration_sql_lexer.mjs";
|
|
2
|
+
export type MigrationSqlRule = "drop_table" | "drop_column" | "truncate" | "broad_update" | "broad_delete" | "not_null" | "new_constraint" | "cascade";
|
|
3
|
+
export interface MigrationSqlFinding {
|
|
4
|
+
rule: MigrationSqlRule;
|
|
5
|
+
severity: "warning" | "danger";
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
}
|
|
9
|
+
export interface MigrationSqlScan {
|
|
10
|
+
/** Complete bounded lexical/rule coverage, never SQL validity or a safe verdict. */
|
|
11
|
+
complete: boolean;
|
|
12
|
+
findings: MigrationSqlFinding[];
|
|
13
|
+
unknownReasons: ScanUnknownReason[];
|
|
14
|
+
}
|
|
15
|
+
/** Static, local-only review hints. Never executes SQL or returns input text. */
|
|
16
|
+
export declare const scanMigrationSql: (source: string) => MigrationSqlScan;
|