@ultimat3/cli 13.0.0 → 15.0.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/CLAUDE.md +121 -1
- package/package.json +29 -29
- package/src/cmd-db.ts +11 -3
- package/src/cmd-doctor.ts +2 -2
- package/src/db-generate.ts +53 -3
- package/src/db-ungeneratable.ts +108 -0
- package/src/e2e-dom-fixture.ts +117 -0
- package/src/e2e-driver.ts +78 -0
- package/src/e2e-errors.ts +103 -0
- package/src/e2e-evaluate.ts +156 -0
- package/src/e2e-locator.ts +86 -0
- package/src/e2e-page.ts +124 -0
- package/src/e2e-selection.ts +182 -0
- package/src/error-codes.ts +35 -0
- package/src/index.ts +32 -0
- package/src/mcp-errors.ts +26 -0
- package/src/schema-diff.ts +275 -0
- package/src/schema-drift.ts +146 -0
- package/src/verify-checks.ts +10 -3
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Single responsibility: the drift a hash cannot see — what the app's entities declare NOW against
|
|
2
|
+
// the schema the newest migration wrote down. No database, so the gate runs it in a CI with nothing
|
|
3
|
+
// listening, exactly as the source-hash half does.
|
|
4
|
+
//
|
|
5
|
+
// It exists because the hash half compares a schema-source hash to a `.hash` sidecar and never
|
|
6
|
+
// reads what the migration RECORDED: `dummy/social-media-clone` sat green on `drift` while nine
|
|
7
|
+
// declared CHECK constraints had never reached any database, and a squash produced a migration
|
|
8
|
+
// missing ten invariants and nine defaults with the gate green over it. Nothing that hashes source
|
|
9
|
+
// can see either, because the source did not move.
|
|
10
|
+
|
|
11
|
+
// why: Bun exposes no synchronous "does this path exist" primitive, and this is a probe rather
|
|
12
|
+
// than a read — `Bun.file().exists()` answers about a file where the question is about a directory.
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
// why: Bun ships no path joiner of its own, and this path is built to be probed, never opened.
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
17
|
+
import type { EntityDescriptionLike, UnrenderedDeclaration } from '@ultimat3/db';
|
|
18
|
+
import { declaredSchema, snapshotOf, unrenderedOf } from '@ultimat3/db';
|
|
19
|
+
import { describeEntities } from '@ultimat3/entity';
|
|
20
|
+
import { loadApp } from './app-load';
|
|
21
|
+
import { checkSourceDrift, DB_PACKAGE } from './drift';
|
|
22
|
+
import { MIGRATIONS_DIR, readMigrations } from './migrations';
|
|
23
|
+
import type { Finding } from './output';
|
|
24
|
+
import { diffDeclaredSchema, type SchemaDifference } from './schema-diff';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the app declares, or `undefined` when the app would not load. Injected so this module's
|
|
28
|
+
* rules are testable against hand-built descriptions with no app on disk, and so the ONE thing a
|
|
29
|
+
* caller must never do — read a short registry as the whole schema — has a single seam.
|
|
30
|
+
*/
|
|
31
|
+
export type DeclaredEntities = (
|
|
32
|
+
root: string,
|
|
33
|
+
) => Promise<readonly EntityDescriptionLike[] | undefined>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `undefined`, never a short list. A module that will not import leaves the registry missing the
|
|
37
|
+
* entities it would have registered, and a short registry against a whole snapshot reads as "every
|
|
38
|
+
* one of these tables was dropped" — a DROP per table, handed out as the fix, for a syntax error in
|
|
39
|
+
* one file. `loadApp`'s findings are reported by the steps that own them.
|
|
40
|
+
*/
|
|
41
|
+
const appEntities: DeclaredEntities = async (root) => {
|
|
42
|
+
const app = await loadApp(root);
|
|
43
|
+
if (app.findings.length > 0) return undefined;
|
|
44
|
+
return describeEntities();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `x db gen` is the repair for both directions — and it is the WRONG instruction while the
|
|
49
|
+
* declaration holds something this generator cannot write down, because regenerating drops it
|
|
50
|
+
* silently and the result is green. In that state the unrendered entry's own `fix:` is the
|
|
51
|
+
* instruction: `@ultimat3/db` owns that wording, and a second one here would drift from it.
|
|
52
|
+
*/
|
|
53
|
+
function repairFix(
|
|
54
|
+
unrendered: readonly UnrenderedDeclaration[],
|
|
55
|
+
difference: SchemaDifference,
|
|
56
|
+
): string {
|
|
57
|
+
// The entry ABOUT this difference first, then any entry at all. Reading `unrendered[0]`
|
|
58
|
+
// unconditionally meant that in any app carrying an unrendered DEFAULT, every difference —
|
|
59
|
+
// a dropped CHECK included — was answered with the default's edit, which is an instruction for
|
|
60
|
+
// a different problem in a different file.
|
|
61
|
+
const named = unrendered.find(
|
|
62
|
+
(entry) => entry.table === difference.table && entry.name === difference.name,
|
|
63
|
+
);
|
|
64
|
+
// The entry ABOUT this difference carries the edit that repairs it, so it is the whole answer.
|
|
65
|
+
if (named !== undefined) return named.fix;
|
|
66
|
+
// Otherwise `x db gen` is still unsafe — it would drop what the other entries name — but the
|
|
67
|
+
// reader must NOT be handed a different declaration's edit as the instruction for this one.
|
|
68
|
+
// Reading `unrendered[0]` did exactly that: a column default and an index each got
|
|
69
|
+
// `invariant('org_slug_shape', …)`, an edit in another file about another rule.
|
|
70
|
+
// No `x db gen` in this branch, and that is the point: while ANY declaration reaches no SQL,
|
|
71
|
+
// regenerating drops it, so the command is the wrong instruction for every difference in the
|
|
72
|
+
// app — not only for the one the entry names.
|
|
73
|
+
const blocker = unrendered[0];
|
|
74
|
+
if (blocker !== undefined) {
|
|
75
|
+
return `${blocker.fix} # ${blocker.table}.${blocker.name} reaches no SQL, so regenerating would drop it; repair that before recording ${difference.name}`;
|
|
76
|
+
}
|
|
77
|
+
// "record", not "add": one finding covers a declaration the migrations never carried AND one they
|
|
78
|
+
// carry differently, so `add` would be a wrong migration name for half of them. `undeclared` gets
|
|
79
|
+
// both branches, in the order they are safe — regenerating emits the DROP, and the declaration
|
|
80
|
+
// may have been LOST rather than removed, which is the whole reason this direction is its own
|
|
81
|
+
// finding.
|
|
82
|
+
return difference.direction === 'unmigrated'
|
|
83
|
+
? `x db gen "record ${difference.name}"`
|
|
84
|
+
: `x db gen "drop ${difference.name}" # or re-declare ${difference.name} on the entity`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Two directions, two codes, because they are two repairs. `unmigrated` means the database will
|
|
89
|
+
* never get what the app declares; `undeclared` means the database holds what nothing declares.
|
|
90
|
+
* One "drift" verdict over both teaches a reader neither.
|
|
91
|
+
*/
|
|
92
|
+
function findingFor(difference: SchemaDifference, fix: string): Finding {
|
|
93
|
+
const cause = `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
|
|
94
|
+
return difference.direction === 'unmigrated'
|
|
95
|
+
? { code: 'X_DB_SCHEMA_UNMIGRATED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR }
|
|
96
|
+
: { code: 'X_DB_SCHEMA_UNDECLARED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One finding per difference, never one per table: each names one declaration and one edit, and a
|
|
101
|
+
* grouped finding would hand a reader nine constraint names under one instruction.
|
|
102
|
+
*
|
|
103
|
+
* Three conditions are deliberately left to their own reporters, because each already has a code
|
|
104
|
+
* and a remedy: an app with no `packages/db`, an app whose first migration has not been generated
|
|
105
|
+
* (`checkSourceDrift`, `x db gen "initial"`), and a newest migration carrying no sidecar
|
|
106
|
+
* (`X_MIGRATION_SNAPSHOT_MISSING`, whose two-branch fix is `@ultimat3/db`'s).
|
|
107
|
+
*/
|
|
108
|
+
export async function checkSnapshotDrift(
|
|
109
|
+
root: string,
|
|
110
|
+
declared: DeclaredEntities = appEntities,
|
|
111
|
+
): Promise<readonly Finding[]> {
|
|
112
|
+
if (!existsSync(join(root, DB_PACKAGE))) return [];
|
|
113
|
+
const entities = await declared(root);
|
|
114
|
+
if (entities === undefined) return [];
|
|
115
|
+
const migrations = await readMigrations(root);
|
|
116
|
+
if (migrations.length === 0) return [];
|
|
117
|
+
const recorded = declaredSchema(migrations);
|
|
118
|
+
if (recorded === undefined) return [];
|
|
119
|
+
const differences = diffDeclaredSchema(snapshotOf(entities), recorded);
|
|
120
|
+
if (differences.length === 0) return [];
|
|
121
|
+
// `recorded` and not the entities alone: whether an `assert` is a LOSS depends on whether a
|
|
122
|
+
// migration recorded a CHECK for it, which only the sidecar knows.
|
|
123
|
+
const unrendered = unrenderedOf(entities, recorded);
|
|
124
|
+
return differences.map((difference) => findingFor(difference, repairFix(unrendered, difference)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The whole no-database drift answer, for the gate step and for `x doctor` — one composition, so
|
|
129
|
+
* the two can never disagree about an app.
|
|
130
|
+
*
|
|
131
|
+
* The source hash STAYS, and it runs second. It catches a class this comparison cannot see at all:
|
|
132
|
+
* a seed, a repo helper or a TS-only invariant moving under `packages/db/src` with no physical
|
|
133
|
+
* schema behind it, which is what `reconcileSchemaHash` exists to re-record. It is suppressed when
|
|
134
|
+
* the snapshot half found something, because both then answer one condition and only one of them
|
|
135
|
+
* is an instruction — `schema hashes to 3f2a, newest migration recorded 91bc` names no constraint,
|
|
136
|
+
* no column and no table.
|
|
137
|
+
*/
|
|
138
|
+
export async function checkMigrationDrift(
|
|
139
|
+
root: string,
|
|
140
|
+
declared: DeclaredEntities = appEntities,
|
|
141
|
+
hashDrift: (root: string) => Promise<readonly Finding[]> = checkSourceDrift,
|
|
142
|
+
): Promise<readonly Finding[]> {
|
|
143
|
+
const snapshot = await checkSnapshotDrift(root, declared);
|
|
144
|
+
if (snapshot.length > 0) return snapshot;
|
|
145
|
+
return hashDrift(root);
|
|
146
|
+
}
|
package/src/verify-checks.ts
CHANGED
|
@@ -23,8 +23,8 @@ import { policyFindings } from './app-permissions';
|
|
|
23
23
|
import { APP_CONFIG_FILE } from './app-root';
|
|
24
24
|
import { checkBudgets, readBuildStats } from './budgets';
|
|
25
25
|
import { checkDestructiveMigrations } from './db-destructive';
|
|
26
|
+
import { checkUngeneratableMigrations } from './db-ungeneratable';
|
|
26
27
|
import { checkDocumentStyles, documentSurfaces } from './document-styles';
|
|
27
|
-
import { checkSourceDrift } from './drift';
|
|
28
28
|
import { checkErrorCodeResolution, checkErrorFixReport } from './error-contract';
|
|
29
29
|
import { guardFindings } from './guards';
|
|
30
30
|
import { catalogFindings } from './i18n-registration';
|
|
@@ -32,6 +32,7 @@ import { liveRouteFindings } from './live-routes';
|
|
|
32
32
|
import { msg } from './messages';
|
|
33
33
|
import type { Finding } from './output';
|
|
34
34
|
import { findingFrom } from './output';
|
|
35
|
+
import { checkMigrationDrift } from './schema-drift';
|
|
35
36
|
import { scanSiteMeta } from './seo-meta';
|
|
36
37
|
import { floorProblemFindings, readVerifyFloor } from './verify-floor';
|
|
37
38
|
import type { VerifyStep } from './verify-step';
|
|
@@ -147,7 +148,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
147
148
|
...TEST_STEPS,
|
|
148
149
|
{
|
|
149
150
|
name: 'drift',
|
|
150
|
-
summary:
|
|
151
|
+
summary:
|
|
152
|
+
'entity declarations vs migrations, every destructive statement declared, and every statement no declaration carries',
|
|
151
153
|
// Only an app owns migrations; a package monorepo's `packages/db` is the driver, not a schema.
|
|
152
154
|
// Source, not database: the gate runs in CI with nothing listening, and the database half is
|
|
153
155
|
// the post-migrate verification `runMigrations` performs where a connection is already open.
|
|
@@ -158,8 +160,13 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
158
160
|
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
159
161
|
run: async (ctx) =>
|
|
160
162
|
fromFindings([
|
|
161
|
-
...(await
|
|
163
|
+
...(await checkMigrationDrift(ctx.root)),
|
|
162
164
|
...(await checkDestructiveMigrations(ctx.root)),
|
|
165
|
+
// The third rail, and the one the other two cannot see: a hand-written statement is
|
|
166
|
+
// recorded by no snapshot and hashed by no source, so both halves above are green over SQL
|
|
167
|
+
// a squash silently drops. Same directory, same reader, no database — this step's own
|
|
168
|
+
// question, which is why it is not an eighteenth step.
|
|
169
|
+
...(await checkUngeneratableMigrations(ctx.root)),
|
|
163
170
|
]),
|
|
164
171
|
},
|
|
165
172
|
{
|