backend-skeleton 1.0.0-beta.1

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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
@@ -0,0 +1,90 @@
1
+ // G1: the fixed capability vocabulary a scanner adapter can declare support for, and which
2
+ // capabilities a CLI command needs before it's allowed to run adapter-specific codegen. Adding a
3
+ // capability here touches zero adapter files (an adapter that doesn't mention it is `false` by
4
+ // construction, via schemas/adapter.schema.json's additionalProperties:{type:'boolean'} +
5
+ // fail-closed reads elsewhere) -- and adding an adapter never touches this file. See
6
+ // D-adapter-registry in DECISIONS.md.
7
+ export const CAPABILITIES = Object.freeze({
8
+ 'api.operations': {
9
+ summary: 'endpoints carry a source-pinned, non-null operationId',
10
+ why: 'a contract operation must be addressable by id -- generic-grep\'s route-pattern grep never correlates one (operationId is always null by construction, see D-generic-grep-reconnaissance in DECISIONS.md)',
11
+ },
12
+ 'api.request-shape': {
13
+ summary: '`controller.file` + `ep.method` are present and re-readable as source, so request-body shape is derivable',
14
+ why: 'declared for documentation, not enforced -- its absence already degrades gracefully to body: "unknown" (see contracts/emit.mjs)',
15
+ },
16
+ 'resource.fetch': {
17
+ summary: 'the IR carries persistence entities (table/idField) and a canonical single-resource GET is identifiable',
18
+ why: 'handle codegen needs to know what to fetch, and by what key',
19
+ },
20
+ 'codegen.handles': {
21
+ summary: 'a handle codegen provider exists for this adapter\'s stack',
22
+ why: 'two providers exist today (java-spring, python-fastapi) -- see D-handles-providers (G4) in DECISIONS.md; a stack without one still fails this capability honestly rather than pretending',
23
+ },
24
+ });
25
+
26
+ export const CAPABILITY_NAMES = Object.freeze(Object.keys(CAPABILITIES));
27
+
28
+ // Which capabilities a command needs before it's allowed to touch adapter-specific codegen, and
29
+ // which gate (if any) a human can `bskel gate force` past once they've hand-supplied the missing
30
+ // artifact themselves.
31
+ //
32
+ // G4: `resource.fetch` moved OFF this list and onto each handles provider's own
33
+ // `requiresCapabilities` (bin/bskel.mjs's requireProviderCapabilitiesOrExit) -- it was never a
34
+ // property of the COMMAND, it was a property of what a codegen PROVIDER needs to do its job, and
35
+ // a future provider could plausibly need a different capability set than "resource.fetch". This
36
+ // list is purely dispatch: does a provider exist at all for this adapter.
37
+ export const COMMAND_CAPABILITIES = Object.freeze({
38
+ 'contract emit': Object.freeze(['api.operations']),
39
+ 'handles plan': Object.freeze(['codegen.handles']),
40
+ 'handles emit': Object.freeze(['codegen.handles']),
41
+ });
42
+
43
+ export const COMMAND_GATE = Object.freeze({
44
+ 'contract emit': 'contract',
45
+ 'handles plan': 'handles',
46
+ 'handles emit': 'handles',
47
+ });
48
+
49
+ // G2: data, not a per-adapter special case -- a capability a weak adapter can't earn statically
50
+ // can still be legitimately satisfied by an explicit CLI flag that supplies the missing ground
51
+ // truth from elsewhere. Today's one instance: `api.operations` (a real OpenAPI document supplies
52
+ // operation identity a static scan of e.g. python-fastapi honestly cannot -- see D-fastapi-adapter
53
+ // in DECISIONS.md). Expressed here, once, as a frozen map keyed by capability -- so
54
+ // `requireCapabilitiesOrExit()` and `explainMissingCapability()` never need to know which
55
+ // ADAPTER this applies to. Any adapter (including a future one) with the same honest weakness
56
+ // benefits automatically; this is deliberately NOT java-spring/python-fastapi-specific.
57
+ export const CAPABILITY_SATISFIERS = Object.freeze({
58
+ 'api.operations': {
59
+ flag: 'openapi-file',
60
+ note: 'a real OpenAPI document can supply operation identity a weak adapter cannot derive statically -- ' +
61
+ 'retry with --openapi-file <path> (and --path-prefix <prefix>, if this repo applies a global prefix ' +
62
+ 'the document\'s own paths already include but this adapter could not resolve on its own -- ' +
63
+ 'otherwise every endpoint stays unresolved with reason "prefix-inconclusive", see ' +
64
+ 'D-openapi-reconciliation in DECISIONS.md)',
65
+ },
66
+ });
67
+
68
+ // Pure and exported so a test can assert the message shape without shelling out. `scanReportPath`
69
+ // is an absolute path, matching this codebase's existing "no scan report at <abs path>"-style
70
+ // messages (bin/bskel.mjs's loadScanReportOrExit).
71
+ export function explainMissingCapability({ adapterId, capability, command, featureId, scanReportPath }) {
72
+ const cap = CAPABILITIES[capability];
73
+ const gate = COMMAND_GATE[command];
74
+ const satisfier = CAPABILITY_SATISFIERS[capability];
75
+ const lines = [
76
+ `blocked: \`bskel ${command}\` requires the \`${capability}\` capability, which the \`${adapterId}\` ` +
77
+ `adapter -- the adapter that produced ${scanReportPath} -- does not declare.`,
78
+ '',
79
+ ` ${capability}: ${cap.summary}. ${cap.why}.`,
80
+ '',
81
+ 'Nothing was written.',
82
+ '',
83
+ 'What you can do:',
84
+ ];
85
+ if (satisfier) lines.push(` - ${satisfier.note}.`);
86
+ lines.push(' - run `bskel doctor` -- it reports why each installed adapter did or did not detect this repo.');
87
+ lines.push(` - hand-write the required artifact against its schema yourself, then \`bskel gate force ${gate} --feature ${featureId} --reason "..."\` if you're confident it's correct.`);
88
+ lines.push(' - no adapter/codegen provider exists for this stack yet -- see G2/G4 in CATALOG.md.');
89
+ return lines.join('\n');
90
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ // P4 (D-extension-conformance): a reusable conformance check for a scanner adapter -- usable by a
4
+ // third-party adapter author, not just this project's own 3 shipped adapters, before shipping
5
+ // one. Checks the behavioral contract scanners/registry.mjs's schema validation can't (detect/
6
+ // scan are functions, JSON Schema has no vocabulary for them): detect() doesn't throw, a truthy
7
+ // detect() is followed by a scan() whose shape matches what scanners/index.mjs::runScan() actually
8
+ // consumes (`result.modules`, each module carrying `module`/`controllers`/`entities`/`enums`),
9
+ // and -- never machine-verified anywhere in this codebase before this -- that scan() is actually
10
+ // deterministic: two consecutive calls against the same repoRoot return deep-equal results, the
11
+ // exact property every shipped adapter's own `.sort()` calls exist to guarantee (O6), previously
12
+ // only a code-review-level belief, not a checked one.
13
+ export function checkAdapterConformance(adapter, repoRoot) {
14
+ const errors = [];
15
+ let detection;
16
+ try {
17
+ detection = adapter.detect(repoRoot);
18
+ } catch (err) {
19
+ errors.push(`detect() threw: ${err.message}`);
20
+ return { adapter: adapter.id, ok: false, errors };
21
+ }
22
+ if (detection == null) {
23
+ // A falsy detect() means this adapter doesn't claim this repoRoot -- nothing further to
24
+ // check (mirrors runScan()'s own `.filter(({ d }) => d != null)`).
25
+ return { adapter: adapter.id, ok: true, errors: [] };
26
+ }
27
+
28
+ let first;
29
+ try {
30
+ first = adapter.scan(repoRoot, detection);
31
+ } catch (err) {
32
+ errors.push(`scan() threw: ${err.message}`);
33
+ return { adapter: adapter.id, ok: false, errors };
34
+ }
35
+ if (!first || !Array.isArray(first.modules)) {
36
+ errors.push('scan() must return { modules: Array } -- runScan() reads result.modules directly');
37
+ return { adapter: adapter.id, ok: false, errors };
38
+ }
39
+ for (const mod of first.modules) {
40
+ for (const field of ['module', 'controllers', 'entities', 'enums']) {
41
+ if (!(field in mod)) errors.push(`scan() module ${JSON.stringify(mod.module ?? '(unknown)')} is missing required field "${field}"`);
42
+ }
43
+ }
44
+
45
+ let second;
46
+ try {
47
+ second = adapter.scan(repoRoot, detection);
48
+ } catch (err) {
49
+ errors.push(`scan() threw on its second, back-to-back call: ${err.message}`);
50
+ return { adapter: adapter.id, ok: false, errors };
51
+ }
52
+ try {
53
+ assert.deepStrictEqual(second, first);
54
+ } catch {
55
+ errors.push('scan() is not deterministic -- two consecutive calls against the same repoRoot returned different results (check for an un-sorted directory listing or Set/Map iteration order)');
56
+ }
57
+
58
+ return { adapter: adapter.id, ok: errors.length === 0, errors };
59
+ }
@@ -0,0 +1,109 @@
1
+ // A4 (D-db-schema-plane): Plane C -- live Postgres introspection. The one place in this whole
2
+ // tool that opens a network connection to something other than a git remote/GitHub API. Reads the
3
+ // connection string ONLY from `process.env[databaseUrlEnv]` at call time -- NEVER from `.env`
4
+ // directly (this codebase's own convention, and Team-IZ-Backend's own CLAUDE.md `.env` caution).
5
+ // `pg` is this project's first-ever SQL dependency -- confirmed no existing dependency does SQL.
6
+ import pg from 'pg';
7
+ import { sha256String } from '../../lib/fsutil.mjs';
8
+
9
+ const { Client } = pg;
10
+
11
+ // information_schema is the SQL-standard view (portable across schemas/versions); pg_indexes and
12
+ // pg_policies are Postgres-specific catalog views with no information_schema equivalent for
13
+ // indexes/RLS. All four queries are parameterized on `schemaName` -- never string-interpolated --
14
+ // even though `schemaName` here only ever comes from a CLI flag this process's own owner typed,
15
+ // not untrusted network input; parameterizing anyway costs nothing and is simply correct SQL
16
+ // hygiene, matching this project's own "parameterized queries only" global rule (CLAUDE.md §6).
17
+ const TABLES_SQL = `SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY table_name`;
18
+ const COLUMNS_SQL = `SELECT table_name, column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = $1 ORDER BY table_name, ordinal_position`;
19
+ const PRIMARY_KEYS_SQL = `
20
+ SELECT tc.table_name, kcu.column_name
21
+ FROM information_schema.table_constraints tc
22
+ JOIN information_schema.key_column_usage kcu
23
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
24
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
25
+ ORDER BY tc.table_name, kcu.ordinal_position`;
26
+ const FOREIGN_KEYS_SQL = `
27
+ SELECT
28
+ tc.table_name, kcu.column_name,
29
+ ccu.table_name AS foreign_table_name, ccu.column_name AS foreign_column_name
30
+ FROM information_schema.table_constraints tc
31
+ JOIN information_schema.key_column_usage kcu
32
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
33
+ JOIN information_schema.constraint_column_usage ccu
34
+ ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
35
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1
36
+ ORDER BY tc.table_name, kcu.column_name`;
37
+ const INDEXES_SQL = `SELECT tablename AS table_name, indexname AS index_name FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`;
38
+ const RLS_POLICIES_SQL = `SELECT tablename AS table_name, policyname AS policy_name FROM pg_policies WHERE schemaname = $1 ORDER BY tablename, policyname`;
39
+
40
+ function groupByTable(rows, tableKey = 'table_name') {
41
+ const map = new Map();
42
+ for (const row of rows) {
43
+ const key = row[tableKey];
44
+ if (!map.has(key)) map.set(key, []);
45
+ map.get(key).push(row);
46
+ }
47
+ return map;
48
+ }
49
+
50
+ // A real, live-tested Node quirk (not hypothetical): a refused TCP connection surfaces as an
51
+ // `AggregateError` (dual-stack IPv6+IPv4 connection attempts, both failing) whose own top-level
52
+ // `.message` is an EMPTY STRING -- `.code` (e.g. `ECONNREFUSED`) and `.errors[]` (the individual
53
+ // per-address failures) carry the actual information. A plain `err.message` alone would surface
54
+ // nothing useful to the user here; this builds a real message from whichever shape the error is.
55
+ export function describeConnectionError(err) {
56
+ if (err.message) return err.message;
57
+ if (Array.isArray(err.errors) && err.errors.length > 0) {
58
+ return err.errors.map((e) => e.message).join('; ');
59
+ }
60
+ return err.code ?? String(err);
61
+ }
62
+
63
+ // Entry point. `connectionString` is whatever `process.env[databaseUrlEnv]` resolved to -- the
64
+ // caller (scanners/index.mjs) owns reading that env var and failing loudly if it's unset; this
65
+ // function only ever receives an already-resolved string. `BEGIN TRANSACTION READ ONLY` is
66
+ // structural defense-in-depth -- every query here is already a SELECT, but a read-only
67
+ // transaction means the database itself refuses any write this connection could ever attempt,
68
+ // not just "we didn't write any queries that would".
69
+ export async function introspectSchema({ connectionString, schema = 'public' }) {
70
+ const client = new Client({ connectionString });
71
+ await client.connect();
72
+ try {
73
+ await client.query('BEGIN TRANSACTION READ ONLY');
74
+
75
+ // Sequential, not Promise.all -- a single pg.Client processes one query at a time over one
76
+ // connection; issuing several concurrently on the same client is deprecated (pg queues them
77
+ // internally today, but warns, and that queuing behavior is going away in pg 9). A Pool
78
+ // would allow real concurrency, but this is a one-shot CLI invocation, not a long-lived
79
+ // server -- the simplicity of one client, one connection, sequential queries is the right
80
+ // trade-off here, not premature optimization for concurrency nothing needs.
81
+ const tablesRes = await client.query(TABLES_SQL, [schema]);
82
+ const columnsRes = await client.query(COLUMNS_SQL, [schema]);
83
+ const pkRes = await client.query(PRIMARY_KEYS_SQL, [schema]);
84
+ const fkRes = await client.query(FOREIGN_KEYS_SQL, [schema]);
85
+ const indexesRes = await client.query(INDEXES_SQL, [schema]);
86
+ const policiesRes = await client.query(RLS_POLICIES_SQL, [schema]);
87
+
88
+ await client.query('COMMIT');
89
+
90
+ const columnsByTable = groupByTable(columnsRes.rows);
91
+ const pkByTable = groupByTable(pkRes.rows);
92
+ const fkByTable = groupByTable(fkRes.rows);
93
+ const indexesByTable = groupByTable(indexesRes.rows);
94
+ const policiesByTable = groupByTable(policiesRes.rows);
95
+
96
+ const tables = tablesRes.rows.map(({ table_name: name }) => ({
97
+ name,
98
+ columns: (columnsByTable.get(name) ?? []).map((c) => ({ name: c.column_name, type: c.data_type, nullable: c.is_nullable === 'YES' })),
99
+ primary_key: (pkByTable.get(name) ?? []).map((r) => r.column_name),
100
+ foreign_keys: (fkByTable.get(name) ?? []).map((r) => ({ column: r.column_name, references_table: r.foreign_table_name, references_column: r.foreign_column_name })),
101
+ indexes: (indexesByTable.get(name) ?? []).map((r) => r.index_name),
102
+ rls_policies: (policiesByTable.get(name) ?? []).map((r) => r.policy_name),
103
+ }));
104
+
105
+ return { schema, tables, schema_hash: sha256String(JSON.stringify(tables)) };
106
+ } finally {
107
+ await client.end();
108
+ }
109
+ }
@@ -0,0 +1,126 @@
1
+ // A4 (D-db-schema-plane): Plane A -- migration-file scanning, always local, never a network call.
2
+ // Deliberately NOT a real SQL parser -- same "good-enough regex, not a real parser" restraint as
3
+ // A2's Java analyzer and G2's Python analyzer, bounded to what real Flyway/Liquibase repos
4
+ // actually look like, not general SQL. Confirmed against the real oracle repo (Team-IZ-Backend)
5
+ // that it has ZERO migration files of either kind -- this module is unverifiable against it and
6
+ // is built/tested entirely against a synthetic fixture instead (see DECISIONS.md D-db-schema-plane).
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { execFileSync } from 'node:child_process';
10
+
11
+ function listRgFiles(repoRoot, glob) {
12
+ try {
13
+ return execFileSync('rg', ['--files', '-g', glob, repoRoot], { encoding: 'utf8' }).split('\n').filter(Boolean).sort();
14
+ } catch {
15
+ return []; // rg exits 1 on "no files matched" -- not an error
16
+ }
17
+ }
18
+
19
+ // Same balanced-paren algorithm as python-fastapi.mjs's own local matchBalancedParens() and
20
+ // _java-spring-analyzer.mjs's matchBalanced() -- a third, deliberately separate copy, matching
21
+ // this project's own established precedent (see python-fastapi.mjs's own comment) of NOT reaching
22
+ // across an unrelated module boundary for a five-line algorithm; SQL is a different language from
23
+ // either of those, sharing the module would be a false economy.
24
+ function matchBalancedParens(text, openIndex) {
25
+ let depth = 0;
26
+ for (let i = openIndex; i < text.length; i++) {
27
+ if (text[i] === '(') depth++;
28
+ else if (text[i] === ')') {
29
+ depth--;
30
+ if (depth === 0) return i;
31
+ }
32
+ }
33
+ return -1;
34
+ }
35
+
36
+ // Splits a column-definition list on top-level commas only -- `VARCHAR(255)`/`NUMERIC(10,2)`/
37
+ // `CHECK (price > 0)` all carry their own parens that must not be mistaken for a column separator.
38
+ function splitTopLevelCommas(text) {
39
+ const parts = [];
40
+ let depth = 0;
41
+ let start = 0;
42
+ for (let i = 0; i < text.length; i++) {
43
+ if (text[i] === '(') depth++;
44
+ else if (text[i] === ')') depth = Math.max(0, depth - 1);
45
+ else if (text[i] === ',' && depth === 0) {
46
+ parts.push(text.slice(start, i));
47
+ start = i + 1;
48
+ }
49
+ }
50
+ const last = text.slice(start);
51
+ if (last.trim()) parts.push(last);
52
+ return parts.map((p) => p.trim()).filter(Boolean);
53
+ }
54
+
55
+ const CREATE_TABLE_RE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?(\w+)"?\s*\(/gi;
56
+ const ALTER_ADD_COLUMN_RE = /ALTER\s+TABLE\s+"?(\w+)"?\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?"?(\w+)"?/gi;
57
+ // A column definition line's own leading constraint keywords never name a column -- skips them so
58
+ // e.g. `PRIMARY KEY (id, org_id)` isn't mistaken for a column named "primary".
59
+ const CONSTRAINT_LEAD_RE = /^(PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT)\b/i;
60
+ const COLUMN_NAME_RE = /^"?(\w+)"?/;
61
+
62
+ function extractTablesFromSql(sqlText, sourceFile) {
63
+ const tables = new Map(); // name -> Set<column>
64
+
65
+ CREATE_TABLE_RE.lastIndex = 0;
66
+ let m;
67
+ while ((m = CREATE_TABLE_RE.exec(sqlText))) {
68
+ const tableName = m[1];
69
+ const openParen = m.index + m[0].length - 1;
70
+ const closeParen = matchBalancedParens(sqlText, openParen);
71
+ if (closeParen === -1) continue; // malformed -- skip, don't misattribute
72
+ const body = sqlText.slice(openParen + 1, closeParen);
73
+ const columns = new Set();
74
+ for (const segment of splitTopLevelCommas(body)) {
75
+ if (CONSTRAINT_LEAD_RE.test(segment)) continue;
76
+ const colMatch = segment.match(COLUMN_NAME_RE);
77
+ if (colMatch) columns.add(colMatch[1]);
78
+ }
79
+ if (!tables.has(tableName)) tables.set(tableName, new Set());
80
+ for (const c of columns) tables.get(tableName).add(c);
81
+ }
82
+
83
+ ALTER_ADD_COLUMN_RE.lastIndex = 0;
84
+ while ((m = ALTER_ADD_COLUMN_RE.exec(sqlText))) {
85
+ const [, tableName, columnName] = m;
86
+ if (!tables.has(tableName)) tables.set(tableName, new Set());
87
+ tables.get(tableName).add(columnName);
88
+ }
89
+
90
+ return [...tables.entries()].map(([name, columns]) => ({ name, columns: [...columns].sort(), source_file: sourceFile }));
91
+ }
92
+
93
+ // Entry point. Returns `{ tool, files, tables }` -- `tool: 'none'` (empty files/tables) is a real,
94
+ // expected, and reported outcome, not an error -- most real repos (including the oracle repo
95
+ // itself) have no migration-file convention at all; their schema lives elsewhere (JPA ddl-auto,
96
+ // or -- the oracle repo's actual case -- entirely outside this repo, in an external Supabase
97
+ // project).
98
+ export function scanMigrations(repoRoot) {
99
+ const flywayFiles = listRgFiles(repoRoot, '**/db/migration/**/*.sql');
100
+ const liquibaseFiles = listRgFiles(repoRoot, '**/db/changelog/**/*.{xml,yaml,yml,sql}');
101
+
102
+ if (flywayFiles.length === 0 && liquibaseFiles.length === 0) {
103
+ return { tool: 'none', files: [], tables: [] };
104
+ }
105
+
106
+ if (flywayFiles.length > 0) {
107
+ const tables = [];
108
+ for (const file of flywayFiles) {
109
+ const text = fs.readFileSync(file, 'utf8');
110
+ tables.push(...extractTablesFromSql(text, path.relative(repoRoot, file)));
111
+ }
112
+ return { tool: 'flyway', files: flywayFiles.map((f) => path.relative(repoRoot, f)), tables };
113
+ }
114
+
115
+ // Liquibase changelogs are DETECTED (filenames recorded) but not deep-parsed in this first
116
+ // pass -- XML/YAML changeSet parsing is a materially larger, separate job than Flyway's plain
117
+ // SQL files; recording their presence is still real value over today's total silence, and is
118
+ // an honestly documented gap, not a silent guess (see DECISIONS.md D-db-schema-plane).
119
+ const sqlLiquibaseFiles = liquibaseFiles.filter((f) => f.endsWith('.sql'));
120
+ const tables = [];
121
+ for (const file of sqlLiquibaseFiles) {
122
+ const text = fs.readFileSync(file, 'utf8');
123
+ tables.push(...extractTablesFromSql(text, path.relative(repoRoot, file)));
124
+ }
125
+ return { tool: 'liquibase', files: liquibaseFiles.map((f) => path.relative(repoRoot, f)), tables };
126
+ }