locksmith-mcp 0.1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/analyzer/astUtils.d.ts +19 -0
- package/dist/analyzer/astUtils.js +50 -0
- package/dist/analyzer/astUtils.js.map +1 -0
- package/dist/analyzer/engine.d.ts +11 -0
- package/dist/analyzer/engine.js +87 -0
- package/dist/analyzer/engine.js.map +1 -0
- package/dist/analyzer/parse.d.ts +7 -0
- package/dist/analyzer/parse.js +165 -0
- package/dist/analyzer/parse.js.map +1 -0
- package/dist/analyzer/rules/addCheckConstraintNoNotValid.d.ts +2 -0
- package/dist/analyzer/rules/addCheckConstraintNoNotValid.js +24 -0
- package/dist/analyzer/rules/addCheckConstraintNoNotValid.js.map +1 -0
- package/dist/analyzer/rules/addColumnNotNullNoDefault.d.ts +2 -0
- package/dist/analyzer/rules/addColumnNotNullNoDefault.js +42 -0
- package/dist/analyzer/rules/addColumnNotNullNoDefault.js.map +1 -0
- package/dist/analyzer/rules/addColumnVolatileDefault.d.ts +2 -0
- package/dist/analyzer/rules/addColumnVolatileDefault.js +36 -0
- package/dist/analyzer/rules/addColumnVolatileDefault.js.map +1 -0
- package/dist/analyzer/rules/addForeignKeyValidating.d.ts +2 -0
- package/dist/analyzer/rules/addForeignKeyValidating.js +26 -0
- package/dist/analyzer/rules/addForeignKeyValidating.js.map +1 -0
- package/dist/analyzer/rules/alterColumnType.d.ts +2 -0
- package/dist/analyzer/rules/alterColumnType.js +31 -0
- package/dist/analyzer/rules/alterColumnType.js.map +1 -0
- package/dist/analyzer/rules/createIndexNonConcurrent.d.ts +2 -0
- package/dist/analyzer/rules/createIndexNonConcurrent.js +24 -0
- package/dist/analyzer/rules/createIndexNonConcurrent.js.map +1 -0
- package/dist/analyzer/rules/dropColumnOrTable.d.ts +2 -0
- package/dist/analyzer/rules/dropColumnOrTable.js +31 -0
- package/dist/analyzer/rules/dropColumnOrTable.js.map +1 -0
- package/dist/analyzer/rules/index.d.ts +7 -0
- package/dist/analyzer/rules/index.js +28 -0
- package/dist/analyzer/rules/index.js.map +1 -0
- package/dist/analyzer/rules/indexConcurrentlyInTransaction.d.ts +2 -0
- package/dist/analyzer/rules/indexConcurrentlyInTransaction.js +23 -0
- package/dist/analyzer/rules/indexConcurrentlyInTransaction.js.map +1 -0
- package/dist/analyzer/rules/renameColumnOrTable.d.ts +2 -0
- package/dist/analyzer/rules/renameColumnOrTable.js +29 -0
- package/dist/analyzer/rules/renameColumnOrTable.js.map +1 -0
- package/dist/analyzer/rules/setNotNull.d.ts +2 -0
- package/dist/analyzer/rules/setNotNull.js +30 -0
- package/dist/analyzer/rules/setNotNull.js.map +1 -0
- package/dist/analyzer/suppress.d.ts +23 -0
- package/dist/analyzer/suppress.js +36 -0
- package/dist/analyzer/suppress.js.map +1 -0
- package/dist/analyzer/types.d.ts +93 -0
- package/dist/analyzer/types.js +2 -0
- package/dist/analyzer/types.js.map +1 -0
- package/dist/analyzer/verdict.d.ts +9 -0
- package/dist/analyzer/verdict.js +35 -0
- package/dist/analyzer/verdict.js.map +1 -0
- package/dist/data/lockMatrix.d.ts +17 -0
- package/dist/data/lockMatrix.js +65 -0
- package/dist/data/lockMatrix.js.map +1 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +52 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +128 -0
- package/dist/server.js.map +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christopher King
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Locksmith 🔒
|
|
2
|
+
|
|
3
|
+
**An MCP server that catches dangerous SQL migrations before they lock your database.**
|
|
4
|
+
|
|
5
|
+
Most migrations look harmless and then take down production: a plain `CREATE INDEX`
|
|
6
|
+
blocks every write for the length of the build; `ALTER COLUMN ... TYPE` rewrites the
|
|
7
|
+
whole table under `ACCESS EXCLUSIVE`; `SET NOT NULL` scans every row. Which operations
|
|
8
|
+
are safe — and the rewrite that makes the dangerous ones safe — is knowledge that
|
|
9
|
+
lives in senior engineers' heads.
|
|
10
|
+
|
|
11
|
+
Locksmith encodes that knowledge as a tool an LLM agent (or a human) can call. Give it
|
|
12
|
+
a PostgreSQL migration; it returns a **PASS / REVIEW / BLOCK** verdict, a finding for
|
|
13
|
+
each risky statement (which lock it takes, *why* that's dangerous), and a concrete safe
|
|
14
|
+
rewrite.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
🛑 BLOCK — do not ship as written
|
|
18
|
+
|
|
19
|
+
## 🛑 CREATE INDEX without CONCURRENTLY `create-index-non-concurrent` (line 5)
|
|
20
|
+
> `CREATE INDEX idx_users_email ON users (email)`
|
|
21
|
+
Problem: This index build will block all writes to the table until it completes.
|
|
22
|
+
Lock taken: SHARE (blocks writes)
|
|
23
|
+
Fix: Build the index with CREATE INDEX CONCURRENTLY, which does not block writes.
|
|
24
|
+
Suggested rewrite:
|
|
25
|
+
CREATE INDEX CONCURRENTLY idx_users_email ON users (email)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Why an MCP server?
|
|
29
|
+
|
|
30
|
+
An agent can often reason about lock semantics — but not *reproducibly*, and not in a
|
|
31
|
+
way you can test, audit, or trust to gate a deploy unsupervised. The same prompt may
|
|
32
|
+
approve a table-rewriting `ALTER` one run and flag it the next, or miss it entirely in a
|
|
33
|
+
long migration. Locksmith turns that probabilistic capability into a **deterministic,
|
|
34
|
+
tested** tool: the lock semantics were verified once (against the PostgreSQL docs and the
|
|
35
|
+
parser's real behavior, with a test suite pinning each rule) and now run identically every
|
|
36
|
+
time, returning the same verdict plus a paste-ready fix — so "review this migration" stops
|
|
37
|
+
being a guess.
|
|
38
|
+
|
|
39
|
+
## Capabilities
|
|
40
|
+
|
|
41
|
+
**Tools**
|
|
42
|
+
- `analyze_migration(sql, assumeLargeTables?)` → verdict + findings + safe rewrites
|
|
43
|
+
(both human-readable Markdown and validated structured output).
|
|
44
|
+
- `explain_lock(query)` → what a given Postgres lock mode blocks and what takes it.
|
|
45
|
+
|
|
46
|
+
**Resources**
|
|
47
|
+
- `locksmith://lock-matrix` — the PostgreSQL table-level lock compatibility matrix.
|
|
48
|
+
- `locksmith://rules` — the full rule catalog (id, severity, rationale) as JSON.
|
|
49
|
+
|
|
50
|
+
**Prompts**
|
|
51
|
+
- `review-migration` — analyze a migration and summarize the risk as a PR comment.
|
|
52
|
+
|
|
53
|
+
## Rule catalog
|
|
54
|
+
|
|
55
|
+
| Rule | Severity | What it catches |
|
|
56
|
+
|------|----------|-----------------|
|
|
57
|
+
| `create-index-non-concurrent` | critical | `CREATE INDEX` without `CONCURRENTLY` (blocks writes) |
|
|
58
|
+
| `index-concurrently-in-transaction` | critical | `CONCURRENTLY` inside `BEGIN/COMMIT` (Postgres rejects it) |
|
|
59
|
+
| `add-column-not-null-no-default` | critical | `ADD COLUMN NOT NULL` with no default (fails / rewrites) |
|
|
60
|
+
| `alter-column-type` | critical | `ALTER COLUMN ... TYPE` (full table rewrite) |
|
|
61
|
+
| `add-column-volatile-default` | warning | `ADD COLUMN ... DEFAULT now()` etc. (rewrites table) |
|
|
62
|
+
| `set-not-null` | warning | `SET NOT NULL` (full scan under exclusive lock) |
|
|
63
|
+
| `add-foreign-key-validating` | warning | `ADD FOREIGN KEY` without `NOT VALID` (locks both tables) |
|
|
64
|
+
| `add-check-constraint-no-not-valid` | warning | `ADD CHECK` without `NOT VALID` (full scan) |
|
|
65
|
+
| `drop-column-or-table` | warning | destructive + breaks deployed code |
|
|
66
|
+
| `rename-column-or-table` | warning | breaks running app code |
|
|
67
|
+
|
|
68
|
+
### Suppressing a rule
|
|
69
|
+
|
|
70
|
+
Acknowledge a deliberate risk inline, eslint-style:
|
|
71
|
+
|
|
72
|
+
```sql
|
|
73
|
+
-- locksmith:disable create-index-non-concurrent
|
|
74
|
+
CREATE INDEX idx_users_email ON users (email);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A bare `-- locksmith:disable` suppresses all rules for the next statement.
|
|
78
|
+
|
|
79
|
+
## Install & run
|
|
80
|
+
|
|
81
|
+
No clone or build required — run it straight from npm:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx locksmith-mcp
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Use with Claude Code
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
claude mcp add locksmith -- npx -y locksmith-mcp
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or add to any MCP client config (Claude Desktop, etc.):
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"locksmith": { "command": "npx", "args": ["-y", "locksmith-mcp"] }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Run from source instead
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
git clone https://github.com/cxk280/locksmith.git && cd locksmith
|
|
107
|
+
npm install && npm run build
|
|
108
|
+
# then point your client at: node /absolute/path/to/locksmith/dist/index.js
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Try it with the MCP Inspector
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx @modelcontextprotocol/inspector npx -y locksmith-mcp
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Then call `analyze_migration` with the contents of `examples/dangerous.sql`.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm run dev # run from source with tsx
|
|
123
|
+
npm test # vitest: per-rule + golden tests on examples/
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Design notes
|
|
127
|
+
|
|
128
|
+
- **Deterministic.** No clock, randomness, or network — same SQL in, same verdict out.
|
|
129
|
+
That's what makes it testable and safe to drop into CI.
|
|
130
|
+
- **Hybrid parsing.** Statements are parsed to an AST ([`pgsql-ast-parser`]) when the
|
|
131
|
+
parser supports them; rules fall back to normalized text for Postgres clauses the
|
|
132
|
+
parser doesn't model (`NOT VALID`, `CONCURRENTLY`). An unparseable statement degrades
|
|
133
|
+
to a "review manually" note — the linter never fails closed on input it doesn't
|
|
134
|
+
understand.
|
|
135
|
+
- **Composable rules.** Each rule is a pure function with its own metadata in its own
|
|
136
|
+
file; adding one is a one-file change plus a line in the registry.
|
|
137
|
+
- **Advisory, not a prover.** Locksmith complements review; `assumeLargeTables` defaults
|
|
138
|
+
to `true` so it errs toward flagging.
|
|
139
|
+
|
|
140
|
+
## Roadmap
|
|
141
|
+
|
|
142
|
+
- Remote (Streamable HTTP) transport for hosted deployments.
|
|
143
|
+
- Additional dialects (MySQL, SQLite).
|
|
144
|
+
- Optional live DB introspection to suppress findings on known-small tables.
|
|
145
|
+
- A GitHub Action wrapping the same engine to gate PRs.
|
|
146
|
+
|
|
147
|
+
[`pgsql-ast-parser`]: https://github.com/oguimbal/pgsql-ast-parser
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Stmt } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The pgsql-ast-parser type union is large and version-sensitive. Rules only
|
|
4
|
+
* need a few fields, so we traverse with small, defensively-typed helpers
|
|
5
|
+
* rather than importing the full node types. `any` is intentional and local.
|
|
6
|
+
*/
|
|
7
|
+
export type AnyAst = any;
|
|
8
|
+
export declare function astType(stmt: Stmt): string | undefined;
|
|
9
|
+
/** Extract a plain name from the parser's `{ name }` / string shapes. */
|
|
10
|
+
export declare function nameOf(x: AnyAst): string | undefined;
|
|
11
|
+
export declare function tableName(ast: AnyAst): string | undefined;
|
|
12
|
+
/** The list of changes inside an ALTER TABLE, or [] for anything else. */
|
|
13
|
+
export declare function alterChanges(ast: AnyAst): AnyAst[];
|
|
14
|
+
/**
|
|
15
|
+
* Is an ADD COLUMN default a compile-time constant? Constant defaults are
|
|
16
|
+
* cheap on PG11+ (stored as metadata); volatile defaults like now() force a
|
|
17
|
+
* full table rewrite.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isConstantDefault(defaultExpr: AnyAst): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function astType(stmt) {
|
|
2
|
+
return stmt.ast?.type;
|
|
3
|
+
}
|
|
4
|
+
/** Extract a plain name from the parser's `{ name }` / string shapes. */
|
|
5
|
+
export function nameOf(x) {
|
|
6
|
+
if (x == null)
|
|
7
|
+
return undefined;
|
|
8
|
+
if (typeof x === "string")
|
|
9
|
+
return x;
|
|
10
|
+
if (typeof x.name === "string")
|
|
11
|
+
return x.name;
|
|
12
|
+
if (x.name && typeof x.name.name === "string")
|
|
13
|
+
return x.name.name;
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
export function tableName(ast) {
|
|
17
|
+
return nameOf(ast?.table) ?? nameOf(ast?.name);
|
|
18
|
+
}
|
|
19
|
+
/** The list of changes inside an ALTER TABLE, or [] for anything else. */
|
|
20
|
+
export function alterChanges(ast) {
|
|
21
|
+
if (ast?.type !== "alter table")
|
|
22
|
+
return [];
|
|
23
|
+
const c = ast.changes;
|
|
24
|
+
if (Array.isArray(c))
|
|
25
|
+
return c;
|
|
26
|
+
return c ? [c] : [];
|
|
27
|
+
}
|
|
28
|
+
const LITERAL_DEFAULT_TYPES = new Set([
|
|
29
|
+
"integer",
|
|
30
|
+
"numeric",
|
|
31
|
+
"string",
|
|
32
|
+
"boolean",
|
|
33
|
+
"null",
|
|
34
|
+
"constant",
|
|
35
|
+
]);
|
|
36
|
+
/**
|
|
37
|
+
* Is an ADD COLUMN default a compile-time constant? Constant defaults are
|
|
38
|
+
* cheap on PG11+ (stored as metadata); volatile defaults like now() force a
|
|
39
|
+
* full table rewrite.
|
|
40
|
+
*/
|
|
41
|
+
export function isConstantDefault(defaultExpr) {
|
|
42
|
+
if (defaultExpr == null)
|
|
43
|
+
return false;
|
|
44
|
+
// Negative literals parse as a unary op around a literal — still constant.
|
|
45
|
+
if (defaultExpr.type === "unary" && defaultExpr.operand) {
|
|
46
|
+
return isConstantDefault(defaultExpr.operand);
|
|
47
|
+
}
|
|
48
|
+
return LITERAL_DEFAULT_TYPES.has(defaultExpr.type);
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=astUtils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"astUtils.js","sourceRoot":"","sources":["../../src/analyzer/astUtils.ts"],"names":[],"mappings":"AASA,MAAM,UAAU,OAAO,CAAC,IAAU;IAChC,OAAQ,IAAI,CAAC,GAAc,EAAE,IAAI,CAAC;AACpC,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,MAAM,CAAC,CAAS;IAC9B,IAAI,CAAC,IAAI,IAAI;QAAE,OAAO,SAAS,CAAC;IAChC,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC;IACpC,IAAI,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC,IAAI,CAAC;IAC9C,IAAI,CAAC,CAAC,IAAI,IAAI,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;IAClE,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,GAAW;IACnC,OAAO,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;AACjD,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,IAAI,GAAG,EAAE,IAAI,KAAK,aAAa;QAAE,OAAO,EAAE,CAAC;IAC3C,MAAM,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC;IACtB,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACtB,CAAC;AAED,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAC;IACpC,SAAS;IACT,SAAS;IACT,QAAQ;IACR,SAAS;IACT,MAAM;IACN,UAAU;CACX,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,WAAW,IAAI,IAAI;QAAE,OAAO,KAAK,CAAC;IACtC,2EAA2E;IAC3E,IAAI,WAAW,CAAC,IAAI,KAAK,OAAO,IAAI,WAAW,CAAC,OAAO,EAAE,CAAC;QACxD,OAAO,iBAAiB,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,qBAAqB,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;AACrD,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AnalysisResult } from "./types.js";
|
|
2
|
+
export interface AnalyzeOptions {
|
|
3
|
+
/** Treat target tables as large/hot (default true → fail safe). */
|
|
4
|
+
assumeLargeTables?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Analyze a migration script and produce a verdict + findings. Pure and
|
|
8
|
+
* deterministic: same SQL in, same result out — no clock, no randomness, no
|
|
9
|
+
* network. That's what makes it trivially testable and safe to run in CI.
|
|
10
|
+
*/
|
|
11
|
+
export declare function analyzeMigration(sql: string, opts?: AnalyzeOptions): AnalysisResult;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { parseMigration } from "./parse.js";
|
|
2
|
+
import { parseDirectives, isSuppressed } from "./suppress.js";
|
|
3
|
+
import { rules } from "./rules/index.js";
|
|
4
|
+
import { computeVerdict, summarize } from "./verdict.js";
|
|
5
|
+
const TXN_BEGIN = /^(begin|start\s+transaction)\b/i;
|
|
6
|
+
const TXN_END = /^(commit|end|rollback)\b/i;
|
|
7
|
+
/**
|
|
8
|
+
* Analyze a migration script and produce a verdict + findings. Pure and
|
|
9
|
+
* deterministic: same SQL in, same result out — no clock, no randomness, no
|
|
10
|
+
* network. That's what makes it trivially testable and safe to run in CI.
|
|
11
|
+
*/
|
|
12
|
+
export function analyzeMigration(sql, opts = {}) {
|
|
13
|
+
const assumeLargeTables = opts.assumeLargeTables ?? true;
|
|
14
|
+
const statements = parseMigration(sql);
|
|
15
|
+
const directives = parseDirectives(sql);
|
|
16
|
+
const findings = [];
|
|
17
|
+
let suppressed = 0;
|
|
18
|
+
let unparsed = 0;
|
|
19
|
+
let inTxn = false;
|
|
20
|
+
let prevEndLine = 0;
|
|
21
|
+
for (const stmt of statements) {
|
|
22
|
+
if (stmt.ast === null)
|
|
23
|
+
unparsed++;
|
|
24
|
+
const ctx = { assumeLargeTables, inExplicitTransaction: inTxn };
|
|
25
|
+
for (const rule of rules) {
|
|
26
|
+
const hit = safeCheck(rule.id, () => rule.check(stmt, ctx));
|
|
27
|
+
if (!hit)
|
|
28
|
+
continue;
|
|
29
|
+
// A directive on the statement or in its leading comment block suppresses it.
|
|
30
|
+
if (isSuppressed(rule.id, directives, prevEndLine + 1, stmt.endLine)) {
|
|
31
|
+
suppressed++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
findings.push({
|
|
35
|
+
ruleId: rule.id,
|
|
36
|
+
title: rule.title,
|
|
37
|
+
severity: rule.severity,
|
|
38
|
+
line: stmt.startLine,
|
|
39
|
+
statement: truncate(stmt.normalized),
|
|
40
|
+
lockTaken: hit.lockTaken,
|
|
41
|
+
message: hit.message,
|
|
42
|
+
rationale: rule.rationale,
|
|
43
|
+
remediation: rule.remediation,
|
|
44
|
+
suggestedRewrite: hit.suggestedRewrite,
|
|
45
|
+
docsUrl: rule.docsUrl,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Update transaction state for the *next* statement.
|
|
49
|
+
if (TXN_BEGIN.test(stmt.lower))
|
|
50
|
+
inTxn = true;
|
|
51
|
+
else if (TXN_END.test(stmt.lower))
|
|
52
|
+
inTxn = false;
|
|
53
|
+
prevEndLine = stmt.endLine;
|
|
54
|
+
}
|
|
55
|
+
// Stable, predictable ordering: by line, then severity (critical first).
|
|
56
|
+
const sevRank = { critical: 0, warning: 1, info: 2 };
|
|
57
|
+
findings.sort((a, b) => a.line - b.line || sevRank[a.severity] - sevRank[b.severity]);
|
|
58
|
+
const verdict = computeVerdict(findings);
|
|
59
|
+
return {
|
|
60
|
+
verdict,
|
|
61
|
+
summary: summarize(verdict, findings),
|
|
62
|
+
stats: {
|
|
63
|
+
statements: statements.length,
|
|
64
|
+
findings: findings.length,
|
|
65
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
66
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
67
|
+
info: findings.filter((f) => f.severity === "info").length,
|
|
68
|
+
suppressed,
|
|
69
|
+
unparsed,
|
|
70
|
+
},
|
|
71
|
+
findings,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** A buggy rule must never crash the whole analysis. */
|
|
75
|
+
function safeCheck(ruleId, fn) {
|
|
76
|
+
try {
|
|
77
|
+
return fn();
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
process.stderr.write(`[locksmith] rule "${ruleId}" threw: ${String(err)}\n`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function truncate(s, max = 200) {
|
|
85
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/analyzer/engine.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Stmt } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Split a migration script into Stmt records. Each statement is parsed
|
|
4
|
+
* individually so a single unsupported statement degrades to `ast: null`
|
|
5
|
+
* (still text-scannable by rules) instead of failing the whole file.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseMigration(sql: string): Stmt[];
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { parse } from "pgsql-ast-parser";
|
|
2
|
+
function splitStatements(sql) {
|
|
3
|
+
const out = [];
|
|
4
|
+
let buf = ""; // raw chars of the current statement
|
|
5
|
+
let stripped = ""; // same, minus comments
|
|
6
|
+
let line = 1;
|
|
7
|
+
let startLine = 1;
|
|
8
|
+
let started = false;
|
|
9
|
+
let i = 0;
|
|
10
|
+
const n = sql.length;
|
|
11
|
+
const pushStatement = (endLine) => {
|
|
12
|
+
const raw = buf.trim();
|
|
13
|
+
if (raw.length > 0) {
|
|
14
|
+
out.push({
|
|
15
|
+
raw,
|
|
16
|
+
stripped: stripped.replace(/\s+/g, " ").trim(),
|
|
17
|
+
startLine,
|
|
18
|
+
endLine,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
buf = "";
|
|
22
|
+
stripped = "";
|
|
23
|
+
started = false;
|
|
24
|
+
};
|
|
25
|
+
while (i < n) {
|
|
26
|
+
const ch = sql[i];
|
|
27
|
+
const next = sql[i + 1];
|
|
28
|
+
// Track the line where the next statement's content begins.
|
|
29
|
+
if (!started && !/\s/.test(ch)) {
|
|
30
|
+
started = true;
|
|
31
|
+
startLine = line;
|
|
32
|
+
}
|
|
33
|
+
// -- line comment
|
|
34
|
+
if (ch === "-" && next === "-") {
|
|
35
|
+
buf += "--";
|
|
36
|
+
i += 2;
|
|
37
|
+
while (i < n && sql[i] !== "\n") {
|
|
38
|
+
buf += sql[i];
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// /* block comment */ (Postgres allows nesting)
|
|
44
|
+
if (ch === "/" && next === "*") {
|
|
45
|
+
let depth = 1;
|
|
46
|
+
buf += "/*";
|
|
47
|
+
i += 2;
|
|
48
|
+
while (i < n && depth > 0) {
|
|
49
|
+
if (sql[i] === "/" && sql[i + 1] === "*") {
|
|
50
|
+
depth++;
|
|
51
|
+
buf += "/*";
|
|
52
|
+
i += 2;
|
|
53
|
+
}
|
|
54
|
+
else if (sql[i] === "*" && sql[i + 1] === "/") {
|
|
55
|
+
depth--;
|
|
56
|
+
buf += "*/";
|
|
57
|
+
i += 2;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
if (sql[i] === "\n")
|
|
61
|
+
line++;
|
|
62
|
+
buf += sql[i];
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// '...' string literal (doubled '' escapes)
|
|
69
|
+
if (ch === "'") {
|
|
70
|
+
buf += ch;
|
|
71
|
+
stripped += ch;
|
|
72
|
+
i++;
|
|
73
|
+
while (i < n) {
|
|
74
|
+
if (sql[i] === "'" && sql[i + 1] === "'") {
|
|
75
|
+
buf += "''";
|
|
76
|
+
stripped += "''";
|
|
77
|
+
i += 2;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (sql[i] === "\n")
|
|
81
|
+
line++;
|
|
82
|
+
buf += sql[i];
|
|
83
|
+
stripped += sql[i];
|
|
84
|
+
if (sql[i] === "'") {
|
|
85
|
+
i++;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// $tag$ dollar-quoted string $tag$
|
|
93
|
+
const dollar = matchDollarTag(sql, i);
|
|
94
|
+
if (dollar) {
|
|
95
|
+
const tag = dollar;
|
|
96
|
+
buf += tag;
|
|
97
|
+
stripped += tag;
|
|
98
|
+
i += tag.length;
|
|
99
|
+
// consume until the same tag closes the literal
|
|
100
|
+
while (i < n) {
|
|
101
|
+
if (sql.startsWith(tag, i)) {
|
|
102
|
+
buf += tag;
|
|
103
|
+
stripped += tag;
|
|
104
|
+
i += tag.length;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
if (sql[i] === "\n")
|
|
108
|
+
line++;
|
|
109
|
+
buf += sql[i];
|
|
110
|
+
stripped += sql[i];
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (ch === ";") {
|
|
116
|
+
buf += ch;
|
|
117
|
+
pushStatement(line);
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ch === "\n")
|
|
122
|
+
line++;
|
|
123
|
+
buf += ch;
|
|
124
|
+
stripped += ch;
|
|
125
|
+
i++;
|
|
126
|
+
}
|
|
127
|
+
// trailing statement without a closing semicolon
|
|
128
|
+
pushStatement(line);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** If position `i` begins a `$tag$` dollar-quote opener, return that opener. */
|
|
132
|
+
function matchDollarTag(sql, i) {
|
|
133
|
+
if (sql[i] !== "$")
|
|
134
|
+
return null;
|
|
135
|
+
const m = /^\$[A-Za-z_]?[A-Za-z0-9_]*\$/.exec(sql.slice(i));
|
|
136
|
+
return m ? m[0] : null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Split a migration script into Stmt records. Each statement is parsed
|
|
140
|
+
* individually so a single unsupported statement degrades to `ast: null`
|
|
141
|
+
* (still text-scannable by rules) instead of failing the whole file.
|
|
142
|
+
*/
|
|
143
|
+
export function parseMigration(sql) {
|
|
144
|
+
return splitStatements(sql).map((s) => {
|
|
145
|
+
let ast = null;
|
|
146
|
+
let parseError;
|
|
147
|
+
try {
|
|
148
|
+
const parsed = parse(s.raw);
|
|
149
|
+
ast = parsed.length > 0 ? parsed[0] : null;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
parseError = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
raw: s.raw,
|
|
156
|
+
normalized: s.stripped,
|
|
157
|
+
lower: s.stripped.toLowerCase(),
|
|
158
|
+
startLine: s.startLine,
|
|
159
|
+
endLine: s.endLine,
|
|
160
|
+
ast,
|
|
161
|
+
parseError,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=parse.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse.js","sourceRoot":"","sources":["../../src/analyzer/parse.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const CHECK_RE = /\badd\s+(?:constraint\s+\S+\s+)?check\s*\(/i;
|
|
2
|
+
export const addCheckConstraintNoNotValid = {
|
|
3
|
+
id: "add-check-constraint-no-not-valid",
|
|
4
|
+
title: "ADD CHECK constraint validates immediately",
|
|
5
|
+
severity: "warning",
|
|
6
|
+
rationale: "Adding a CHECK constraint scans every existing row to validate it while holding ACCESS EXCLUSIVE on the table, blocking reads and writes during the scan.",
|
|
7
|
+
remediation: "Add the constraint NOT VALID (instant — applies to new/changed rows), then VALIDATE CONSTRAINT separately to scan existing rows without blocking writes.",
|
|
8
|
+
docsUrl: "https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-DESC-ADD-TABLE-CONSTRAINT",
|
|
9
|
+
check(stmt) {
|
|
10
|
+
if (!stmt.lower.startsWith("alter table"))
|
|
11
|
+
return null;
|
|
12
|
+
if (!CHECK_RE.test(stmt.lower))
|
|
13
|
+
return null;
|
|
14
|
+
if (/\bnot\s+valid\b/i.test(stmt.lower))
|
|
15
|
+
return null;
|
|
16
|
+
const withNotValid = stmt.normalized.replace(/;?\s*$/, " NOT VALID;");
|
|
17
|
+
return {
|
|
18
|
+
message: "This CHECK constraint is validated immediately, scanning the table under a lock.",
|
|
19
|
+
lockTaken: "ACCESS EXCLUSIVE",
|
|
20
|
+
suggestedRewrite: `${withNotValid}\n-- then, separately:\n-- ALTER TABLE <table> VALIDATE CONSTRAINT <name>;`,
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=addCheckConstraintNoNotValid.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"addCheckConstraintNoNotValid.js","sourceRoot":"","sources":["../../../src/analyzer/rules/addCheckConstraintNoNotValid.ts"],"names":[],"mappings":"AAEA,MAAM,QAAQ,GAAG,6CAA6C,CAAC;AAE/D,MAAM,CAAC,MAAM,4BAA4B,GAAS;IAChD,EAAE,EAAE,mCAAmC;IACvC,KAAK,EAAE,4CAA4C;IACnD,QAAQ,EAAE,SAAS;IACnB,SAAS,EACP,2JAA2J;IAC7J,WAAW,EACT,0JAA0J;IAC5J,OAAO,EAAE,sGAAsG;IAC/G,KAAK,CAAC,IAAI;QACR,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,aAAa,CAAC;YAAE,OAAO,IAAI,CAAC;QACvD,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAC5C,IAAI,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAErD,MAAM,YAAY,GAAG,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC;QACtE,OAAO;YACL,OAAO,EAAE,kFAAkF;YAC3F,SAAS,EAAE,kBAAkB;YAC7B,gBAAgB,EAAE,GAAG,YAAY,4EAA4E;SAC9G,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { alterChanges, tableName, nameOf } from "../astUtils.js";
|
|
2
|
+
function constraintTypes(change) {
|
|
3
|
+
const cons = change?.column?.constraints;
|
|
4
|
+
return Array.isArray(cons) ? cons.map((c) => c?.type) : [];
|
|
5
|
+
}
|
|
6
|
+
export const addColumnNotNullNoDefault = {
|
|
7
|
+
id: "add-column-not-null-no-default",
|
|
8
|
+
title: "ADD COLUMN NOT NULL without a default",
|
|
9
|
+
severity: "critical",
|
|
10
|
+
rationale: "Adding a NOT NULL column without a default fails outright on a non-empty table (existing rows would violate NOT NULL), and even when it succeeds it takes ACCESS EXCLUSIVE on the table.",
|
|
11
|
+
remediation: "Add the column nullable, backfill existing rows, then add the NOT NULL constraint via a validated CHECK — in separate steps.",
|
|
12
|
+
docsUrl: "https://www.postgresql.org/docs/current/sql-altertable.html",
|
|
13
|
+
check(stmt) {
|
|
14
|
+
const changes = alterChanges(stmt.ast);
|
|
15
|
+
for (const change of changes) {
|
|
16
|
+
if (change?.type !== "add column")
|
|
17
|
+
continue;
|
|
18
|
+
const types = constraintTypes(change);
|
|
19
|
+
const hasNotNull = types.includes("not null");
|
|
20
|
+
const hasDefault = types.includes("default");
|
|
21
|
+
if (hasNotNull && !hasDefault) {
|
|
22
|
+
const table = tableName(stmt.ast) ?? "your_table";
|
|
23
|
+
const col = nameOf(change.column) ?? nameOf(change.column?.name) ?? "new_col";
|
|
24
|
+
return {
|
|
25
|
+
message: `Column "${col}" is added NOT NULL with no default; this fails on any non-empty table and rewrites it otherwise.`,
|
|
26
|
+
lockTaken: "ACCESS EXCLUSIVE",
|
|
27
|
+
suggestedRewrite: [
|
|
28
|
+
`-- Step 1: add nullable`,
|
|
29
|
+
`ALTER TABLE ${table} ADD COLUMN ${col} <type>;`,
|
|
30
|
+
`-- Step 2: backfill in batches (outside this migration if the table is large)`,
|
|
31
|
+
`UPDATE ${table} SET ${col} = <value> WHERE ${col} IS NULL;`,
|
|
32
|
+
`-- Step 3: enforce NOT NULL without a long scan`,
|
|
33
|
+
`ALTER TABLE ${table} ADD CONSTRAINT ${col}_not_null CHECK (${col} IS NOT NULL) NOT VALID;`,
|
|
34
|
+
`ALTER TABLE ${table} VALIDATE CONSTRAINT ${col}_not_null;`,
|
|
35
|
+
].join("\n"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=addColumnNotNullNoDefault.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"addColumnNotNullNoDefault.js","sourceRoot":"","sources":["../../../src/analyzer/rules/addColumnNotNullNoDefault.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,EAAU,MAAM,gBAAgB,CAAC;AAEzE,SAAS,eAAe,CAAC,MAAc;IACrC,MAAM,IAAI,GAAG,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC;IACzC,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACrE,CAAC;AAED,MAAM,CAAC,MAAM,yBAAyB,GAAS;IAC7C,EAAE,EAAE,gCAAgC;IACpC,KAAK,EAAE,uCAAuC;IAC9C,QAAQ,EAAE,UAAU;IACpB,SAAS,EACP,0LAA0L;IAC5L,WAAW,EACT,8HAA8H;IAChI,OAAO,EAAE,6DAA6D;IACtE,KAAK,CAAC,IAAI;QACR,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACvC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC7B,IAAI,MAAM,EAAE,IAAI,KAAK,YAAY;gBAAE,SAAS;YAC5C,MAAM,KAAK,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;YACtC,MAAM,UAAU,GAAG,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;YAC9C,MAAM,UAAU,GAAG,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;YAC7C,IAAI,UAAU,IAAI,CAAC,UAAU,EAAE,CAAC;gBAC9B,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC;gBAClD,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,SAAS,CAAC;gBAC9E,OAAO;oBACL,OAAO,EAAE,WAAW,GAAG,mGAAmG;oBAC1H,SAAS,EAAE,kBAAkB;oBAC7B,gBAAgB,EAAE;wBAChB,yBAAyB;wBACzB,eAAe,KAAK,eAAe,GAAG,UAAU;wBAChD,+EAA+E;wBAC/E,UAAU,KAAK,QAAQ,GAAG,oBAAoB,GAAG,WAAW;wBAC5D,iDAAiD;wBACjD,eAAe,KAAK,mBAAmB,GAAG,oBAAoB,GAAG,0BAA0B;wBAC3F,eAAe,KAAK,wBAAwB,GAAG,YAAY;qBAC5D,CAAC,IAAI,CAAC,IAAI,CAAC;iBACb,CAAC;YACJ,CAAC;QACH,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAC"}
|