@starci/eslint-canon-be 3.4.39 → 3.4.41

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 (48) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/boundary-schema.mjs +32 -4
  4. package/idempotency.mjs +3 -0
  5. package/index.mjs +2 -0
  6. package/infra-owner.mjs +4 -3
  7. package/lib/client-idempotency.mjs +403 -0
  8. package/lib/persistence.mjs +1 -1
  9. package/lib/schema-values.mjs +90 -0
  10. package/lib/unit-spec.mjs +6 -0
  11. package/operations.mjs +197 -32
  12. package/package.json +1 -1
  13. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  14. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  15. package/runtime/knowledge/hfs/rules.yaml +168 -47
  16. package/runtime/knowledge/hfs/slots.yaml +110 -52
  17. package/runtime/knowledge/sonar-gate.yaml +1 -1
  18. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  19. package/runtime/modules/models/registry.yaml +3 -1
  20. package/runtime/modules/models/runtimes.yaml +2 -0
  21. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  22. package/runtime/scripts/api/git/show.mjs +6 -0
  23. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  24. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  25. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  26. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  27. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  28. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  29. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  30. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  31. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  32. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  33. package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
  34. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  35. package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  37. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  38. package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
  39. package/runtime/scripts/hfs/slots.mjs +1 -0
  40. package/runtime/scripts/hfs/view.mjs +2 -1
  41. package/runtime/scripts/lib/in-order.mjs +71 -0
  42. package/runtime/scripts/lib/language.mjs +94 -1
  43. package/runtime/scripts/lib/package-at.mjs +62 -0
  44. package/runtime/scripts/lib/same-text.mjs +5 -0
  45. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  46. package/suppression.mjs +1 -1
  47. package/unit-spec.mjs +66 -6
  48. package/uploads.mjs +112 -0
