@everystack/cli 0.4.43 → 0.4.45
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/package.json +2 -2
- package/src/cli/authz-adoption-class.ts +265 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +146 -0
- package/src/cli/authz-compile.ts +128 -18
- package/src/cli/authz-contract.ts +120 -25
- package/src/cli/authz-derive.ts +254 -22
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +19 -27
- package/src/cli/commands/db-generate.ts +50 -0
- package/src/cli/commands/db-plan.ts +34 -2
- package/src/cli/commands/db-pull.ts +52 -4
- package/src/cli/edge-plan.ts +14 -2
- package/src/cli/index.ts +1 -17
- package/src/cli/model-render.ts +80 -14
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-fingerprint.ts +88 -36
package/src/cli/model-render.ts
CHANGED
|
@@ -81,6 +81,10 @@ export interface RenderOptions {
|
|
|
81
81
|
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
82
82
|
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
83
83
|
derived?: DerivedRenderResult;
|
|
84
|
+
/** The `--derived-out` case: the layer lives in its own file, so the barrel imports and
|
|
85
|
+
* wires it instead of embedding it. Mutually exclusive with `derived` in practice —
|
|
86
|
+
* passing neither is what silently produced `defineModule({ models })`. */
|
|
87
|
+
externalDerived?: ExternalDerived;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/**
|
|
@@ -209,8 +213,25 @@ function bareName(table: string): string {
|
|
|
209
213
|
return table.replace(/^[^.]+\./, '');
|
|
210
214
|
}
|
|
211
215
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Migration infrastructure a pull must never render as a model, whatever schema it landed in.
|
|
218
|
+
*
|
|
219
|
+
* A brownfield pull adopts a database another migration tool still owns, and that tool's
|
|
220
|
+
* bookkeeping is not the app's data. Rendering it as a model makes everystack GOVERN it: the
|
|
221
|
+
* compiled contract declares an admin policy, and the adopter's first plan carries a
|
|
222
|
+
* `CREATE POLICY` on a table nobody asked us to manage. The journal belongs to whoever writes
|
|
223
|
+
* it — drizzle-kit's `__drizzle_migrations`, Rails' `schema_migrations` +
|
|
224
|
+
* `ar_internal_metadata`.
|
|
225
|
+
*
|
|
226
|
+
* Deliberately a SHORT list of names that are unambiguously a migration tool's own state. It
|
|
227
|
+
* is not a heuristic and must not become one: a real app table wrongly matched here silently
|
|
228
|
+
* loses its declared authorization.
|
|
229
|
+
*/
|
|
230
|
+
const INFRASTRUCTURE_TABLES = new Set([
|
|
231
|
+
'__drizzle_migrations', // drizzle-kit
|
|
232
|
+
'schema_migrations', // Rails / ActiveRecord
|
|
233
|
+
'ar_internal_metadata', // Rails / ActiveRecord
|
|
234
|
+
]);
|
|
214
235
|
|
|
215
236
|
/**
|
|
216
237
|
* The tables a pull renders: the requested schema, minus migration infrastructure.
|
|
@@ -505,22 +526,42 @@ function importHeader(body: string, extra: string[] = []): string {
|
|
|
505
526
|
return imports.join('\n');
|
|
506
527
|
}
|
|
507
528
|
|
|
508
|
-
/**
|
|
509
|
-
|
|
529
|
+
/**
|
|
530
|
+
* The barrel's module wrapper: models always; sequences/derived when the pull found any (B5).
|
|
531
|
+
*
|
|
532
|
+
* `external` is the `--derived-out` case — the descriptors live in their own file, so the
|
|
533
|
+
* barrel IMPORTS the arrays rather than re-declaring them. Before this the barrel simply
|
|
534
|
+
* omitted them, which meant the same pull that wrote 120 descriptors emitted a
|
|
535
|
+
* `defineModule({ models })` that excluded every one, and `db:plan` then compared against a
|
|
536
|
+
* fraction of the database while reporting a confident statement count. A plan scoped to
|
|
537
|
+
* something narrower than the reader assumes is the same false-completeness failure as a
|
|
538
|
+
* classification that defaults to safe.
|
|
539
|
+
*/
|
|
540
|
+
function moduleFooter(
|
|
541
|
+
modelNames: string[],
|
|
542
|
+
derived?: DerivedRenderResult,
|
|
543
|
+
multiline = false,
|
|
544
|
+
external?: ExternalDerived,
|
|
545
|
+
): string {
|
|
510
546
|
// A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
|
|
511
547
|
// render (topo-ordered with the objects around it) but its name belongs in `models`.
|
|
512
|
-
const
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
548
|
+
const hasMaterialized = external ? external.materializedTables : Boolean(derived?.materializedTableNames.length);
|
|
549
|
+
const inlineNames = external ? [] : (derived?.materializedTableNames ?? []);
|
|
550
|
+
const allModels = [...modelNames, ...inlineNames];
|
|
551
|
+
const rendered = multiline
|
|
552
|
+
? `export const models = [\n${allModels.map((n) => ` ${n},`).join('\n')}\n${external && hasMaterialized ? ' ...materializedTables,\n' : ''}];`
|
|
553
|
+
: `export const models = [${[...allModels, ...(external && hasMaterialized ? ['...materializedTables'] : [])].join(', ')}];`;
|
|
554
|
+
const parts = [rendered];
|
|
517
555
|
const keys = ['models'];
|
|
518
|
-
|
|
519
|
-
|
|
556
|
+
|
|
557
|
+
const hasSequences = external ? external.sequences : Boolean(derived?.sequenceNames.length);
|
|
558
|
+
const hasDerived = external ? external.derived : Boolean(derived?.names.length);
|
|
559
|
+
if (hasSequences) {
|
|
560
|
+
if (!external) parts.push(`export const sequences = [${derived!.sequenceNames.join(', ')}];`);
|
|
520
561
|
keys.push('sequences');
|
|
521
562
|
}
|
|
522
|
-
if (
|
|
523
|
-
parts.push(`export const derived = [${derived
|
|
563
|
+
if (hasDerived) {
|
|
564
|
+
if (!external) parts.push(`export const derived = [${derived!.names.join(', ')}];`);
|
|
524
565
|
keys.push('derived');
|
|
525
566
|
}
|
|
526
567
|
parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
|
|
@@ -528,6 +569,24 @@ function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multi
|
|
|
528
569
|
return parts.join('\n\n');
|
|
529
570
|
}
|
|
530
571
|
|
|
572
|
+
/** What an external (`--derived-out`) derived file exports, so the barrel can wire it. */
|
|
573
|
+
export interface ExternalDerived {
|
|
574
|
+
/** Import specifier as written in the barrel, e.g. `'./derived'`. */
|
|
575
|
+
specifier: string;
|
|
576
|
+
sequences: boolean;
|
|
577
|
+
materializedTables: boolean;
|
|
578
|
+
derived: boolean;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** The named exports to pull out of an external derived file, in a stable order. */
|
|
582
|
+
function externalDerivedNames(e: ExternalDerived): string[] {
|
|
583
|
+
const names: string[] = [];
|
|
584
|
+
if (e.derived) names.push('derived');
|
|
585
|
+
if (e.materializedTables) names.push('materializedTables');
|
|
586
|
+
if (e.sequences) names.push('sequences');
|
|
587
|
+
return names;
|
|
588
|
+
}
|
|
589
|
+
|
|
531
590
|
/**
|
|
532
591
|
* Render a whole snapshot as a single Models module: the import, one block per table (scoped
|
|
533
592
|
* to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
|
|
@@ -610,14 +669,21 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
610
669
|
// their own model.
|
|
611
670
|
const names = tables.map((t) => modelVarName(t.table));
|
|
612
671
|
const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
|
|
672
|
+
const ext = opts.externalDerived;
|
|
673
|
+
const extNames = ext ? externalDerivedNames(ext) : [];
|
|
613
674
|
const index = [
|
|
614
675
|
[
|
|
615
676
|
`import { ${modelSymbols.join(', ')} } from '@everystack/model';`,
|
|
677
|
+
// The --derived-out layer, imported rather than re-declared — the barrel must WIRE it
|
|
678
|
+
// or db:plan silently compares against models only.
|
|
679
|
+
...(extNames.length ? [`import { ${extNames.join(', ')} } from '${ext!.specifier}';`] : []),
|
|
616
680
|
...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
|
|
617
681
|
].join('\n'),
|
|
618
682
|
`export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
|
|
683
|
+
// Re-exported so the barrel remains the one place that describes the database.
|
|
684
|
+
...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
|
|
619
685
|
...(opts.derived?.block ? [opts.derived.block] : []),
|
|
620
|
-
moduleFooter(names, opts.derived, true),
|
|
686
|
+
moduleFooter(names, opts.derived, true, ext),
|
|
621
687
|
].join('\n\n');
|
|
622
688
|
files.push({ file: 'index.ts', source: index + '\n' });
|
|
623
689
|
|
package/src/cli/output.ts
CHANGED
|
@@ -11,11 +11,11 @@ export function step(msg: string): void {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function success(msg: string): void {
|
|
14
|
-
|
|
14
|
+
say(` \u2713 ${msg}`);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function warn(msg: string): void {
|
|
18
|
-
|
|
18
|
+
say(` ! ${msg}`);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function fail(msg: string): void {
|
|
@@ -23,5 +23,27 @@ export function fail(msg: string): void {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function info(msg: string): void {
|
|
26
|
-
|
|
26
|
+
say(` ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* When a command makes stdout the DATA channel (`db:plan --out -`), every human-readable
|
|
31
|
+
* line has to move to stderr \u2014 otherwise the report is interleaved with the artifact and
|
|
32
|
+
* the stdout form cannot be piped to anything. `step` and `fail` already write to stderr
|
|
33
|
+
* on that principle; this extends it to the rest, on demand.
|
|
34
|
+
*
|
|
35
|
+
* Off by default, so no command's output moves unless it asks. One-way and process-wide:
|
|
36
|
+
* a CLI process runs exactly one command, and the choice is made once, before it prints.
|
|
37
|
+
*/
|
|
38
|
+
let stdoutReserved = false;
|
|
39
|
+
|
|
40
|
+
/** Declare stdout the data channel: human-readable output moves to stderr from here on. */
|
|
41
|
+
export function reserveStdoutForData(): void {
|
|
42
|
+
stdoutReserved = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Human-readable output \u2014 stdout normally, stderr once stdout is reserved for data. */
|
|
46
|
+
function say(line: string): void {
|
|
47
|
+
if (stdoutReserved) console.error(line);
|
|
48
|
+
else console.log(line);
|
|
27
49
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's flag parser.
|
|
3
|
+
*
|
|
4
|
+
* `--key value` / `-k value`, with a value-less flag defaulting to the string `'true'`.
|
|
5
|
+
* Extracted from the entry point so it can be tested without executing the CLI.
|
|
6
|
+
*
|
|
7
|
+
* The subtlety is what counts as "the next argument is a VALUE, not the next flag".
|
|
8
|
+
* Two legitimate values look like flags, or like nothing, to a naive check:
|
|
9
|
+
*
|
|
10
|
+
* - A bare `-` is the conventional stdout sentinel. `db:plan --out -` documents itself
|
|
11
|
+
* as "print to stdout"; read as a value-less flag it became `out=true` and the
|
|
12
|
+
* command wrote a file literally named `true`.
|
|
13
|
+
* - The EMPTY string is a value. `db:approvers --set ''` is the documented way to
|
|
14
|
+
* declare an empty approver set and DISABLE destructive applies on a stage; read as
|
|
15
|
+
* value-less it became `set=true`, and the command refused with "--set needs a
|
|
16
|
+
* value" — so the documented way to turn the gate off could not be typed.
|
|
17
|
+
*
|
|
18
|
+
* Both are the same mistake: treating an unusual-looking value as an absent one. A flag
|
|
19
|
+
* is an argument that starts with `-` AND is longer than one character; everything else,
|
|
20
|
+
* including `''`, is a value.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Is this argument a flag (as opposed to a value)? A bare `-` is a value. */
|
|
24
|
+
function isFlag(arg: string): boolean {
|
|
25
|
+
return arg.startsWith('-') && arg.length > 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse `--key value` / `-k value` pairs; a flag with no value becomes `'true'`. */
|
|
29
|
+
export function parseFlags(args: string[]): Record<string, string> {
|
|
30
|
+
const flags: Record<string, string> = {};
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const arg = args[i];
|
|
33
|
+
if (!isFlag(arg)) continue;
|
|
34
|
+
const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
|
|
35
|
+
const next = args[i + 1];
|
|
36
|
+
flags[key] = next !== undefined && !isFlag(next) ? args[++i] : 'true';
|
|
37
|
+
}
|
|
38
|
+
return flags;
|
|
39
|
+
}
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
48
|
import { createHash } from 'node:crypto';
|
|
49
|
+
import { canonicalAuthz } from './authz-canonical.js';
|
|
49
50
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
50
51
|
import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
|
|
51
52
|
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
@@ -62,7 +63,17 @@ import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
|
62
63
|
// STANDALONE SEQUENCES enter the canonical form (declared via defineSequence,
|
|
63
64
|
// introspected from pg_sequence minus serial-owned) — a coverage expansion; the
|
|
64
65
|
// `sequences` key appears only when any exist, so sequence-free states hash unchanged.
|
|
65
|
-
|
|
66
|
+
// v4: the AUTHZ canonical form became the reconciler's equivalence relation instead of a
|
|
67
|
+
// name-keyed transcript of the catalog. Policy NAMES leave the hash (a brownfield database
|
|
68
|
+
// names its policies whatever its previous migration tool named them, so hashing the name made
|
|
69
|
+
// a policy carrying the declared authorization read as a different state); policies expand
|
|
70
|
+
// across their roles as a MULTISET (so one policy TO a,b hashes equal to two identical ones TO
|
|
71
|
+
// a and TO b, and duplicates never collapse); PUBLIC stays a single sentinel and is never
|
|
72
|
+
// enumerated; and grants are filtered to the GOVERNED grantees, because the reconciler leaves
|
|
73
|
+
// an ungoverned migrator/ETL role alone and a hash that counts it can never converge.
|
|
74
|
+
// Together these restore the identity the format exists for: fingerprints match exactly when
|
|
75
|
+
// db:generate is a no-op.
|
|
76
|
+
export const FINGERPRINT_VERSION = 4;
|
|
66
77
|
|
|
67
78
|
// ---------------------------------------------------------------------------
|
|
68
79
|
// Canonical form.
|
|
@@ -115,40 +126,18 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
|
|
|
115
126
|
};
|
|
116
127
|
}
|
|
117
128
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
Object.entries(contract.columnGrants)
|
|
131
|
-
.map(([role, byPriv]) => [
|
|
132
|
-
role,
|
|
133
|
-
Object.fromEntries(
|
|
134
|
-
Object.entries(byPriv)
|
|
135
|
-
.map(([priv, cols]) => [priv, [...cols].sort()] as const)
|
|
136
|
-
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
137
|
-
),
|
|
138
|
-
] as const)
|
|
139
|
-
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
140
|
-
),
|
|
141
|
-
}
|
|
142
|
-
: {}),
|
|
143
|
-
policies: byKey(
|
|
144
|
-
contract.policies.map((p) => ({
|
|
145
|
-
name: p.name, command: p.command, roles: [...p.roles].sort(),
|
|
146
|
-
permissive: p.permissive, using: p.using, check: p.check,
|
|
147
|
-
})),
|
|
148
|
-
(p) => p.name,
|
|
149
|
-
),
|
|
150
|
-
// The handler-side columns exposure block is NOT a database fact — excluded.
|
|
151
|
-
};
|
|
129
|
+
/**
|
|
130
|
+
* The authorization slice of the canonical form.
|
|
131
|
+
*
|
|
132
|
+
* Delegates to `authz-canonical`, which the reconciler and the differ read too. Keeping a
|
|
133
|
+
* private copy here is what let the fingerprint drift out of step with the reconciler and
|
|
134
|
+
* report permanent drift on a database that had nothing to reconcile.
|
|
135
|
+
*/
|
|
136
|
+
function canonicalAuthzTable(
|
|
137
|
+
contract: TableContract,
|
|
138
|
+
governed?: ReadonlySet<string>,
|
|
139
|
+
): Record<string, unknown> {
|
|
140
|
+
return canonicalAuthz(contract, governed);
|
|
152
141
|
}
|
|
153
142
|
|
|
154
143
|
export interface CanonicalState {
|
|
@@ -171,6 +160,17 @@ export interface CanonicalizeOptions {
|
|
|
171
160
|
/** Restrict the state to these schemas — the content address of ONE schema (e.g. a
|
|
172
161
|
* schema-scoped artifact) instead of the whole database. Omitted = every schema. */
|
|
173
162
|
schemas?: string[];
|
|
163
|
+
/**
|
|
164
|
+
* The grantees the models govern. PASS THIS WHENEVER HASHING A LIVE CONTRACT.
|
|
165
|
+
*
|
|
166
|
+
* The reconciler leaves an ungoverned grantee alone by design, so a live grant to a
|
|
167
|
+
* migrator or ETL role is never reconciled. A hash that counts it describes a state the
|
|
168
|
+
* models can never reach: the operator gets "nothing to do" from the plan and "you have
|
|
169
|
+
* drifted" from the gate, with no action in between that resolves it. Omitted means "hash
|
|
170
|
+
* every grantee", which is correct only for a contract compiled FROM the models, where
|
|
171
|
+
* every grantee is governed by construction.
|
|
172
|
+
*/
|
|
173
|
+
governedRoles?: ReadonlySet<string>;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
export function canonicalizeState(
|
|
@@ -208,7 +208,10 @@ export function canonicalizeState(
|
|
|
208
208
|
.map((e) => ({ name: e.name, values: e.values })),
|
|
209
209
|
(e) => e.name,
|
|
210
210
|
),
|
|
211
|
-
authz: byKey(
|
|
211
|
+
authz: byKey(
|
|
212
|
+
authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzTable(t, opts.governedRoles)),
|
|
213
|
+
(t) => String(t.table),
|
|
214
|
+
),
|
|
212
215
|
...(sequences.length ? { sequences } : {}),
|
|
213
216
|
};
|
|
214
217
|
}
|
|
@@ -305,3 +308,52 @@ export interface UnfingerprintedObject {
|
|
|
305
308
|
export function mapUnfingerprintedRows(rows: Array<{ kind: unknown; identity: unknown }>): UnfingerprintedObject[] {
|
|
306
309
|
return rows.map((r) => ({ kind: String(r.kind), identity: String(r.identity) }));
|
|
307
310
|
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Comparing a STORED fingerprint against live reality.
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A fingerprint as recorded in an artifact — a plan, a baseline, an export stamp.
|
|
319
|
+
*
|
|
320
|
+
* `v` is the format it was computed under. It must be stored ALONGSIDE the hash, not merely
|
|
321
|
+
* mixed into it: a hash alone cannot say why it differs, and the difference between "you have
|
|
322
|
+
* drifted" and "the format changed" is the difference between an operator hunting a phantom
|
|
323
|
+
* change and an operator running one re-baseline.
|
|
324
|
+
*/
|
|
325
|
+
export interface StoredFingerprint {
|
|
326
|
+
hash: string;
|
|
327
|
+
/** The FINGERPRINT_VERSION in force when the hash was computed. Absent = pre-v4 artifact. */
|
|
328
|
+
v?: number;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export type FingerprintComparison =
|
|
332
|
+
| { kind: 'match' }
|
|
333
|
+
| { kind: 'drift' }
|
|
334
|
+
| { kind: 'format-changed'; stored: number | 'unstamped'; current: number };
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Compare a stored fingerprint to one computed now.
|
|
338
|
+
*
|
|
339
|
+
* A raw hash compare across formats reports DRIFT, which is a lie — the state may be
|
|
340
|
+
* untouched while the way we describe it changed. Every hash comparison that spans an artifact
|
|
341
|
+
* boundary must come through here so a format change reads as a format change.
|
|
342
|
+
*/
|
|
343
|
+
export function compareStoredFingerprint(
|
|
344
|
+
stored: StoredFingerprint,
|
|
345
|
+
currentHash: string,
|
|
346
|
+
currentVersion: number = FINGERPRINT_VERSION,
|
|
347
|
+
): FingerprintComparison {
|
|
348
|
+
if (stored.v !== currentVersion) {
|
|
349
|
+
return { kind: 'format-changed', stored: stored.v ?? 'unstamped', current: currentVersion };
|
|
350
|
+
}
|
|
351
|
+
return stored.hash === currentHash ? { kind: 'match' } : { kind: 'drift' };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The operator-facing sentence for a format change. Names the fix, never the phantom drift. */
|
|
355
|
+
export function formatChangedMessage(c: Extract<FingerprintComparison, { kind: 'format-changed' }>): string {
|
|
356
|
+
return `fingerprint format changed (recorded v${c.stored}, current v${c.current}) — this is NOT drift. `
|
|
357
|
+
+ 'The artifact predates the current canonical form, so its hash cannot be compared. '
|
|
358
|
+
+ 'Re-mint the plan (db:plan) or re-baseline the stage (db:reconcile --rebaseline); no DDL is involved.';
|
|
359
|
+
}
|