@metaobjectsdev/cli 1.0.9-rc.2 → 1.0.9-rc.4
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/dist/src/commands/eject.d.ts +3 -0
- package/dist/src/commands/eject.d.ts.map +1 -1
- package/dist/src/commands/eject.js +10 -2
- package/dist/src/commands/eject.js.map +1 -1
- package/dist/src/commands/gen.d.ts +0 -5
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +18 -2
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +5 -1
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/verify.d.ts +13 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +64 -11
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/args.d.ts +12 -0
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +6 -0
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/catalog-listing.d.ts +10 -0
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +43 -7
- package/dist/src/lib/catalog-listing.js.map +1 -1
- package/dist/src/lib/codegen-drift.d.ts +10 -0
- package/dist/src/lib/codegen-drift.d.ts.map +1 -1
- package/dist/src/lib/codegen-drift.js +8 -0
- package/dist/src/lib/codegen-drift.js.map +1 -1
- package/dist/src/lib/config.d.ts +8 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +11 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/gen-migrate-advice.d.ts +17 -0
- package/dist/src/lib/gen-migrate-advice.d.ts.map +1 -0
- package/dist/src/lib/gen-migrate-advice.js +105 -0
- package/dist/src/lib/gen-migrate-advice.js.map +1 -0
- package/dist/src/lib/install-set.d.ts +5 -1
- package/dist/src/lib/install-set.d.ts.map +1 -1
- package/dist/src/lib/install-set.js +9 -1
- package/dist/src/lib/install-set.js.map +1 -1
- package/dist/src/lib/output.d.ts +5 -0
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +1 -1
- package/dist/src/lib/output.js.map +1 -1
- package/package.json +10 -10
- package/src/commands/eject.ts +12 -2
- package/src/commands/gen.ts +20 -2
- package/src/commands/migrate.ts +5 -0
- package/src/commands/verify.ts +66 -10
- package/src/index.ts +4 -0
- package/src/lib/args.ts +16 -0
- package/src/lib/catalog-listing.ts +66 -9
- package/src/lib/codegen-drift.ts +17 -0
- package/src/lib/config.ts +14 -0
- package/src/lib/gen-migrate-advice.ts +129 -0
- package/src/lib/install-set.ts +11 -1
- package/src/lib/output.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metaobjectsdev/cli",
|
|
3
|
-
"version": "1.0.9-rc.
|
|
3
|
+
"version": "1.0.9-rc.4",
|
|
4
4
|
"description": "CLI for MetaObjects: scaffold, codegen, migrate, and drift-detection commands.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -51,15 +51,15 @@
|
|
|
51
51
|
"@biomejs/js-api": "^0.7.0",
|
|
52
52
|
"@biomejs/wasm-nodejs": "^1.9.4",
|
|
53
53
|
"@libsql/kysely-libsql": "^0.4.0",
|
|
54
|
-
"@metaobjectsdev/codegen-ts": "1.0.9-rc.
|
|
55
|
-
"@metaobjectsdev/codegen-ts-react": "1.0.9-rc.
|
|
56
|
-
"@metaobjectsdev/codegen-ts-tanstack": "1.0.9-rc.
|
|
57
|
-
"@metaobjectsdev/docs-site": "1.0.9-rc.
|
|
58
|
-
"@metaobjectsdev/metadata": "1.0.9-rc.
|
|
59
|
-
"@metaobjectsdev/migrate-ts": "1.0.9-rc.
|
|
60
|
-
"@metaobjectsdev/render": "1.0.9-rc.
|
|
61
|
-
"@metaobjectsdev/runtime-ts": "1.0.9-rc.
|
|
62
|
-
"@metaobjectsdev/sdk": "1.0.9-rc.
|
|
54
|
+
"@metaobjectsdev/codegen-ts": "1.0.9-rc.4",
|
|
55
|
+
"@metaobjectsdev/codegen-ts-react": "1.0.9-rc.4",
|
|
56
|
+
"@metaobjectsdev/codegen-ts-tanstack": "1.0.9-rc.4",
|
|
57
|
+
"@metaobjectsdev/docs-site": "1.0.9-rc.4",
|
|
58
|
+
"@metaobjectsdev/metadata": "1.0.9-rc.4",
|
|
59
|
+
"@metaobjectsdev/migrate-ts": "1.0.9-rc.4",
|
|
60
|
+
"@metaobjectsdev/render": "1.0.9-rc.4",
|
|
61
|
+
"@metaobjectsdev/runtime-ts": "1.0.9-rc.4",
|
|
62
|
+
"@metaobjectsdev/sdk": "1.0.9-rc.4",
|
|
63
63
|
"@toon-format/toon": "^2.3.0",
|
|
64
64
|
"jiti": "^2.4.0"
|
|
65
65
|
},
|
package/src/commands/eject.ts
CHANGED
|
@@ -142,6 +142,9 @@ export interface EjectResult {
|
|
|
142
142
|
packageName: string;
|
|
143
143
|
/** Advisory lines about packages the ejected file imports but the project lacks. */
|
|
144
144
|
dependencyNotes: string[];
|
|
145
|
+
/** Every `@metaobjectsdev/*` package the ejected file imports (declared or not) — what
|
|
146
|
+
* the consolidated install summary folds in, so it covers each per-file line. */
|
|
147
|
+
templatePackages: string[];
|
|
145
148
|
status: "created" | "preserved" | "replaced";
|
|
146
149
|
/**
|
|
147
150
|
* How the file ALREADY on disk compares to the reference template this CLI ships.
|
|
@@ -231,6 +234,7 @@ export async function ejectGenerator(opts: EjectOptions): Promise<EjectResult> {
|
|
|
231
234
|
exportName,
|
|
232
235
|
packageName: source.packageName,
|
|
233
236
|
dependencyNotes: notes,
|
|
237
|
+
templatePackages: requiredPackages(templateSource),
|
|
234
238
|
};
|
|
235
239
|
|
|
236
240
|
const existing = (await fileExists(abs)) ? await readFile(abs, "utf8") : undefined;
|
|
@@ -570,6 +574,7 @@ export async function ejectCommand(
|
|
|
570
574
|
const catalog = composeCatalog();
|
|
571
575
|
const rows: EjectedRow[] = [];
|
|
572
576
|
const libraries: LibraryEjectResult[] = [];
|
|
577
|
+
const templatePackages = new Set<string>();
|
|
573
578
|
try {
|
|
574
579
|
for (const name of flags.names) {
|
|
575
580
|
if (isLibraryName(name)) {
|
|
@@ -579,6 +584,7 @@ export async function ejectCommand(
|
|
|
579
584
|
continue;
|
|
580
585
|
}
|
|
581
586
|
const result = await ejectGenerator({ cwd, name, force: flags.force });
|
|
587
|
+
for (const p of result.templatePackages) templatePackages.add(p);
|
|
582
588
|
rows.push({
|
|
583
589
|
name,
|
|
584
590
|
path: result.path,
|
|
@@ -595,12 +601,16 @@ export async function ejectCommand(
|
|
|
595
601
|
|
|
596
602
|
// ONE install set for the whole call, not one per name: ejecting `hooks` and `grid`
|
|
597
603
|
// needs @metaobjectsdev/codegen-ts-tanstack once, and an adopter handed the same
|
|
598
|
-
// package twice reasonably wonders which line to run.
|
|
604
|
+
// package twice reasonably wonders which line to run. It folds in what the ejected
|
|
605
|
+
// FILES import as well as what the catalog says the generators need: the reference
|
|
606
|
+
// templates import `@metaobjectsdev/metadata` at gen time, which no catalog entry
|
|
607
|
+
// names, so a summary built from the catalog alone printed a line that omitted a
|
|
608
|
+
// package each per-file note had just asked for.
|
|
599
609
|
const entries = flags.names
|
|
600
610
|
.filter((n) => !isLibraryName(n))
|
|
601
611
|
.map((n) => catalog[n])
|
|
602
612
|
.filter((e) => e !== undefined);
|
|
603
|
-
const install = installSetFor(entries);
|
|
613
|
+
const install = installSetFor(entries, [...templatePackages]);
|
|
604
614
|
const configKeys = [...new Set(entries.flatMap((e) => e.configKeys ?? []))].sort();
|
|
605
615
|
|
|
606
616
|
if (fmt === "text") {
|
package/src/commands/gen.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import { parseGenArgs } from "../lib/args.js";
|
|
3
|
-
import { resolveGenConfig } from "../lib/config.js";
|
|
3
|
+
import { resolveGenConfig, resolveMigrateDefaults } from "../lib/config.js";
|
|
4
|
+
import { genMigrateAdvice } from "../lib/gen-migrate-advice.js";
|
|
4
5
|
import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
|
|
5
6
|
import { collectionLoadOptions } from "../lib/collection-load-options.js";
|
|
6
7
|
import { formatGenResult, formatGenResultToon, type GenFileEntry, type GenFileStatus } from "../lib/output.js";
|
|
@@ -35,6 +36,9 @@ import { composeCatalog } from "../lib/catalog.js";
|
|
|
35
36
|
*/
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
/** Write outcomes that change a file on disk — the ones that can carry a schema change. */
|
|
40
|
+
const CHANGED_STATUSES: ReadonlySet<WriteStatus> = new Set<WriteStatus>(["new", "overwrite", "merged", "conflict", "removed"]);
|
|
41
|
+
|
|
38
42
|
export function mapStatus(s: WriteStatus): GenFileStatus {
|
|
39
43
|
switch (s) {
|
|
40
44
|
case "new": return "new";
|
|
@@ -232,6 +236,17 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
|
|
|
232
236
|
const antiPatterns = runAntiPatternScan(
|
|
233
237
|
projectRoot, cliConfig.dryRun, flags.noAntipatterns, forgeConfig.verify?.antiPatternIgnore);
|
|
234
238
|
|
|
239
|
+
const migrateDefaults = await resolveMigrateDefaults(projectRoot);
|
|
240
|
+
const migrateAdvice = await genMigrateAdvice({
|
|
241
|
+
metadata,
|
|
242
|
+
dialect: forgeConfig.dialect,
|
|
243
|
+
migrateDialect: migrateDefaults.dialect,
|
|
244
|
+
columnNamingStrategy: forgeConfig.columnNamingStrategy,
|
|
245
|
+
projectRoot,
|
|
246
|
+
migrateOutDir: migrateDefaults.outDir,
|
|
247
|
+
changedFiles: result.files.filter((f) => CHANGED_STATUSES.has(f.status)).map((f) => f.path),
|
|
248
|
+
});
|
|
249
|
+
|
|
235
250
|
const genResult = {
|
|
236
251
|
files,
|
|
237
252
|
outDir: targetDirs.length > 1 ? targetDirs.join(", ") : forgeConfig.outDir,
|
|
@@ -240,6 +255,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
|
|
|
240
255
|
warnings: [],
|
|
241
256
|
antiPatterns,
|
|
242
257
|
generatorCount: forgeConfig.generators?.length ?? 0,
|
|
258
|
+
...(migrateAdvice !== undefined ? { migrateAdvice } : {}),
|
|
243
259
|
};
|
|
244
260
|
const output =
|
|
245
261
|
fmt === "toon" ? formatGenResultToon(genResult)
|
|
@@ -412,7 +428,9 @@ async function listCatalogCommand(
|
|
|
412
428
|
// codegen config it was moved out of.
|
|
413
429
|
libraries: genCollection.libraries,
|
|
414
430
|
},
|
|
415
|
-
|
|
431
|
+
// The collection's own files, exactly as `meta gen` supplies them below —
|
|
432
|
+
// `shared-model` cannot be counted without them.
|
|
433
|
+
probe: { metadata, scope: genCollection.inScope, sourceFiles: genCollection.ownFiles },
|
|
416
434
|
};
|
|
417
435
|
} else {
|
|
418
436
|
// No probe: still report `wired` / `owned` when a project happens to be here, since
|
package/src/commands/migrate.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
isBlockedChangesError,
|
|
20
20
|
isPrimaryKeyChangeError,
|
|
21
21
|
isDeclaredRenameError,
|
|
22
|
+
isDuplicateSqlNameError,
|
|
22
23
|
buildExpectedSchemaWithProvenance,
|
|
23
24
|
scopeExpectedSchema,
|
|
24
25
|
scopedDiffInputs,
|
|
@@ -314,6 +315,8 @@ function refuseEngineError(err: Error, hint: string, fmt: OutputFormat): number
|
|
|
314
315
|
|
|
315
316
|
const PK_CHANGE_HINT =
|
|
316
317
|
"align the primary key manually, or reconcile the metadata identity to match the live table";
|
|
318
|
+
const DUPLICATE_SQL_NAME_HINT =
|
|
319
|
+
"rename one of the colliding declarations; an index's database name is its identity.secondary / index.lookup name, not prefixed with its table";
|
|
317
320
|
const DECLARED_RENAME_HINT =
|
|
318
321
|
"fix or remove the --rename-table / --rename-column flag; a table's name is its name in the metadata (the new name when it is renamed too)";
|
|
319
322
|
|
|
@@ -1032,6 +1035,8 @@ export async function migrateCommand(
|
|
|
1032
1035
|
// AlreadyEmittedError: sub-function already called emitStructuredError — just
|
|
1033
1036
|
// propagate the exit code without double-emitting.
|
|
1034
1037
|
if (err instanceof AlreadyEmittedError) return err.exitCode;
|
|
1038
|
+
// A metadata defect, not a tool failure: two declarations generate one database name.
|
|
1039
|
+
if (isDuplicateSqlNameError(err)) return refuseEngineError(err, DUPLICATE_SQL_NAME_HINT, fmt);
|
|
1035
1040
|
// Unexpected error: emit structured error on stdout in the active format, then exit 1.
|
|
1036
1041
|
const msg = (err as Error).message ?? String(err);
|
|
1037
1042
|
log.error(`migrate: unexpected error: ${msg}`);
|
package/src/commands/verify.ts
CHANGED
|
@@ -416,12 +416,12 @@ export async function verifyCommand(
|
|
|
416
416
|
|
|
417
417
|
// Naming a gate runs ONLY the gates named (ADR-0021 D2), so `verify --codegen --db <url>`
|
|
418
418
|
// exits 0 without ever looking at the prompt templates. A green partial run must not read
|
|
419
|
-
// as a full one: say which gates did not run and how to select each.
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
419
|
+
// as a full one: say which gates did not run and how to select each. The BARE run says so
|
|
420
|
+
// too, at the END where the verdict is read: its opening note scrolls past, and on a model
|
|
421
|
+
// with no templates the only other line was "nothing to check" over a green exit — easy to
|
|
422
|
+
// wire into CI believing it guards everything. Never changes the exit code.
|
|
423
|
+
const note = notRunNote(gates, { bare: !flags.anyExplicit });
|
|
424
|
+
if (note !== undefined) say(note);
|
|
425
425
|
|
|
426
426
|
if (structured) {
|
|
427
427
|
emitStructured(
|
|
@@ -937,7 +937,9 @@ export async function verifyCommand(
|
|
|
937
937
|
// ADR-0039: effective children — resolve rather than rely on root being unextended.
|
|
938
938
|
const templates = root.children().filter((c) => c.type === TYPE_TEMPLATE);
|
|
939
939
|
if (templates.length === 0) {
|
|
940
|
-
|
|
940
|
+
// Names the GATE that had nothing to check — "nothing to check" alone read as a
|
|
941
|
+
// verdict on the whole project.
|
|
942
|
+
say("meta verify — no template.* nodes found; the template gate had nothing to check.");
|
|
941
943
|
return 0;
|
|
942
944
|
}
|
|
943
945
|
|
|
@@ -1450,13 +1452,19 @@ export async function verifyCommand(
|
|
|
1450
1452
|
return 2;
|
|
1451
1453
|
}
|
|
1452
1454
|
|
|
1455
|
+
// Hand edits are not drift, but they must never be INVISIBLE: a validator weakened
|
|
1456
|
+
// inside a generated file used to pass this gate with "every file's generated
|
|
1457
|
+
// contribution is current" and nothing else. Reported on every run; fails the build
|
|
1458
|
+
// only under --forbid-hand-edits.
|
|
1459
|
+
const handEditExit = reportHandEdits(result.handEdited);
|
|
1460
|
+
|
|
1453
1461
|
if (result.clean) {
|
|
1454
1462
|
// Says what it CHECKED, not more. Since 0.24.3 this gate asks whether each file's
|
|
1455
1463
|
// GENERATED contribution is current — a hand edit `meta gen` preserves is exempt —
|
|
1456
1464
|
// so "output is in sync with the metadata" claimed a stronger property than it had
|
|
1457
1465
|
// verified, and did so over a file whose committed content contradicted the model.
|
|
1458
1466
|
say("meta verify — every file's generated contribution is current (no codegen drift).");
|
|
1459
|
-
return
|
|
1467
|
+
return handEditExit;
|
|
1460
1468
|
}
|
|
1461
1469
|
|
|
1462
1470
|
log.error(
|
|
@@ -1481,6 +1489,23 @@ export async function verifyCommand(
|
|
|
1481
1489
|
return 1;
|
|
1482
1490
|
}
|
|
1483
1491
|
|
|
1492
|
+
/** Print the hand-edit notice (or failure, under --forbid-hand-edits); return its exit code. */
|
|
1493
|
+
function reportHandEdits(handEdited: readonly string[]): number {
|
|
1494
|
+
if (handEdited.length === 0) return 0;
|
|
1495
|
+
const [header, ...rest] = handEditNotice(handEdited, flags.forbidHandEdits);
|
|
1496
|
+
const paths = rest.slice(0, handEdited.length);
|
|
1497
|
+
const footer = rest.slice(handEdited.length);
|
|
1498
|
+
if (flags.forbidHandEdits) {
|
|
1499
|
+
// A failure lists every path: truncating the list a build failed on hides the cause.
|
|
1500
|
+
for (const line of [header!, ...paths, ...footer]) log.error(line);
|
|
1501
|
+
return 1;
|
|
1502
|
+
}
|
|
1503
|
+
log.warn(header!);
|
|
1504
|
+
warnCapped(paths, flags.limit, { structured });
|
|
1505
|
+
for (const line of footer) log.warn(line);
|
|
1506
|
+
return 0;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1484
1509
|
// -- docs drift -------------------------------------------------------------
|
|
1485
1510
|
// Gated on --docs. Runs `meta docs` into a temp dir and diffs the committed docs
|
|
1486
1511
|
// tree. See lib/docs-drift.ts for the deliberate differences from --codegen: a byte
|
|
@@ -1683,6 +1708,30 @@ interface VerifyGateRow {
|
|
|
1683
1708
|
ok: boolean;
|
|
1684
1709
|
}
|
|
1685
1710
|
|
|
1711
|
+
/**
|
|
1712
|
+
* The hand-edit report for `verify --codegen`: a header with the count, one indented line
|
|
1713
|
+
* per path, then how to see an edit. Exported for its test.
|
|
1714
|
+
*
|
|
1715
|
+
* The recipe regenerates rather than diffing against `.gen-state`, because the snapshot
|
|
1716
|
+
* BODIES are gitignored — on a fresh clone or a CI runner only the hashes exist, which
|
|
1717
|
+
* prove a file was edited but cannot show the edit. Deleting a generated file and
|
|
1718
|
+
* re-running `meta gen` re-emits its pristine generation; git then shows the difference.
|
|
1719
|
+
*/
|
|
1720
|
+
export function handEditNotice(paths: readonly string[], forbidden: boolean): string[] {
|
|
1721
|
+
const header = forbidden
|
|
1722
|
+
? `meta verify — ${paths.length} generated file(s) carry hand edits, and --forbid-hand-edits is set:`
|
|
1723
|
+
: `meta verify — ${paths.length} generated file(s) carry hand edits ` +
|
|
1724
|
+
`(notice — 'meta gen' preserves them by design; does not fail the build — ` +
|
|
1725
|
+
`pass --forbid-hand-edits to make it):`;
|
|
1726
|
+
return [
|
|
1727
|
+
header,
|
|
1728
|
+
...paths.map((p) => ` ${p}`),
|
|
1729
|
+
"To see an edit: delete the file and run 'meta gen' (it re-emits the pristine generation), " +
|
|
1730
|
+
"then 'git diff -R -- <file>' shows the edit and 'git checkout -- <file>' restores it" +
|
|
1731
|
+
(forbidden ? "; to drop the edit, commit the regenerated file instead." : "."),
|
|
1732
|
+
];
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1686
1735
|
/** The flag that selects each gate, for the "not run" note. `requirements` always runs. */
|
|
1687
1736
|
const GATE_SELECTOR: Record<string, string> = {
|
|
1688
1737
|
templates: "--templates",
|
|
@@ -1697,14 +1746,21 @@ const GATE_SELECTOR: Record<string, string> = {
|
|
|
1697
1746
|
* One line naming every gate this run did NOT run, each with the flag that selects it —
|
|
1698
1747
|
* undefined when every gate ran. Exported for its test.
|
|
1699
1748
|
*/
|
|
1700
|
-
export function notRunNote(
|
|
1749
|
+
export function notRunNote(
|
|
1750
|
+
gates: readonly VerifyGateRow[],
|
|
1751
|
+
opts: { bare?: boolean } = {},
|
|
1752
|
+
): string | undefined {
|
|
1701
1753
|
const skipped = gates.filter((g) => !g.ran);
|
|
1702
1754
|
if (skipped.length === 0) return undefined;
|
|
1703
1755
|
const named = skipped.map((g) => {
|
|
1704
1756
|
const flag = GATE_SELECTOR[g.gate];
|
|
1705
1757
|
return flag !== undefined ? `${g.gate} (${flag})` : g.gate;
|
|
1706
1758
|
});
|
|
1707
|
-
|
|
1759
|
+
const tail = opts.bare === true
|
|
1760
|
+
? " — a bare 'meta verify' runs only the template gate (plus the requirement ledger); " +
|
|
1761
|
+
"name each gate to run it"
|
|
1762
|
+
: "";
|
|
1763
|
+
return `meta verify — not run: ${named.join(", ")}${tail}`;
|
|
1708
1764
|
}
|
|
1709
1765
|
|
|
1710
1766
|
/**
|
package/src/index.ts
CHANGED
|
@@ -270,6 +270,10 @@ FLAGS:
|
|
|
270
270
|
An <Entity>.names.ts artifact is the exception: it must match a
|
|
271
271
|
fresh regen exactly — a hand edit in it is itself the defect.
|
|
272
272
|
Needs metaobjects.config.ts; exit 2 if absent.
|
|
273
|
+
Every generated file that carries a hand edit is still LISTED
|
|
274
|
+
(a notice, exit code unchanged), so an edit is never invisible.
|
|
275
|
+
--forbid-hand-edits With --codegen: fail (exit 1) on ANY hand edit inside generated
|
|
276
|
+
code, for teams that want generated files untouchable.
|
|
273
277
|
--docs Docs drift — run 'meta docs' to a temp dir and diff docs.outDir
|
|
274
278
|
Needs metaobjects.config.ts; exit 2 if absent. A page the
|
|
275
279
|
project GIT-IGNORES is exempt (docs.outDir is a directory, not
|
package/src/lib/args.ts
CHANGED
|
@@ -279,6 +279,14 @@ export interface VerifyFlags {
|
|
|
279
279
|
templates: boolean;
|
|
280
280
|
/** Run the codegen-drift gate: is the GENERATED contribution of each file current? */
|
|
281
281
|
codegen: boolean;
|
|
282
|
+
/**
|
|
283
|
+
* With `--codegen`: fail the build on any hand edit inside a generated file, for teams
|
|
284
|
+
* that want generated code untouchable. Off by default, because hand edits are the
|
|
285
|
+
* documented workflow (`meta gen` three-way-merges them) — without it they are
|
|
286
|
+
* reported as a notice and never change the exit code. Refused without `--codegen`:
|
|
287
|
+
* it modifies that gate and would otherwise parse cleanly and do nothing.
|
|
288
|
+
*/
|
|
289
|
+
forbidHandEdits: boolean;
|
|
282
290
|
/**
|
|
283
291
|
* Run the docs-drift gate: regenerate the `meta docs` surfaces into a temp dir and diff
|
|
284
292
|
* them against the committed docs tree.
|
|
@@ -370,6 +378,7 @@ export const VERIFY_OPTIONS = {
|
|
|
370
378
|
"skip-schema": { type: "boolean", default: false },
|
|
371
379
|
templates: { type: "boolean", default: false },
|
|
372
380
|
codegen: { type: "boolean", default: false },
|
|
381
|
+
"forbid-hand-edits": { type: "boolean", default: false },
|
|
373
382
|
docs: { type: "boolean", default: false },
|
|
374
383
|
deps: { type: "boolean", default: false },
|
|
375
384
|
replay: { type: "boolean", default: false },
|
|
@@ -427,6 +436,12 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
|
|
|
427
436
|
|
|
428
437
|
const templates = !!values.templates;
|
|
429
438
|
const codegen = !!values.codegen;
|
|
439
|
+
const forbidHandEdits = !!values["forbid-hand-edits"];
|
|
440
|
+
if (forbidHandEdits && !codegen) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
"--forbid-hand-edits only applies to the codegen gate. Run: meta verify --codegen --forbid-hand-edits",
|
|
443
|
+
);
|
|
444
|
+
}
|
|
430
445
|
const docs = !!values.docs;
|
|
431
446
|
const deps = !!values.deps;
|
|
432
447
|
const replay = !!values.replay;
|
|
@@ -448,6 +463,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
|
|
|
448
463
|
skipSchema: !!values["skip-schema"],
|
|
449
464
|
templates,
|
|
450
465
|
codegen,
|
|
466
|
+
forbidHandEdits,
|
|
451
467
|
docs,
|
|
452
468
|
deps,
|
|
453
469
|
replay,
|
|
@@ -77,6 +77,9 @@ export interface GeneratorCatalogRow {
|
|
|
77
77
|
wouldEmit: number | null;
|
|
78
78
|
/** Why `wouldEmit` is null despite --probe. Absent when it is a number. */
|
|
79
79
|
probeError?: string;
|
|
80
|
+
/** Config keys this project leaves unset that the generator needs before `meta gen`
|
|
81
|
+
* can run it. `wouldEmit` was counted with a placeholder for each. Absent when none. */
|
|
82
|
+
needsConfig?: string[];
|
|
80
83
|
};
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -164,7 +167,17 @@ export interface CatalogProject {
|
|
|
164
167
|
export interface CatalogListingOpts {
|
|
165
168
|
project?: CatalogProject;
|
|
166
169
|
/** Run every generator in memory and report a real file count. Needs `project` + `metadata`. */
|
|
167
|
-
probe?: {
|
|
170
|
+
probe?: {
|
|
171
|
+
metadata: MetaData;
|
|
172
|
+
scope?: (fqn: string) => boolean;
|
|
173
|
+
/**
|
|
174
|
+
* The collection's OWN source files — the same list `meta gen` threads onto
|
|
175
|
+
* `ctx.sourceFiles`. `shared-model` loads them standalone to build its artifact, so
|
|
176
|
+
* without them the probe asked it to publish nothing and reported the refusal as
|
|
177
|
+
* "probe failed" on every fresh project, for a generator that would in fact run.
|
|
178
|
+
*/
|
|
179
|
+
sourceFiles?: readonly string[];
|
|
180
|
+
};
|
|
168
181
|
}
|
|
169
182
|
|
|
170
183
|
/**
|
|
@@ -180,21 +193,46 @@ async function probeOne(
|
|
|
180
193
|
project: CatalogProject,
|
|
181
194
|
metadata: MetaData,
|
|
182
195
|
scope: ((fqn: string) => boolean) | undefined,
|
|
183
|
-
|
|
184
|
-
|
|
196
|
+
sourceFiles: readonly string[] | undefined,
|
|
197
|
+
): Promise<{ count: number; needsConfig?: string[] } | { error: string }> {
|
|
198
|
+
const count = async (config: MetaobjectsGenConfig): Promise<number> => {
|
|
185
199
|
const result = await runGen({
|
|
186
|
-
config: { ...
|
|
200
|
+
config: { ...config, generators: [entry.factory()] },
|
|
187
201
|
metadata,
|
|
188
202
|
projectRoot: project.projectRoot,
|
|
189
203
|
dryRun: true,
|
|
190
204
|
...(scope !== undefined ? { scope } : {}),
|
|
205
|
+
...(sourceFiles !== undefined ? { sourceFiles } : {}),
|
|
191
206
|
});
|
|
192
|
-
return
|
|
207
|
+
return result.files.length;
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
return { count: await count(project.config) };
|
|
193
211
|
} catch (err) {
|
|
212
|
+
// A generator that emits `import { db } from …` (the Fastify `routes`, `queries`)
|
|
213
|
+
// refuses to run while `dbImport` is unset — which it is in a freshly initialised
|
|
214
|
+
// project, exactly when `--probe` is being asked "what would this emit?". The path
|
|
215
|
+
// changes one import line, never the file count, so count with a placeholder and
|
|
216
|
+
// report the key as needed rather than dropping the count: the listing used to show
|
|
217
|
+
// `routes … [fastify]` beside `routes-hono … [hono, would emit 3]` with no reason.
|
|
218
|
+
// Only a retry that SUCCEEDS with the key supplied attributes the failure to it.
|
|
219
|
+
if (project.config.dbImport === undefined) {
|
|
220
|
+
try {
|
|
221
|
+
return {
|
|
222
|
+
count: await count({ ...project.config, dbImport: PROBE_DB_IMPORT_PLACEHOLDER }),
|
|
223
|
+
needsConfig: ["dbImport"],
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
// Fall through: the original error is the one worth reporting.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
194
229
|
return { error: (err as Error).message };
|
|
195
230
|
}
|
|
196
231
|
}
|
|
197
232
|
|
|
233
|
+
/** Stand-in `dbImport` for a probe. Never written anywhere — `dryRun` touches no file. */
|
|
234
|
+
const PROBE_DB_IMPORT_PLACEHOLDER = "./db";
|
|
235
|
+
|
|
198
236
|
// ---------------------------------------------------------------------------
|
|
199
237
|
// the listing
|
|
200
238
|
// ---------------------------------------------------------------------------
|
|
@@ -241,10 +279,19 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
|
|
|
241
279
|
|
|
242
280
|
let wouldEmit: number | null = null;
|
|
243
281
|
let probeError: string | undefined;
|
|
282
|
+
let needsConfig: string[] | undefined;
|
|
244
283
|
if (opts.probe !== undefined) {
|
|
245
|
-
const outcome = await probeOne(
|
|
246
|
-
|
|
247
|
-
|
|
284
|
+
const outcome = await probeOne(
|
|
285
|
+
entry,
|
|
286
|
+
project,
|
|
287
|
+
opts.probe.metadata,
|
|
288
|
+
opts.probe.scope,
|
|
289
|
+
opts.probe.sourceFiles,
|
|
290
|
+
);
|
|
291
|
+
if ("count" in outcome) {
|
|
292
|
+
wouldEmit = outcome.count;
|
|
293
|
+
needsConfig = outcome.needsConfig;
|
|
294
|
+
} else probeError = outcome.error;
|
|
248
295
|
}
|
|
249
296
|
|
|
250
297
|
row.project = {
|
|
@@ -252,6 +299,7 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
|
|
|
252
299
|
frameworkDetected,
|
|
253
300
|
wouldEmit,
|
|
254
301
|
...(probeError !== undefined ? { probeError } : {}),
|
|
302
|
+
...(needsConfig !== undefined ? { needsConfig } : {}),
|
|
255
303
|
};
|
|
256
304
|
}
|
|
257
305
|
|
|
@@ -361,7 +409,16 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
|
|
|
361
409
|
if (r.project?.wired) marks.push("WIRED");
|
|
362
410
|
if (r.source.owned) marks.push("owned");
|
|
363
411
|
if (probed && r.project?.wouldEmit !== null && r.project?.wouldEmit !== undefined) {
|
|
364
|
-
|
|
412
|
+
const needs = r.project.needsConfig;
|
|
413
|
+
marks.push(
|
|
414
|
+
needs !== undefined && needs.length > 0
|
|
415
|
+
? `would emit ${r.project.wouldEmit} once ${needs.join(", ")} is set`
|
|
416
|
+
: `would emit ${r.project.wouldEmit}`,
|
|
417
|
+
);
|
|
418
|
+
} else if (probed && r.project?.probeError !== undefined) {
|
|
419
|
+
// Say that the probe could not count it, not nothing: a missing count read as
|
|
420
|
+
// "emits nothing" beside a sibling that showed one.
|
|
421
|
+
marks.push("probe failed — see --format json");
|
|
365
422
|
}
|
|
366
423
|
const suffix = marks.length > 0 ? ` [${marks.join(", ")}]` : "";
|
|
367
424
|
lines.push(` ${r.name.padEnd(width)} — ${r.description}${suffix}`);
|
package/src/lib/codegen-drift.ts
CHANGED
|
@@ -68,6 +68,16 @@ export interface CodegenDriftResult {
|
|
|
68
68
|
* to end. Two doors asking one question must not answer it twice.
|
|
69
69
|
*/
|
|
70
70
|
handEditedNames: string[];
|
|
71
|
+
/**
|
|
72
|
+
* Project-relative paths of generated files that carry a PRESERVED hand edit, sorted:
|
|
73
|
+
* the generated contribution is current (a fresh regen hashes to what we recorded
|
|
74
|
+
* writing) but the committed file differs from it. Not drift — `meta gen` keeps these
|
|
75
|
+
* edits by design — so they never set `clean` to false. Reported so the edits are
|
|
76
|
+
* VISIBLE: a validator weakened inside a generated file used to pass this gate in
|
|
77
|
+
* silence. The command decides whether they fail the build (`--forbid-hand-edits`).
|
|
78
|
+
* Never overlaps `driftedFiles`.
|
|
79
|
+
*/
|
|
80
|
+
handEdited: string[];
|
|
71
81
|
/** Set when the gate could not run (e.g. no outDir to compare against). */
|
|
72
82
|
error?: string;
|
|
73
83
|
}
|
|
@@ -138,6 +148,7 @@ export async function computeCodegenDrift(
|
|
|
138
148
|
driftedFiles: [],
|
|
139
149
|
lines: [],
|
|
140
150
|
handEditedNames: [],
|
|
151
|
+
handEdited: [],
|
|
141
152
|
error:
|
|
142
153
|
"verify --codegen: no outDir configured — cannot locate the committed " +
|
|
143
154
|
"generated output to diff against. Set 'outDir' (and/or per-target " +
|
|
@@ -207,6 +218,7 @@ export async function computeCodegenDrift(
|
|
|
207
218
|
const driftedFiles = new Set<string>();
|
|
208
219
|
const lines: string[] = [];
|
|
209
220
|
const handEditedNames = new Set<string>();
|
|
221
|
+
const handEdited = new Set<string>();
|
|
210
222
|
|
|
211
223
|
// Whether we have any record of what we wrote here. With records, the orphan
|
|
212
224
|
// branch below can scope itself to our own output; with none, it cannot tell
|
|
@@ -286,6 +298,10 @@ export async function computeCodegenDrift(
|
|
|
286
298
|
// nothing is proven, so the old byte verdict stands.
|
|
287
299
|
driftedFiles.add(relKey);
|
|
288
300
|
lines.push(`~ ${relKey} (committed content differs from a fresh regen)`);
|
|
301
|
+
} else {
|
|
302
|
+
// The generated contribution is current and the bytes still differ: a
|
|
303
|
+
// hand edit `meta gen` preserved. Not drift, but never silent either.
|
|
304
|
+
handEdited.add(relKey);
|
|
289
305
|
}
|
|
290
306
|
}
|
|
291
307
|
}
|
|
@@ -298,6 +314,7 @@ export async function computeCodegenDrift(
|
|
|
298
314
|
driftedFiles: sorted,
|
|
299
315
|
lines,
|
|
300
316
|
handEditedNames: [...handEditedNames].sort(),
|
|
317
|
+
handEdited: [...handEdited].sort(),
|
|
301
318
|
};
|
|
302
319
|
} finally {
|
|
303
320
|
rmSync(tempRoot, { recursive: true, force: true });
|
package/src/lib/config.ts
CHANGED
|
@@ -82,6 +82,20 @@ async function tryLoadConfig(metaRoot: string): Promise<Config | undefined> {
|
|
|
82
82
|
// Public resolvers
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The migrations directory and dialect `meta migrate` would use with no flags — what
|
|
87
|
+
* `meta gen` needs to word its "now migrate" next step and find the committed snapshot.
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveMigrateDefaults(
|
|
90
|
+
metaRoot: string,
|
|
91
|
+
): Promise<{ outDir: string; dialect: Dialect | undefined }> {
|
|
92
|
+
const config = await tryLoadConfig(metaRoot);
|
|
93
|
+
return {
|
|
94
|
+
outDir: config?.migrate?.outDir ?? MIGRATE_DEFAULTS.outDir,
|
|
95
|
+
dialect: config?.migrate?.dialect ?? MIGRATE_DEFAULTS.dialect,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
export function resolveGenConfig(flags: GenFlags): ResolvedGenConfig {
|
|
86
100
|
return { dryRun: flags.dryRun, entities: flags.entities };
|
|
87
101
|
}
|