@@ -0,0 +1,489 @@
1
+ // database-migrations.mjs - L02 migration name, UTC ordering and base-branch immutability checks. Git reads go
2
+ // through the runtime API call files, and specs may inject the same small runner contract used by database.mjs.
3
+ import { lsTree } from '../../api/git/ls-tree.mjs';
4
+ import { mergeBase } from '../../api/git/merge-base.mjs';
5
+ import { show } from '../../api/git/show.mjs';
6
+ import { withoutGitLocalEnv } from '../../lib/git.mjs';
7
+ import { eachInOrder, findInOrder } from '../../lib/in-order.mjs';
8
+ import { sameText } from '../../lib/same-text.mjs';
9
+ import { found, readText } from './read.mjs';
10
+ import { DB_MIGRATION_DESTRUCTIVE, DB_MIGRATION_SHAPE, isActiveMigration, MIGRATIONS_ARCHIVE_DIR, MIGRATIONS_DIR } from './database-constants.mjs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { loadTypescript } from '../../lib/package-at.mjs';
14
+ import { isConstVariable } from '../../lib/ts-ast.mjs';
15
+ import { lexicalChecker, unwrapExpression } from '../../lib/ts-analysis.mjs';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const MIGRATION_PLAIN = /[a-z0-9]+(?:-[a-z0-9]+)*/;
19
+ const MIGRATION_CONTRACT_TAIL = /contract_[a-z0-9]+(?:[-_][a-z0-9]+)*/;
20
+ const MIGRATION_SLUG = [MIGRATION_PLAIN.source, MIGRATION_CONTRACT_TAIL.source, `${MIGRATION_PLAIN.source}_${MIGRATION_CONTRACT_TAIL.source}`].join('|');
21
+ const MIGRATION_NAME = new RegExp(String.raw`^(\d{14})_(${MIGRATION_SLUG})\.sql$`);
22
+ export const isMigrationName = (name) => MIGRATION_NAME.test(name);
23
+ const RESTAMP_HEADER = /^-- starci:restamp (\d{14}) (\d{14})(?:\r?\n|$)/;
24
+ const withoutRestampHeader = (text) => text?.replace(RESTAMP_HEADER, '') ?? null;
25
+
26
+ /** `<ts14>` -> the UTC instant it names, or null when the digits are no real time (month 13, day 32, ...). */
27
+ export function migrationStamp(text) {
28
+ const [year, month, day, hour, minute, second] = [Number(text.slice(0, 4)), ...[4, 6, 8, 10, 12].map((at) => Number(text.slice(at, at + 2)))];
29
+ const stamp = Date.UTC(year, month - 1, day, hour, minute, second);
30
+ const date = new Date(stamp);
31
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
32
+ && date.getUTCHours() === hour && date.getUTCMinutes() === minute && date.getUTCSeconds() === second ? stamp : null;
33
+ }
34
+
35
+ /** The three git reads L02 makes: (args, {cwd}) -> {ok, stdout}. */
36
+ export function defaultGit(args, { cwd }) {
37
+ const [verb, ...rest] = args;
38
+ const env = withoutGitLocalEnv(process.env);
39
+ if (verb === 'merge-base') { const sha = mergeBase(cwd, rest[0], rest[1]); return { ok: sha !== null, stdout: sha ?? '' }; }
40
+ let call = show;
41
+ if (verb === 'ls-tree') call = lsTree;
42
+ const result = call(rest, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
43
+ return { ok: !result.error && result.status === 0, stdout: result.stdout };
44
+ }
45
+
46
+ const runGit = (git, repoRoot, args) => Promise.resolve(git(args, { cwd: repoRoot }))
47
+ .then((result) => ({ ok: result?.ok === true, stdout: String(result?.stdout ?? '') }));
48
+
49
+ async function baseShaOf(git, repoRoot, base) {
50
+ let found = null;
51
+ await findInOrder(base ? [base] : ['origin/main', 'main'], async (ref) => {
52
+ const result = await runGit(git, repoRoot, ['merge-base', 'HEAD', ref]);
53
+ if (result.ok && result.stdout.trim()) found = { ref, sha: result.stdout.trim() };
54
+ return found !== null;
55
+ });
56
+ return found;
57
+ }
58
+
59
+ async function migrationFilesAt(git, repoRoot, sha) {
60
+ const result = await runGit(git, repoRoot, ['ls-tree', '-r', '--name-only', '-z', sha, '--', MIGRATIONS_DIR]);
61
+ if (!result.ok) return [];
62
+ const prefix = `${MIGRATIONS_DIR}/`;
63
+ return result.stdout.split('\0').filter((entry) => entry.startsWith(prefix));
64
+ }
65
+
66
+ async function baseFileText(git, repoRoot, sha, file) {
67
+ // `./` reads the path from the app root, which is a folder of a larger repository when the app is nested in one (an example of the runtime).
68
+ const result = await runGit(git, repoRoot, ['show', `${sha}:./${file}`]);
69
+ return result.ok ? result.stdout : null;
70
+ }
71
+
72
+ function localMigrationFindings(local) {
73
+ const findings = [];
74
+ const stamps = new Map();
75
+ for (const entry of local) {
76
+ if (!entry.match) {
77
+ findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is not named <ts14>_<kebab>.sql; a migration is created by \`npm run db:new\` (supabase migration new), never renamed by hand`, { expected: '<ts14>_<kebab>.sql' }));
78
+ continue;
79
+ }
80
+ const stamp = migrationStamp(entry.match[1]);
81
+ if (stamp === null) {
82
+ findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} carries ${entry.match[1]}, which is no valid UTC time; a migration stamp must name a valid UTC instant`, { stamp: entry.match[1] }));
83
+ continue;
84
+ }
85
+ if (stamps.has(stamp)) findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} shares stamp ${entry.match[1]} with ${stamps.get(stamp)}; stamps are strictly increasing`, { stamp: entry.match[1], other: stamps.get(stamp) }));
86
+ stamps.set(stamp, entry.file);
87
+ }
88
+ return findings;
89
+ }
90
+
91
+ /** HEAD owns the append boundary. Archived names retire the old chain, so a replacement baseline starts afresh. */
92
+ function migrationHistory(files, retired, localNames) {
93
+ const prefix = `${MIGRATIONS_DIR}/`;
94
+ const onHead = new Set(files.filter(isActiveMigration).map((file) => file.slice(prefix.length)));
95
+ const retiredNames = new Set([...retired, ...files.filter((file) => file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`)).map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1))].filter((name) => !localNames.has(name)));
96
+ const stamps = [...onHead].filter((name) => !retiredNames.has(name)).map((name) => MIGRATION_NAME.exec(name)?.[1]).filter(Boolean).map(migrationStamp).filter((stamp) => stamp !== null);
97
+ return { onHead, latest: stamps.length ? Math.max(...stamps) : null };
98
+ }
99
+
100
+ function migrationOrderingFindings(local, history, restamped = new Set()) {
101
+ return local.flatMap((entry) => {
102
+ const stamp = entry.match ? migrationStamp(entry.match[1]) : null;
103
+ if (history.onHead.has(entry.name) || restamped.has(entry.name) || stamp === null || history.latest === null || stamp > history.latest) return [];
104
+ return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration already in repository history (latest active ${new Date(history.latest).toISOString()}); a new migration sorts after the committed active chain`, { stamp: entry.match[1], latest: new Date(history.latest).toISOString() })];
105
+ });
106
+ }
107
+
108
+ /** A restamp changes only the version, and its header proves the explicit old->new map against repository SQL. */
109
+ async function verifiedRestamps(local, files, repoRoot, git, sha) {
110
+ const prefix = `${MIGRATIONS_DIR}/`;
111
+ const active = files.filter(isActiveMigration).map((file) => file.slice(prefix.length));
112
+ const claims = local.map((entry) => ({ ...entry, header: RESTAMP_HEADER.exec(readText(repoRoot, entry.file) ?? '') })).filter((entry) => entry.header);
113
+ const accepted = [];
114
+ await eachInOrder(claims, async (entry) => {
115
+ const [, oldStamp, newStamp] = entry.header;
116
+ if (!entry.match || entry.match[1] !== newStamp || migrationStamp(oldStamp) === null || oldStamp === newStamp) return;
117
+ if (claims.filter((claim) => claim.header[1] === oldStamp).length !== 1) return;
118
+ const names = active.filter((name) => name.startsWith(`${oldStamp}_`) && name.slice(14) === entry.name.slice(14));
119
+ if (names.length !== 1 || local.some((candidate) => candidate.name === names[0])) return;
120
+ const committed = await baseFileText(git, repoRoot, sha, `${prefix}${names[0]}`);
121
+ if (committed === null || !sameText(withoutRestampHeader(readText(repoRoot, entry.file)), withoutRestampHeader(committed))) return;
122
+ accepted.push({ source: names[0], destination: entry.name });
123
+ });
124
+ return accepted;
125
+ }
126
+
127
+ async function baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase) {
128
+ if (!entry.match) return [];
129
+ if (onBase.has(entry.name)) {
130
+ const committed = await baseFileText(git, repoRoot, baseSha.sha, entry.file);
131
+ const current = readText(repoRoot, entry.file);
132
+ if (!sameText(current, committed)) return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} differs from its content on ${baseSha.ref}; a migration on the base branch is immutable - write a new migration`, { base: baseSha.ref })];
133
+ }
134
+ return [];
135
+ }
136
+
137
+ function removedMigrationFindings(local, retired, baseNames, baseSha) {
138
+ const findings = [];
139
+ for (const name of baseNames) {
140
+ if (!local.some((entry) => entry.name === name) && !retired.has(name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
141
+ }
142
+ return findings;
143
+ }
144
+
145
+ /**
146
+ * L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. `archived` are the files of
147
+ * supabase/migrations/_archive/ (app.supabase.migrations.archive): never judged, and a migration moved there at a baseline
148
+ * cutover is retired from the ordering chain, not removed. New files append after HEAD's active migrations; timestamps
149
+ * on archived migrations, remote branches and the wall clock do not constrain a replacement baseline.
150
+ */
151
+ export async function migrationShapeFindings({ repoRoot, migrations, archived = [], git, base }) {
152
+ const prefix = `${MIGRATIONS_DIR}/`;
153
+ const local = migrations.map((file) => {
154
+ const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
155
+ return { file, name, match: MIGRATION_NAME.exec(name) };
156
+ });
157
+ const findings = localMigrationFindings(local);
158
+ const retired = new Set(archived.map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1)));
159
+ const headFiles = await migrationFilesAt(git, repoRoot, 'HEAD');
160
+ const baseSha = await baseShaOf(git, repoRoot, base);
161
+ const baseFiles = baseSha ? await migrationFilesAt(git, repoRoot, baseSha.sha) : [];
162
+ const restamps = await verifiedRestamps(local, baseSha ? baseFiles : headFiles, repoRoot, git, baseSha?.sha ?? 'HEAD');
163
+ for (const restamp of restamps) retired.add(restamp.source);
164
+ const history = migrationHistory(headFiles, retired, new Set(local.map((entry) => entry.name)));
165
+ findings.push(...migrationOrderingFindings(local, history, new Set(restamps.map((entry) => entry.destination))));
166
+ if (!baseSha) return findings;
167
+ const baseNames = baseFiles.filter(isActiveMigration).map((file) => file.slice(prefix.length));
168
+ const onBase = new Set(baseNames);
169
+ await eachInOrder(local, async (entry) => {
170
+ findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase));
171
+ });
172
+ findings.push(...removedMigrationFindings(local, retired, baseNames, baseSha));
173
+ return findings;
174
+ }
175
+
176
+ const sqlName = (token) => token && ['word', 'ident'].includes(token.t) ? token.v : null;
177
+ const sqlWord = (token, value) => token?.t === 'word' && token.v === value;
178
+
179
+ function commentEnd(text, start) {
180
+ if (text.startsWith('--', start)) {
181
+ const end = text.indexOf('\n', start + 2);
182
+ return end === -1 ? text.length : end;
183
+ }
184
+ if (!text.startsWith('/*', start)) return null;
185
+ let depth = 1;
186
+ let index = start + 2;
187
+ while (index < text.length && depth > 0) {
188
+ if (text.startsWith('/*', index)) { depth += 1; index += 2; }
189
+ else if (text.startsWith('*/', index)) { depth -= 1; index += 2; }
190
+ else index += 1;
191
+ }
192
+ return index;
193
+ }
194
+
195
+ function quotedToken(text, start, quote, escaped = false) {
196
+ let index = start + 1;
197
+ let value = '';
198
+ while (index < text.length) {
199
+ if (text[index] === quote && text[index + 1] === quote) { value += quote; index += 2; }
200
+ else if (text[index] === quote) return { value, end: index + 1 };
201
+ else if (escaped && text[index] === '\\') { value += text[index + 1] ?? ''; index += 2; }
202
+ else { value += text[index]; index += 1; }
203
+ }
204
+ return { value, end: index };
205
+ }
206
+
207
+ function sqlTokenAt(text, index) {
208
+ const char = text[index];
209
+ if (char === "'" || char === '"') {
210
+ const token = quotedToken(text, index, char);
211
+ return { t: char === '"' ? 'ident' : 'string', v: token.value, end: token.end };
212
+ }
213
+ if (/[eE]/.test(char) && text[index + 1] === "'") {
214
+ const token = quotedToken(text, index + 1, "'", true);
215
+ return { t: 'string', v: token.value, end: token.end };
216
+ }
217
+ const tag = char === '$' ? /^\$(?:[A-Za-z_]\w*)?\$/.exec(text.slice(index))?.[0] : null;
218
+ if (tag) {
219
+ const end = text.indexOf(tag, index + tag.length);
220
+ return { t: 'body', v: text.slice(index + tag.length, end === -1 ? text.length : end), end: end === -1 ? text.length : end + tag.length };
221
+ }
222
+ const word = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(text.slice(index))?.[0];
223
+ if (word) return { t: 'word', v: word.toLowerCase(), end: index + word.length };
224
+ const number = /^\d+(?:\.\d+)?/.exec(text.slice(index))?.[0];
225
+ if (number) return { t: 'number', v: number, end: index + number.length };
226
+ return { t: 'punct', v: char, end: index + 1 };
227
+ }
228
+
229
+ /** Synchronous SQL tokens; literal values stay internal and must never be copied to a finding. */
230
+ export function databaseSqlTokens(text) {
231
+ const tokens = [];
232
+ let index = 0;
233
+ let line = 1;
234
+ while (index < text.length) {
235
+ if (/\s/.test(text[index])) {
236
+ if (text[index] === '\n') line += 1;
237
+ index += 1;
238
+ continue;
239
+ }
240
+ const end = commentEnd(text, index);
241
+ if (end !== null) { line += text.slice(index, end).split('\n').length - 1; index = end; continue; }
242
+ const token = sqlTokenAt(text, index);
243
+ tokens.push({ t: token.t, v: token.v, line, index });
244
+ line += text.slice(index, token.end).split('\n').length - 1;
245
+ index = token.end;
246
+ }
247
+ return tokens;
248
+ }
249
+
250
+ /** Split executable SQL, retaining function bodies as opaque tokens until their language is known. */
251
+ export function databaseSqlStatements(tokens) {
252
+ const statements = [];
253
+ let current = [];
254
+ for (const token of tokens) {
255
+ if (token.t === 'punct' && token.v === ';') {
256
+ if (current.length) statements.push(current);
257
+ current = [];
258
+ }
259
+ else current.push(token);
260
+ }
261
+ if (current.length) statements.push(current);
262
+ return statements;
263
+ }
264
+
265
+ function executableStatementTokens(statement, depth) {
266
+ const isDo = sqlWord(statement[0], 'do');
267
+ const functionAt = statement.findIndex((token) => sqlWord(token, 'function') || sqlWord(token, 'procedure'));
268
+ const isFunction = sqlWord(statement[0], 'create') && functionAt > 0 && functionAt < 4;
269
+ if (!isDo && !isFunction) return statement;
270
+ const languageAt = statement.findIndex((token) => sqlWord(token, 'language'));
271
+ const language = statement[languageAt + 1]?.v ?? 'sql';
272
+ if (languageAt !== -1 && !['sql', 'plpgsql'].includes(language)) return statement;
273
+ return statement.flatMap((token, index) => {
274
+ const body = token.t === 'body' || (token.t === 'string' && (isDo || sqlWord(statement[index - 1], 'as')));
275
+ if (!body) return [token];
276
+ return databaseExecutableTokens(token.v, depth + 1).map((child) => ({ ...child, line: child.line + token.line - 1 }));
277
+ });
278
+ }
279
+
280
+ /** SQL and PL/pgSQL bodies are scanned; comments, data literals and other function languages are opaque. */
281
+ export function databaseExecutableTokens(text, depth = 0) {
282
+ const tokens = databaseSqlTokens(text);
283
+ if (depth > 8) return tokens;
284
+ return databaseSqlStatements(tokens).flatMap((statement) => [...executableStatementTokens(statement, depth), { t: 'punct', v: ';' }]);
285
+ }
286
+
287
+ function nameAt(tokens, index) {
288
+ const first = sqlName(tokens[index]);
289
+ if (first === null) return null;
290
+ const parts = [first];
291
+ let next = index + 1;
292
+ while (tokens[next]?.v === '.' && sqlName(tokens[next + 1]) !== null) { parts.push(sqlName(tokens[next + 1])); next += 2; }
293
+ return { name: parts.join('.'), leaf: parts.at(-1), next };
294
+ }
295
+
296
+ const skipWords = (tokens, index, words) => {
297
+ let next = index;
298
+ while (words.some((word) => sqlWord(tokens[next], word))) next += 1;
299
+ return next;
300
+ };
301
+
302
+ function dropObjects(tokens, start) {
303
+ const kind = tokens[start + 1]?.v;
304
+ if (!['table', 'role', 'user', 'schema', 'type', 'view', 'materialized', 'sequence', 'function', 'procedure', 'index', 'policy', 'trigger'].includes(kind)) return [];
305
+ let index = skipWords(tokens, start + 2, ['view', 'if', 'exists', 'concurrently']);
306
+ const objects = [];
307
+ while (index < tokens.length) {
308
+ const object = nameAt(tokens, index);
309
+ if (!object) break;
310
+ objects.push({ ...object, kind, line: tokens[start].line });
311
+ if (tokens[object.next]?.v !== ',') break;
312
+ index = object.next + 1;
313
+ }
314
+ return objects;
315
+ }
316
+
317
+ function columnAlterObject(tokens, index, table, line) {
318
+ let next = index + 1;
319
+ if (sqlWord(tokens[next], 'column')) next += 1;
320
+ const column = nameAt(tokens, next);
321
+ if (!column) return null;
322
+ const tail = tokens.slice(column.next);
323
+ const type = sqlWord(tail[0], 'type') || (sqlWord(tail[0], 'set') && sqlWord(tail[1], 'data') && sqlWord(tail[2], 'type'));
324
+ const notNull = sqlWord(tail[0], 'set') && sqlWord(tail[1], 'not') && sqlWord(tail[2], 'null');
325
+ const defaultSet = tokens.some((token, at) => sqlWord(token, 'alter') && sqlName(tokens[at + 1 + Number(sqlWord(tokens[at + 1], 'column'))]) === column.leaf
326
+ && sqlWord(tokens[at + 2 + Number(sqlWord(tokens[at + 1], 'column'))], 'set') && sqlWord(tokens[at + 3 + Number(sqlWord(tokens[at + 1], 'column'))], 'default'));
327
+ if (!type && !(notNull && !defaultSet)) return null;
328
+ return { name: `${table.name}.${column.name}`, leaf: column.leaf, table: table.leaf, kind: 'column', line };
329
+ }
330
+
331
+ function alteredColumn(table, column, line) {
332
+ return { name: `${table.name}.${column.name}`, leaf: column.leaf, table: table.leaf, kind: 'column', line };
333
+ }
334
+
335
+ function tableActionObject(body, index, table, line) {
336
+ if (sqlWord(body[index], 'drop')) {
337
+ const constraint = sqlWord(body[index + 1], 'constraint');
338
+ const at = skipWords(body, index + 1, ['column', 'constraint', 'if', 'exists']);
339
+ if (sqlWord(body[at], 'default') || sqlWord(body[at], 'not')) return null;
340
+ const column = nameAt(body, at);
341
+ return column ? { ...alteredColumn(table, column, line), kind: constraint ? 'constraint' : 'column' } : null;
342
+ }
343
+ if (sqlWord(body[index], 'rename')) {
344
+ const column = sqlWord(body[index + 1], 'column') ? nameAt(body, index + 2) : null;
345
+ return column ? alteredColumn(table, column, line) : { ...table, kind: 'table', line };
346
+ }
347
+ return sqlWord(body[index], 'alter') ? columnAlterObject(body, index, table, line) : null;
348
+ }
349
+
350
+ function alterObjects(tokens, start) {
351
+ const table = nameAt(tokens, skipWords(tokens, start + 2, ['if', 'exists', 'only']));
352
+ if (!table) return [];
353
+ const body = tokens.slice(table.next);
354
+ return body.map((token, index) => tableActionObject(body, index, table, tokens[start].line)).filter(Boolean);
355
+ }
356
+
357
+ function truncatedObjects(tokens, start) {
358
+ const objects = [];
359
+ let index = skipWords(tokens, start + 1, ['table', 'only']);
360
+ while (index < tokens.length) {
361
+ const table = nameAt(tokens, index);
362
+ if (!table) break;
363
+ objects.push({ ...table, kind: 'table', line: tokens[start].line });
364
+ if (tokens[table.next]?.v !== ',') break;
365
+ index = skipWords(tokens, table.next + 1, ['only']);
366
+ }
367
+ return objects;
368
+ }
369
+
370
+ function destructiveObjects(statement) {
371
+ const objects = [];
372
+ for (let index = 0; index < statement.length; index += 1) {
373
+ if (sqlWord(statement[index], 'drop')) objects.push(...dropObjects(statement, index));
374
+ else if (sqlWord(statement[index], 'truncate')) objects.push(...truncatedObjects(statement, index));
375
+ else if (sqlWord(statement[index], 'alter') && sqlWord(statement[index + 1], 'table')) objects.push(...alterObjects(statement, index));
376
+ else if (sqlWord(statement[index], 'alter') && ['type', 'schema', 'view', 'sequence', 'function', 'procedure', 'index'].includes(statement[index + 1]?.v)) {
377
+ const object = nameAt(statement, index + 2);
378
+ if (object && statement.slice(object.next).some((token) => sqlWord(token, 'rename'))) objects.push({ ...object, kind: statement[index + 1].v, line: statement[index].line });
379
+ }
380
+ }
381
+ return objects;
382
+ }
383
+
384
+ /** Keep the static parts of one SQL expression together across interpolation and string concatenation. */
385
+ function sourceSqlText(ts, expression, checker, visiting = new Set()) {
386
+ const node = unwrapExpression(ts, expression);
387
+ if (ts.isStringLiteralLike(node)) return node.text;
388
+ if (ts.isTaggedTemplateExpression(node)) return sourceSqlText(ts, node.template, checker, visiting);
389
+ if (ts.isIdentifier(node)) {
390
+ const declaration = checker.getSymbolAtLocation(node)?.valueDeclaration;
391
+ if (!declaration || !ts.isVariableDeclaration(declaration) || !isConstVariable(ts, declaration)
392
+ || !declaration.initializer || visiting.has(declaration)) return null;
393
+ return sourceSqlText(ts, declaration.initializer, checker, new Set([...visiting, declaration]));
394
+ }
395
+ if (ts.isTemplateExpression(node)) return node.head.text + node.templateSpans.map((span) =>
396
+ `${sourceSqlText(ts, span.expression, checker, visiting) ?? ' '}${span.literal.text}`).join('');
397
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
398
+ return `${sourceSqlText(ts, node.left, checker, visiting) ?? ' '}${sourceSqlText(ts, node.right, checker, visiting) ?? ' '}`;
399
+ }
400
+ return null;
401
+ }
402
+
403
+ /** SET CONSTRAINTS names have no table qualifier; every matching table's constraint is affected. */
404
+ function globalConstraintNames(statement) {
405
+ const names = new Set();
406
+ for (let index = 0; index < statement.length; index += 1) {
407
+ if (!sqlWord(statement[index], 'set') || !sqlWord(statement[index + 1], 'constraints')) continue;
408
+ let at = index + 2;
409
+ while (at < statement.length && !sqlWord(statement[at], 'all')) {
410
+ const constraint = nameAt(statement, at);
411
+ if (!constraint) break;
412
+ names.add(constraint.leaf);
413
+ if (statement[constraint.next]?.v !== ',') break;
414
+ at = constraint.next + 1;
415
+ }
416
+ }
417
+ return names;
418
+ }
419
+
420
+ function sourceNameScopes(ts, text, file) {
421
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
422
+ const checker = lexicalChecker(ts, source);
423
+ const scopes = [];
424
+ const entityNames = /\.entity\.[cm]?tsx?$/.test(file) ? new Set() : null;
425
+ const addSql = (sql) => {
426
+ for (const statement of databaseSqlStatements(databaseSqlTokens(sql))) {
427
+ const names = new Set(statement.filter((token) => sqlName(token) !== null).map((token) => token.v));
428
+ if (entityNames) for (const name of names) entityNames.add(name);
429
+ else {
430
+ const constraints = new Set(statement.flatMap((token, index) =>
431
+ sqlWord(token, 'constraint') && sqlName(statement[index + 1]) !== null ? [statement[index + 1].v] : []));
432
+ scopes.push({ names, constraints, globalConstraints: globalConstraintNames(statement) });
433
+ }
434
+ }
435
+ };
436
+ const visit = (node) => {
437
+ const sql = sourceSqlText(ts, node, checker);
438
+ if (sql !== null) addSql(sql);
439
+ if (entityNames && ts.isIdentifier(node)) entityNames.add(node.text);
440
+ ts.forEachChild(node, visit);
441
+ };
442
+ visit(source);
443
+ return entityNames ? [{ names: entityNames, constraints: entityNames, globalConstraints: new Set() }] : scopes;
444
+ }
445
+
446
+ /**
447
+ * Whether a be/src source references a destroyed object. A column (`<schema>.<table>.<column>`) is referenced only by a source that
448
+ * names both its table and the column in one SQL statement or entity, so separate queries cannot supply each other's names.
449
+ * SQL constraints require explicit constraint names, while entities retain their table/decorator association.
450
+ * Other object kinds match their leaf name conservatively.
451
+ */
452
+ const referencesObject = (scopes, object) => scopes.some(({ names, constraints, globalConstraints }) =>
453
+ (object.kind === 'constraint' && globalConstraints.has(object.leaf))
454
+ || ((object.kind === 'constraint' ? constraints : names).has(object.leaf)
455
+ && (!['column', 'constraint'].includes(object.kind) || !object.table || names.has(object.table))));
456
+
457
+ function destructiveObjectFinding(file, object, contract, references) {
458
+ const reference = references.find(({ scopes }) => referencesObject(scopes, object));
459
+ if (contract && !reference) return null;
460
+ const reason = reference && contract ? `${reference.file} still references this object; remove the reference before the contract migration`
461
+ : 'use a *_contract_* migration after the application stops using this object';
462
+ return found(DB_MIGRATION_DESTRUCTIVE, file, `${file}:${object.line} destructively changes ${object.name}; ${reason}`,
463
+ { line: object.line, object: object.name, ...(reference ? { reference: reference.file } : {}) });
464
+ }
465
+
466
+ function destructiveFileFindings(file, text, references) {
467
+ const contract = /_contract_[a-z0-9]+(?:[-_][a-z0-9]+)*\.sql$/.test(file);
468
+ const seen = new Set();
469
+ const objects = databaseSqlStatements(databaseExecutableTokens(text)).flatMap(destructiveObjects);
470
+ return objects.flatMap((object) => {
471
+ const key = `${object.line}:${object.kind}:${object.name}`;
472
+ if (seen.has(key)) return [];
473
+ seen.add(key);
474
+ const finding = destructiveObjectFinding(file, object, contract, references);
475
+ return finding ? [finding] : [];
476
+ });
477
+ }
478
+
479
+ /** Destructive changes require a contract migration and no remaining SQL/entity reference in be/src. */
480
+ export function destructiveMigrationFindings({ repoRoot, migrations, files }) {
481
+ const sources = files.filter((file) => /^be\/src\/.+\.(?:sql|entity)\.[cm]?tsx?$/.test(file));
482
+ const ts = sources.length ? loadTypescript(repoRoot, HERE) : null;
483
+ if (sources.length && !ts) return [found(DB_MIGRATION_DESTRUCTIVE, 'hfs.json', 'destructive migration checks require the TypeScript parser to read be/src references')];
484
+ const references = sources.map((file) => ({ file, scopes: sourceNameScopes(ts, readText(repoRoot, file) ?? '', file) }));
485
+ return migrations.flatMap((file) => {
486
+ const text = readText(repoRoot, file);
487
+ return text === null ? [] : destructiveFileFindings(file, text, references);
488
+ });
489
+ }
@@ -1,5 +1,5 @@
1
1
  // slot-side-problems.mjs - declaration checks for one app side against the HFS manifest.
2
- import { optionalSlotProblems } from './declaration-slots.mjs';
2
+ import { backingProblems, optionalSlotProblems } from './declaration-slots.mjs';
3
3
 
4
4
  function sideAppKindProblems(manifest, side, apps) {
5
5
  const bad = [];
@@ -21,7 +21,7 @@ function requiredAppKindProblems(manifest, side, declaration) {
21
21
  /** Problems for one side declaration, preserving the manifest's checks and their order. */
22
22
  export function sideProblems(manifest, side, declaration) {
23
23
  const bad = sideAppKindProblems(manifest, side, declaration.apps);
24
- bad.push(...optionalSlotProblems(manifest, side, declaration));
24
+ bad.push(...optionalSlotProblems(manifest, side, declaration), ...backingProblems(manifest, side, declaration));
25
25
  for (const read of declaration.reads ?? []) if (!manifest.sides[side].reads.includes(read)) bad.push(`sides.${side}.reads names ${read}; ${side} may read only ${manifest.sides[side].reads.join(', ') || 'nothing of the other side'}`);
26
26
  bad.push(...requiredAppKindProblems(manifest, side, declaration));
27
27
  return bad;
@@ -116,6 +116,7 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
116
116
  apps: Object.freeze(s.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
117
117
  optionalSlots: Object.freeze([...(s.optionalSlots ?? [])]),
118
118
  patterns: Object.freeze([...(s.patterns ?? [])]), kinds: Object.freeze([...(s.kinds ?? [])]),
119
+ backing: Object.freeze([...(s.backing ?? [])]),
119
120
  connections: Object.freeze((s.connections ?? []).map(connectionView)),
120
121
  providers,
121
122
  reads: Object.freeze([...(s.reads ?? [])]),
@@ -14,7 +14,7 @@ const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(tex
14
14
  *
15
15
  * @param {object} opened - The result of `openHfs` (manifest, repo and resolver methods).
16
16
  * @param {string} repoRoot - The absolute repository root.
17
- * @returns {object} The frozen view: profile, apps, connections, ruleParams, relative, classify, slotOf, tierOf, ownerOf, slot.
17
+ * @returns {object} The frozen view: profile, apps, connections, patterns, ruleParams, relative, classify, slotOf, tierOf, ownerOf, slot.
18
18
  */
19
19
  function hfsView(opened, repoRoot) {
20
20
  const rel = (file) => {
@@ -35,6 +35,7 @@ function hfsView(opened, repoRoot) {
35
35
  edition: opened.repo.edition ?? 'full',
36
36
  apps: opened.repo.apps,
37
37
  connections: opened.repo.connections,
38
+ patterns: Object.freeze([...(opened.repo.patterns ?? [])]),
38
39
  ruleParams: opened.ruleParams(),
39
40
  /** The repository-relative, forward-slash form of a linted filename. */
40
41
  relative: rel,
@@ -0,0 +1,71 @@
1
+ // in-order.mjs — asynchronous work done strictly one after another, named by what it does. A sequential `await` in a
2
+ // loop is often the point (ordered ledger writes, one Orca or git call at a time, a rate limit, stop at the first
3
+ // failure or match); these helpers state that intent at the call site so no loop body carries an `await`. Each one
4
+ // starts item n+1 only after item n has settled, lets the first rejection (or a synchronous throw of `fn`) stop the run
5
+ // with later items never started, pulls a lazy iterable one item at a time, and closes it on an early stop, the way
6
+ // `for (const x of xs) await fn(x)` does. All of them run on the one `walk` below: promise callbacks, not an async
7
+ // recursion, so the stack and the memory stay flat however long the input is.
8
+ // Provably independent work that needs no order, short-circuit or error contract may use `Promise.all` instead.
9
+
10
+ /** Close `iterator` the way a `for...of` leaves it: `return()` when it has one. */
11
+ const close = (iterator) => iterator.return?.();
12
+
13
+ /**
14
+ * Drive `visit(item, index)` over `items` one at a time. `decide(outcome, item)` runs after each settled visit: a
15
+ * `{ value }` box ends the walk with that value, anything else goes on. Resolves with the box value, or `undefined` when
16
+ * the items ran out; rejects with the first rejection or throw, after closing the iterator.
17
+ */
18
+ const walk = (items, visit, decide) => new Promise((resolve, reject) => {
19
+ const iterator = items[Symbol.iterator]();
20
+ let index = 0;
21
+ const fail = (error) => {
22
+ try { close(iterator); } catch { /* the failure being reported wins, as it does in a for...of */ }
23
+ reject(error);
24
+ };
25
+ const stop = (value) => {
26
+ try { close(iterator); } catch (error) { reject(error); return; }
27
+ resolve(value);
28
+ };
29
+ const advance = () => {
30
+ let step, pending;
31
+ try {
32
+ step = iterator.next();
33
+ if (step.done) { resolve(undefined); return; }
34
+ pending = visit(step.value, index++);
35
+ } catch (error) {
36
+ if (step === undefined || step.done) reject(error); else fail(error);
37
+ return;
38
+ }
39
+ Promise.resolve(pending).then((outcome) => {
40
+ let box;
41
+ try { box = decide(outcome, step.value); } catch (error) { fail(error); return; }
42
+ if (box) stop(box.value); else advance();
43
+ }, fail);
44
+ };
45
+ advance();
46
+ });
47
+
48
+ const never = () => undefined;
49
+
50
+ /** `for (const x of items) await fn(x, index)`: resolves undefined once every item has run, one at a time. */
51
+ export const eachInOrder = (items, fn) => walk(items, fn, never);
52
+
53
+ /** The sequential map: `fn(x, index)` for each item in turn, resolving the results in item order. */
54
+ export const mapInOrder = (items, fn) => {
55
+ const results = [];
56
+ return walk(items, fn, (outcome) => { results.push(outcome); }).then(() => results);
57
+ };
58
+
59
+ /** The first item whose `predicate(x, index)` resolves truthy (undefined when none); later items never run. */
60
+ export const findInOrder = (items, predicate) => walk(items, predicate, (outcome, item) => (outcome ? { value: item } : undefined));
61
+
62
+ /**
63
+ * `for (;;) await step(attempt)`: `step(attempt)` runs again, after the previous run settled, until it resolves
64
+ * something other than undefined; that value (null included) is the result. A poll or retry returns undefined to go
65
+ * on and its answer to stop. `attempt` counts from 0.
66
+ */
67
+ export const repeatInOrder = (step) => walk(forever(), step, (outcome) => (outcome === undefined ? undefined : { value: outcome }));
68
+
69
+ function* forever() {
70
+ for (let attempt = 0; ; attempt++) yield attempt;
71
+ }