@metaobjectsdev/cli 1.0.9-rc.3 → 1.0.9-rc.5
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/README.md +15 -0
- package/dist/src/commands/eject.d.ts +17 -0
- package/dist/src/commands/eject.d.ts.map +1 -1
- package/dist/src/commands/eject.js +87 -2
- package/dist/src/commands/eject.js.map +1 -1
- package/dist/src/commands/gen.js +3 -1
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/generator-help.d.ts +21 -0
- package/dist/src/commands/generator-help.d.ts.map +1 -0
- package/dist/src/commands/generator-help.js +43 -0
- package/dist/src/commands/generator-help.js.map +1 -0
- package/dist/src/commands/generator.d.ts +36 -0
- package/dist/src/commands/generator.d.ts.map +1 -0
- package/dist/src/commands/generator.js +333 -0
- package/dist/src/commands/generator.js.map +1 -0
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +10 -1
- package/dist/src/commands/init.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 +69 -14
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/advisory.d.ts +14 -0
- package/dist/src/lib/advisory.d.ts.map +1 -1
- package/dist/src/lib/advisory.js +64 -5
- package/dist/src/lib/advisory.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 +15 -0
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +38 -3
- 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/install-set.d.ts +12 -2
- package/dist/src/lib/install-set.d.ts.map +1 -1
- package/dist/src/lib/install-set.js +14 -2
- package/dist/src/lib/install-set.js.map +1 -1
- package/dist/src/lib/runtime-eject.d.ts +80 -0
- package/dist/src/lib/runtime-eject.d.ts.map +1 -0
- package/dist/src/lib/runtime-eject.js +212 -0
- package/dist/src/lib/runtime-eject.js.map +1 -0
- package/package.json +10 -10
- package/src/commands/eject.ts +103 -3
- package/src/commands/gen.ts +3 -1
- package/src/commands/generator-help.ts +47 -0
- package/src/commands/generator.ts +390 -0
- package/src/commands/init.ts +10 -1
- package/src/commands/verify.ts +71 -14
- package/src/index.ts +12 -0
- package/src/lib/advisory.ts +71 -5
- package/src/lib/args.ts +16 -0
- package/src/lib/catalog-listing.ts +68 -3
- package/src/lib/codegen-drift.ts +17 -0
- package/src/lib/install-set.ts +19 -2
- package/src/lib/runtime-eject.ts +263 -0
package/src/lib/advisory.ts
CHANGED
|
@@ -108,6 +108,72 @@ export function ranSection<Row>(rows: Row[]): AdvisorySection<Row> {
|
|
|
108
108
|
return { status: "ran", total: rows.length, rows };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// The `rule` of each advisory row that is NOT an anti-pattern-scanner finding. A row whose
|
|
112
|
+
// rule is none of these came from the scanner: an authored site hand-rolling a construct.
|
|
113
|
+
export const RULE_MISSING_BASE_URL = "missing-base-url";
|
|
114
|
+
export const RULE_REMOVED_PROP_PREFIX = "removed-prop:";
|
|
115
|
+
export const RULE_LIBRARY_PREFIX = "library-prefix-unprovenanced";
|
|
116
|
+
export const RULE_UNINDEXED_FK = "unindexed-foreign-key";
|
|
117
|
+
export const RULE_REFERENTIAL_ACTION_CONFLICT = "overridden-referential-action";
|
|
118
|
+
|
|
119
|
+
/** The next-step line for `n` rows of one advisory kind, each naming what the rows are. */
|
|
120
|
+
const ADVISORY_HELP: ReadonlyArray<{ matches: (rule: string) => boolean; line: (n: number) => string }> = [
|
|
121
|
+
{
|
|
122
|
+
matches: (r) => r === RULE_UNINDEXED_FK,
|
|
123
|
+
line: (n) =>
|
|
124
|
+
`${n} foreign key(s) with no covering index — see antiPatterns.rows[] (rule "${RULE_UNINDEXED_FK}"); each names the index.lookup to declare`,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
matches: (r) => r === RULE_REFERENTIAL_ACTION_CONFLICT,
|
|
128
|
+
line: (n) =>
|
|
129
|
+
`${n} foreign key(s) whose two sides disagree on the referential action — see antiPatterns.rows[] (rule "${RULE_REFERENTIAL_ACTION_CONFLICT}")`,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
matches: (r) => r === RULE_MISSING_BASE_URL,
|
|
133
|
+
line: (n) =>
|
|
134
|
+
`${n} entity-fetcher provider(s) mounted with no baseUrl while apiPrefix is set — see antiPatterns.rows[] (rule "${RULE_MISSING_BASE_URL}")`,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
matches: (r) => r.startsWith(RULE_REMOVED_PROP_PREFIX),
|
|
138
|
+
line: (n) =>
|
|
139
|
+
`${n} provider(s) still mounted with a prop 1.0 renamed away — see antiPatterns.rows[] (rule "${RULE_REMOVED_PROP_PREFIX}<prop>")`,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
matches: (r) => r === RULE_LIBRARY_PREFIX,
|
|
143
|
+
line: (n) =>
|
|
144
|
+
`${n} node(s) declared under "metaobjects::" with no ejection provenance — see antiPatterns.rows[] (rule "${RULE_LIBRARY_PREFIX}")`,
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One next-step line per KIND of advisory row, in a structured payload's `help`.
|
|
150
|
+
*
|
|
151
|
+
* The advisory section carries several kinds of row keyed by `rule`, and the help line used
|
|
152
|
+
* to count them all as "authored site(s) hand-roll what MetaObjects can model" — so a project
|
|
153
|
+
* whose only advisories were unindexed foreign keys was told it hand-rolled three constructs.
|
|
154
|
+
* Scanner findings keep that line; every other kind is counted under its own label.
|
|
155
|
+
*/
|
|
156
|
+
export function advisoryHelpLines(rows: readonly AdvisoryFindingRow[]): string[] {
|
|
157
|
+
const counts = ADVISORY_HELP.map(() => 0);
|
|
158
|
+
let handRolled = 0;
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const kind = ADVISORY_HELP.findIndex((k) => k.matches(row.rule));
|
|
161
|
+
if (kind === -1) handRolled++;
|
|
162
|
+
else counts[kind] = (counts[kind] ?? 0) + 1;
|
|
163
|
+
}
|
|
164
|
+
const lines: string[] = [];
|
|
165
|
+
if (handRolled > 0) {
|
|
166
|
+
lines.push(
|
|
167
|
+
`${handRolled} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
ADVISORY_HELP.forEach((k, i) => {
|
|
171
|
+
const n = counts[i] ?? 0;
|
|
172
|
+
if (n > 0) lines.push(k.line(n));
|
|
173
|
+
});
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
|
|
111
177
|
/** Project the scanner's findings into payload rows. `snippet` is deliberately
|
|
112
178
|
* dropped: it is a copy of the reader's own source line, and `file`+`line`
|
|
113
179
|
* already address it. */
|
|
@@ -129,7 +195,7 @@ export function missingBaseUrlRows(findings: readonly BaseUrlFinding[]): Advisor
|
|
|
129
195
|
return findings.map((f) => ({
|
|
130
196
|
file: f.file,
|
|
131
197
|
line: f.line,
|
|
132
|
-
rule:
|
|
198
|
+
rule: RULE_MISSING_BASE_URL,
|
|
133
199
|
construct: f.construct,
|
|
134
200
|
message: f.message,
|
|
135
201
|
}));
|
|
@@ -144,7 +210,7 @@ export function removedPropRows(findings: readonly RemovedPropFinding[]): Adviso
|
|
|
144
210
|
return findings.map((f) => ({
|
|
145
211
|
file: f.file,
|
|
146
212
|
line: f.line,
|
|
147
|
-
rule:
|
|
213
|
+
rule: `${RULE_REMOVED_PROP_PREFIX}${f.prop}`,
|
|
148
214
|
construct: f.construct,
|
|
149
215
|
message: f.message,
|
|
150
216
|
}));
|
|
@@ -157,7 +223,7 @@ export function libraryPrefixRows(
|
|
|
157
223
|
return findings.map((f) => ({
|
|
158
224
|
file: f.file,
|
|
159
225
|
line: 0,
|
|
160
|
-
rule:
|
|
226
|
+
rule: RULE_LIBRARY_PREFIX,
|
|
161
227
|
construct: f.fqn,
|
|
162
228
|
message: f.message,
|
|
163
229
|
}));
|
|
@@ -174,7 +240,7 @@ export function unindexedFkRows(
|
|
|
174
240
|
return findings.map((f) => ({
|
|
175
241
|
file: f.file,
|
|
176
242
|
line: 0,
|
|
177
|
-
rule:
|
|
243
|
+
rule: RULE_UNINDEXED_FK,
|
|
178
244
|
construct: f.construct,
|
|
179
245
|
message: f.message,
|
|
180
246
|
}));
|
|
@@ -190,7 +256,7 @@ export function referentialActionConflictRows(
|
|
|
190
256
|
return findings.map((f) => ({
|
|
191
257
|
file: f.file,
|
|
192
258
|
line: 0,
|
|
193
|
-
rule:
|
|
259
|
+
rule: RULE_REFERENTIAL_ACTION_CONFLICT,
|
|
194
260
|
construct: f.construct,
|
|
195
261
|
message: f.message,
|
|
196
262
|
}));
|
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,
|
|
@@ -34,6 +34,11 @@ import {
|
|
|
34
34
|
type LibraryCatalogRow, type LibraryProjectContext,
|
|
35
35
|
} from "./library-listing.js";
|
|
36
36
|
import { installSetFor } from "./install-set.js";
|
|
37
|
+
import {
|
|
38
|
+
RUNTIME_ENTRIES, resolveRuntimePackage, runtimeClosure, runtimeCopyStatus, runtimeInstall,
|
|
39
|
+
type RuntimeCopyRow,
|
|
40
|
+
} from "./runtime-eject.js";
|
|
41
|
+
import { OWNED_RUNTIME_DIR } from "@metaobjectsdev/codegen-ts";
|
|
37
42
|
import { readPackageManifest, declaredDependencyNames } from "./package-manifest.js";
|
|
38
43
|
|
|
39
44
|
/**
|
|
@@ -67,6 +72,13 @@ export interface GeneratorCatalogRow {
|
|
|
67
72
|
ejectable: boolean;
|
|
68
73
|
/** Whether this project already owns a copy. `null` with no project. */
|
|
69
74
|
owned: boolean | null;
|
|
75
|
+
/**
|
|
76
|
+
* ADR-0034 Amendment 3 (2026-09-24): the HTTP-adapter source this generator's output
|
|
77
|
+
* imports, as far as the project owns a copy of it under `codegen/runtime/` — each
|
|
78
|
+
* file compared to the installed runtime package. Absent when the generator's output
|
|
79
|
+
* imports no adapter, or the project owns none of it.
|
|
80
|
+
*/
|
|
81
|
+
runtimeCopy?: RuntimeCopyRow[];
|
|
70
82
|
};
|
|
71
83
|
project?: {
|
|
72
84
|
/** Wired in this project's `generators: [...]`. */
|
|
@@ -167,7 +179,17 @@ export interface CatalogProject {
|
|
|
167
179
|
export interface CatalogListingOpts {
|
|
168
180
|
project?: CatalogProject;
|
|
169
181
|
/** Run every generator in memory and report a real file count. Needs `project` + `metadata`. */
|
|
170
|
-
probe?: {
|
|
182
|
+
probe?: {
|
|
183
|
+
metadata: MetaData;
|
|
184
|
+
scope?: (fqn: string) => boolean;
|
|
185
|
+
/**
|
|
186
|
+
* The collection's OWN source files — the same list `meta gen` threads onto
|
|
187
|
+
* `ctx.sourceFiles`. `shared-model` loads them standalone to build its artifact, so
|
|
188
|
+
* without them the probe asked it to publish nothing and reported the refusal as
|
|
189
|
+
* "probe failed" on every fresh project, for a generator that would in fact run.
|
|
190
|
+
*/
|
|
191
|
+
sourceFiles?: readonly string[];
|
|
192
|
+
};
|
|
171
193
|
}
|
|
172
194
|
|
|
173
195
|
/**
|
|
@@ -183,6 +205,7 @@ async function probeOne(
|
|
|
183
205
|
project: CatalogProject,
|
|
184
206
|
metadata: MetaData,
|
|
185
207
|
scope: ((fqn: string) => boolean) | undefined,
|
|
208
|
+
sourceFiles: readonly string[] | undefined,
|
|
186
209
|
): Promise<{ count: number; needsConfig?: string[] } | { error: string }> {
|
|
187
210
|
const count = async (config: MetaobjectsGenConfig): Promise<number> => {
|
|
188
211
|
const result = await runGen({
|
|
@@ -191,6 +214,7 @@ async function probeOne(
|
|
|
191
214
|
projectRoot: project.projectRoot,
|
|
192
215
|
dryRun: true,
|
|
193
216
|
...(scope !== undefined ? { scope } : {}),
|
|
217
|
+
...(sourceFiles !== undefined ? { sourceFiles } : {}),
|
|
194
218
|
});
|
|
195
219
|
return result.files.length;
|
|
196
220
|
};
|
|
@@ -225,12 +249,37 @@ const PROBE_DB_IMPORT_PLACEHOLDER = "./db";
|
|
|
225
249
|
// the listing
|
|
226
250
|
// ---------------------------------------------------------------------------
|
|
227
251
|
|
|
252
|
+
/** Per generator that hands over adapter source, the project's copies of the files it reaches. */
|
|
253
|
+
async function ownedRuntimeByGenerator(
|
|
254
|
+
projectRoot: string,
|
|
255
|
+
): Promise<Map<string, { files: RuntimeCopyRow[]; packages: string[] }>> {
|
|
256
|
+
const out = new Map<string, { files: RuntimeCopyRow[]; packages: string[] }>();
|
|
257
|
+
const status = await runtimeCopyStatus(projectRoot);
|
|
258
|
+
if (status.length === 0) return out;
|
|
259
|
+
const { srcRoot } = resolveRuntimePackage();
|
|
260
|
+
for (const [name, modules] of Object.entries(RUNTIME_ENTRIES)) {
|
|
261
|
+
const closure = runtimeClosure(srcRoot, modules);
|
|
262
|
+
const reached = new Set(closure.files.map((f) => `${OWNED_RUNTIME_DIR}/${f}`));
|
|
263
|
+
const files = status.filter((r) => reached.has(r.path));
|
|
264
|
+
if (files.length > 0) out.set(name, { files, packages: closure.packages });
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
228
269
|
export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promise<CatalogRow[]> {
|
|
229
270
|
const rows: CatalogRow[] = [];
|
|
271
|
+
const runtimeCopies = opts.project === undefined
|
|
272
|
+
? new Map<string, { files: RuntimeCopyRow[]; packages: string[] }>()
|
|
273
|
+
: await ownedRuntimeByGenerator(opts.project.projectRoot);
|
|
230
274
|
|
|
231
275
|
for (const entry of listCatalog()) {
|
|
232
276
|
const template = entry.ejectable ? readTemplate(entry.name) : undefined;
|
|
233
|
-
|
|
277
|
+
// A project that owns this generator's adapter copy installs what the COPY imports,
|
|
278
|
+
// not the runtime package — the same trade `meta eject` prints.
|
|
279
|
+
const owned = runtimeCopies.get(entry.name);
|
|
280
|
+
const install = owned === undefined
|
|
281
|
+
? installSetFor([entry])
|
|
282
|
+
: installSetFor([entry], [], { generators: new Set([entry.name]), ...runtimeInstall(owned.packages) });
|
|
234
283
|
const pkg = packageOf(entry.name) ?? "";
|
|
235
284
|
|
|
236
285
|
const row: GeneratorCatalogRow = {
|
|
@@ -254,6 +303,7 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
|
|
|
254
303
|
kind: entry.ejectable ? "reference-template" : "package-only",
|
|
255
304
|
ejectable: entry.ejectable,
|
|
256
305
|
owned: opts.project === undefined ? null : opts.project.ownedNames.has(entry.name),
|
|
306
|
+
...(owned !== undefined ? { runtimeCopy: owned.files } : {}),
|
|
257
307
|
},
|
|
258
308
|
};
|
|
259
309
|
|
|
@@ -269,7 +319,13 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
|
|
|
269
319
|
let probeError: string | undefined;
|
|
270
320
|
let needsConfig: string[] | undefined;
|
|
271
321
|
if (opts.probe !== undefined) {
|
|
272
|
-
const outcome = await probeOne(
|
|
322
|
+
const outcome = await probeOne(
|
|
323
|
+
entry,
|
|
324
|
+
project,
|
|
325
|
+
opts.probe.metadata,
|
|
326
|
+
opts.probe.scope,
|
|
327
|
+
opts.probe.sourceFiles,
|
|
328
|
+
);
|
|
273
329
|
if ("count" in outcome) {
|
|
274
330
|
wouldEmit = outcome.count;
|
|
275
331
|
needsConfig = outcome.needsConfig;
|
|
@@ -390,6 +446,15 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
|
|
|
390
446
|
if (!r.source.ejectable) marks.push("package-only");
|
|
391
447
|
if (r.project?.wired) marks.push("WIRED");
|
|
392
448
|
if (r.source.owned) marks.push("owned");
|
|
449
|
+
if (r.source.runtimeCopy !== undefined) {
|
|
450
|
+
// The adapter source this generator's output imports, compared to the package.
|
|
451
|
+
const differing = r.source.runtimeCopy.filter((c) => c.verdict === "differs").length;
|
|
452
|
+
marks.push(
|
|
453
|
+
differing === 0
|
|
454
|
+
? "owned runtime: identical to the package"
|
|
455
|
+
: `owned runtime: ${differing} file(s) DIFFER from the package — meta eject --list`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
393
458
|
if (probed && r.project?.wouldEmit !== null && r.project?.wouldEmit !== undefined) {
|
|
394
459
|
const needs = r.project.needsConfig;
|
|
395
460
|
marks.push(
|
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/install-set.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
21
|
import { existsSync, readFileSync } from "node:fs";
|
|
22
22
|
import { dirname, join } from "node:path";
|
|
23
|
-
import type
|
|
23
|
+
import { HTTP_RUNTIME_PACKAGE, type GeneratorRegistryEntry } from "@metaobjectsdev/codegen-ts";
|
|
24
24
|
import { cliVersion } from "./version.js";
|
|
25
25
|
import { packageOf } from "./catalog.js";
|
|
26
26
|
import type { PackageManifest } from "./package-manifest.js";
|
|
@@ -63,6 +63,13 @@ export function peerRangesOf(packageName: string): Record<string, string> {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** What an owned adapter copy changes about the install set — see `installSetFor`. */
|
|
67
|
+
export interface OwnedRuntimeInstall {
|
|
68
|
+
generators: ReadonlySet<string>;
|
|
69
|
+
runtime: ReadonlyMap<string, string | undefined>;
|
|
70
|
+
dev: ReadonlyMap<string, string | undefined>;
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
export interface InstallSet {
|
|
67
74
|
/** Build-time packages, `name@range`. */
|
|
68
75
|
dev: string[];
|
|
@@ -114,6 +121,10 @@ export function installSetFor(
|
|
|
114
121
|
* catalog does not know them). Build-time, so dev — unless the runtime set already
|
|
115
122
|
* installs the package, which satisfies the build too. */
|
|
116
123
|
templatePackages: readonly string[] = [],
|
|
124
|
+
/** ADR-0034 Amendment 3 (2026-09-24): generators whose adapter SOURCE was copied into the
|
|
125
|
+
* project, and what that copy imports. Their output no longer imports the runtime package,
|
|
126
|
+
* so it is not installed on their account; the copy's own imports are, instead. */
|
|
127
|
+
ownedRuntime?: OwnedRuntimeInstall,
|
|
117
128
|
): InstallSet {
|
|
118
129
|
const version = cliVersion();
|
|
119
130
|
const dev = new Map<string, string | undefined>();
|
|
@@ -123,7 +134,10 @@ export function installSetFor(
|
|
|
123
134
|
const pkg = packageOf(entry.name);
|
|
124
135
|
if (pkg !== undefined) addPackage(dev, pkg, `^${version}`);
|
|
125
136
|
|
|
126
|
-
for (const rt of entry.runtimePackages ?? [])
|
|
137
|
+
for (const rt of entry.runtimePackages ?? []) {
|
|
138
|
+
if (ownedRuntime?.generators.has(entry.name) && rt === HTTP_RUNTIME_PACKAGE) continue;
|
|
139
|
+
addPackage(runtime, rt, `^${version}`);
|
|
140
|
+
}
|
|
127
141
|
|
|
128
142
|
if (entry.runtimePeers !== undefined && entry.runtimePeers.length > 0) {
|
|
129
143
|
// Ranges come from whichever runtime package declares these as peers — the union
|
|
@@ -137,6 +151,9 @@ export function installSetFor(
|
|
|
137
151
|
}
|
|
138
152
|
}
|
|
139
153
|
|
|
154
|
+
for (const [name, range] of ownedRuntime?.runtime ?? []) addPackage(runtime, name, range);
|
|
155
|
+
for (const [name, range] of ownedRuntime?.dev ?? []) addPackage(dev, name, range);
|
|
156
|
+
|
|
140
157
|
for (const p of templatePackages) {
|
|
141
158
|
if (!runtime.has(p)) addPackage(dev, p, `^${version}`);
|
|
142
159
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// ADR-0034 Amendment 3 (2026-09-24 ruling) — ejecting a helper hands over ALL of its code.
|
|
2
|
+
//
|
|
3
|
+
// `meta eject routes` used to copy a generator whose OUTPUT still imported the HTTP adapter
|
|
4
|
+
// from `@metaobjectsdev/runtime-ts` — the mount helpers, filter parser, error envelopes and
|
|
5
|
+
// pagination, which is where most route defects have lived. Owning the generator while the
|
|
6
|
+
// code it emits calls into a package is half of owning it: a defect in the adapter still
|
|
7
|
+
// waits on an upstream release.
|
|
8
|
+
//
|
|
9
|
+
// So eject also copies the adapter SOURCE the output depends on — the transitive closure of
|
|
10
|
+
// relative imports from the entry module each generator's output names — into
|
|
11
|
+
// `codegen/runtime/`, laid out exactly as the package's own `src/` so every relative import
|
|
12
|
+
// in it resolves unchanged. Package imports are left as they are: what the closure reaches
|
|
13
|
+
// outside the package is `@metaobjectsdev/metadata` (core: the filter-op vocabulary and the
|
|
14
|
+
// canonical serializer) and third-party libraries (drizzle-orm, fastify, hono, zod, qs).
|
|
15
|
+
//
|
|
16
|
+
// The copies are VERBATIM. That is what makes "is my copy still the package's?" a byte
|
|
17
|
+
// comparison and "what did upstream change?" a plain `diff -r` against the installed
|
|
18
|
+
// package's `src/`, with no header to strip first.
|
|
19
|
+
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
22
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
23
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
24
|
+
import {
|
|
25
|
+
HTTP_RUNTIME_MODULES,
|
|
26
|
+
HTTP_RUNTIME_PACKAGE,
|
|
27
|
+
OWNED_RUNTIME_DIR,
|
|
28
|
+
type HttpRuntimeModule,
|
|
29
|
+
} from "@metaobjectsdev/codegen-ts";
|
|
30
|
+
import { compareOwnedCopy, type OwnedComparison } from "./owned-copy.js";
|
|
31
|
+
import { cliVersion } from "./version.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The ejectable generators whose OUTPUT imports the adapter tier, and the adapter modules
|
|
35
|
+
* it imports. `entity` is here for its allowlist TYPES only, which is one file.
|
|
36
|
+
*/
|
|
37
|
+
export const RUNTIME_ENTRIES: Readonly<Record<string, readonly HttpRuntimeModule[]>> = {
|
|
38
|
+
routes: ["drizzle-fastify"],
|
|
39
|
+
"routes-hono": ["hono"],
|
|
40
|
+
entity: ["allowlists"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** True iff ejecting `name` also copies adapter source. */
|
|
44
|
+
export function ejectsRuntime(name: string): boolean {
|
|
45
|
+
return Object.hasOwn(RUNTIME_ENTRIES, name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RuntimePackage {
|
|
49
|
+
/** `<root>/src` — the source the copies are taken from and compared against. */
|
|
50
|
+
srcRoot: string;
|
|
51
|
+
manifest: {
|
|
52
|
+
dependencies?: Record<string, string>;
|
|
53
|
+
peerDependencies?: Record<string, string>;
|
|
54
|
+
devDependencies?: Record<string, string>;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The installed `@metaobjectsdev/runtime-ts`: its `src/` and its manifest.
|
|
60
|
+
*
|
|
61
|
+
* Resolves the package ENTRY and walks up to the manifest that names it, never
|
|
62
|
+
* `"<pkg>/package.json"`: the exports map does not export that subpath, and under real Node
|
|
63
|
+
* asking for it throws ERR_PACKAGE_PATH_NOT_EXPORTED (see `install-set.ts`). `src/` ships
|
|
64
|
+
* in the published package (`files: ["dist", "src", ...]`).
|
|
65
|
+
*/
|
|
66
|
+
export function resolveRuntimePackage(): RuntimePackage {
|
|
67
|
+
const req = createRequire(import.meta.url);
|
|
68
|
+
let dir = dirname(req.resolve(HTTP_RUNTIME_PACKAGE));
|
|
69
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
70
|
+
const candidate = join(dir, "package.json");
|
|
71
|
+
if (existsSync(candidate)) {
|
|
72
|
+
const manifest = JSON.parse(readFileSync(candidate, "utf8")) as RuntimePackage["manifest"] & { name?: string };
|
|
73
|
+
if (manifest.name === HTTP_RUNTIME_PACKAGE) {
|
|
74
|
+
const srcRoot = join(dir, "src");
|
|
75
|
+
if (!existsSync(srcRoot)) {
|
|
76
|
+
throw new Error(`${HTTP_RUNTIME_PACKAGE} at ${dir} ships no src/ — cannot copy the adapter source.`);
|
|
77
|
+
}
|
|
78
|
+
return { srcRoot, manifest };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const up = dirname(dir);
|
|
82
|
+
if (up === dir) break;
|
|
83
|
+
dir = up;
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`could not locate the installed ${HTTP_RUNTIME_PACKAGE} package.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Relative (`./`, `../`) and bare module specifiers a source file imports or re-exports. */
|
|
89
|
+
function specifiersOf(source: string): string[] {
|
|
90
|
+
const out: string[] = [];
|
|
91
|
+
// `import … from "x"` and the re-export forms (`export { … } from`, `export * from`) only —
|
|
92
|
+
// a bare `export function f() { return Array.from("…") }` must not read as an import.
|
|
93
|
+
const STATEMENT =
|
|
94
|
+
/(?:^|\n)\s*(?:import\s+[^;"']*?\s+from|export\s+(?:type\s+)?(?:\*(?:\s+as\s+\w+)?|\{[^}]*\})\s*from)\s*["']([^"']+)["']/g;
|
|
95
|
+
for (const m of source.matchAll(STATEMENT)) {
|
|
96
|
+
if (m[1] !== undefined) out.push(m[1]);
|
|
97
|
+
}
|
|
98
|
+
for (const m of source.matchAll(/(?:^|\n)\s*import\s*["']([^"']+)["']/g)) {
|
|
99
|
+
if (m[1] !== undefined) out.push(m[1]);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `@scope/name/sub` → `@scope/name`; `name/sub` → `name`. */
|
|
105
|
+
function packageNameOf(spec: string): string {
|
|
106
|
+
const parts = spec.split("/");
|
|
107
|
+
return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toPosix(p: string): string {
|
|
111
|
+
return p.split(sep).join("/");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface RuntimeClosure {
|
|
115
|
+
/** Source files, POSIX paths relative to the package's `src/`, sorted. */
|
|
116
|
+
files: string[];
|
|
117
|
+
/** Packages the closure imports (never `node:` builtins), sorted. */
|
|
118
|
+
packages: string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every file the given adapter modules reach through relative imports, and what else they import. */
|
|
122
|
+
export function runtimeClosure(srcRoot: string, modules: readonly HttpRuntimeModule[]): RuntimeClosure {
|
|
123
|
+
const files = new Set<string>();
|
|
124
|
+
const packages = new Set<string>();
|
|
125
|
+
const queue = modules.map((m) => `${HTTP_RUNTIME_MODULES[m].file}.ts`);
|
|
126
|
+
while (queue.length > 0) {
|
|
127
|
+
const rel = queue.shift()!;
|
|
128
|
+
if (files.has(rel)) continue;
|
|
129
|
+
const abs = join(srcRoot, rel);
|
|
130
|
+
if (!existsSync(abs)) {
|
|
131
|
+
throw new Error(`${HTTP_RUNTIME_PACKAGE}: ${rel} is imported but not shipped in src/.`);
|
|
132
|
+
}
|
|
133
|
+
files.add(rel);
|
|
134
|
+
for (const spec of specifiersOf(readFileSync(abs, "utf8"))) {
|
|
135
|
+
if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
136
|
+
const target = toPosix(join(dirname(rel), spec)).replace(/\.js$/, ".ts");
|
|
137
|
+
queue.push(target.endsWith(".ts") ? target : `${target}.ts`);
|
|
138
|
+
} else if (!spec.startsWith("node:")) {
|
|
139
|
+
packages.add(packageNameOf(spec));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { files: [...files].sort(), packages: [...packages].sort() };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type RuntimeFileStatus = "created" | "preserved" | "replaced";
|
|
147
|
+
|
|
148
|
+
export interface RuntimeFileResult {
|
|
149
|
+
/** Project-relative path of the copy. */
|
|
150
|
+
path: string;
|
|
151
|
+
status: RuntimeFileStatus;
|
|
152
|
+
/** How the copy ALREADY on disk compared to the package's source. Absent when there was none. */
|
|
153
|
+
comparison?: OwnedComparison | undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface RuntimeEjectResult {
|
|
157
|
+
files: RuntimeFileResult[];
|
|
158
|
+
/** Packages the copied source imports — what the adopter now installs directly. */
|
|
159
|
+
packages: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Copy the adapter source `names` emit imports of into `<cwd>/codegen/runtime/`.
|
|
164
|
+
* Never overwrites a file without `force`, the same rule the generator copy keeps.
|
|
165
|
+
*/
|
|
166
|
+
export async function ejectRuntime(cwd: string, names: readonly string[], force: boolean): Promise<RuntimeEjectResult> {
|
|
167
|
+
const modules = [...new Set(names.flatMap((n) => RUNTIME_ENTRIES[n] ?? []))];
|
|
168
|
+
if (modules.length === 0) return { files: [], packages: [] };
|
|
169
|
+
const { srcRoot } = resolveRuntimePackage();
|
|
170
|
+
const closure = runtimeClosure(srcRoot, modules);
|
|
171
|
+
const files: RuntimeFileResult[] = [];
|
|
172
|
+
for (const rel of closure.files) {
|
|
173
|
+
const source = await readFile(join(srcRoot, rel), "utf8");
|
|
174
|
+
const path = `${OWNED_RUNTIME_DIR}/${rel}`;
|
|
175
|
+
const abs = join(cwd, path);
|
|
176
|
+
const existing = existsSync(abs) ? await readFile(abs, "utf8") : undefined;
|
|
177
|
+
const comparison = existing === undefined ? undefined : await compareOwnedCopy(existing, source);
|
|
178
|
+
if (existing !== undefined && !force) {
|
|
179
|
+
files.push({ path, status: "preserved", comparison });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
183
|
+
await writeFile(abs, source, "utf8");
|
|
184
|
+
files.push({ path, status: existing === undefined ? "created" : "replaced", comparison });
|
|
185
|
+
}
|
|
186
|
+
return { files, packages: closure.packages };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The install lines the copied source needs, with ranges read from the runtime package's
|
|
191
|
+
* own manifest (never restated here). A `@metaobjectsdev/*` package moves with the CLI.
|
|
192
|
+
* A package that ships no types of its own gets its `@types/*` sibling as a dev dependency
|
|
193
|
+
* when the runtime package itself develops against one.
|
|
194
|
+
*/
|
|
195
|
+
export function runtimeInstall(packages: readonly string[]): {
|
|
196
|
+
runtime: Map<string, string | undefined>;
|
|
197
|
+
dev: Map<string, string | undefined>;
|
|
198
|
+
} {
|
|
199
|
+
const { manifest } = resolveRuntimePackage();
|
|
200
|
+
const known = { ...manifest.peerDependencies, ...manifest.dependencies };
|
|
201
|
+
const runtime = new Map<string, string | undefined>();
|
|
202
|
+
const dev = new Map<string, string | undefined>();
|
|
203
|
+
for (const p of packages) {
|
|
204
|
+
runtime.set(p, p.startsWith("@metaobjectsdev/") ? `^${cliVersion()}` : known[p]);
|
|
205
|
+
const typesPkg = `@types/${p.replace(/^@/, "").replace("/", "__")}`;
|
|
206
|
+
const typesRange = manifest.devDependencies?.[typesPkg];
|
|
207
|
+
if (typesRange !== undefined) dev.set(typesPkg, typesRange);
|
|
208
|
+
}
|
|
209
|
+
return { runtime, dev };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One owned adapter file, compared to the package it came from. */
|
|
213
|
+
export interface RuntimeCopyRow {
|
|
214
|
+
path: string;
|
|
215
|
+
verdict: OwnedComparison["verdict"] | "not-in-package";
|
|
216
|
+
localOnly: number;
|
|
217
|
+
referenceOnly: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function listTs(dir: string): string[] {
|
|
221
|
+
const out: string[] = [];
|
|
222
|
+
for (const entry of readdirSync(dir)) {
|
|
223
|
+
const abs = join(dir, entry);
|
|
224
|
+
if (statSync(abs).isDirectory()) out.push(...listTs(abs));
|
|
225
|
+
else if (entry.endsWith(".ts")) out.push(abs);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Every file under `<cwd>/codegen/runtime/`, compared to the installed package's source.
|
|
232
|
+
* Empty when the project owns no adapter copy. `not-in-package` is a file the package does
|
|
233
|
+
* not ship — your own addition, or one upstream deleted or renamed.
|
|
234
|
+
*/
|
|
235
|
+
export async function runtimeCopyStatus(cwd: string): Promise<RuntimeCopyRow[]> {
|
|
236
|
+
const root = join(cwd, OWNED_RUNTIME_DIR);
|
|
237
|
+
if (!existsSync(root)) return [];
|
|
238
|
+
let srcRoot: string;
|
|
239
|
+
try {
|
|
240
|
+
srcRoot = resolveRuntimePackage().srcRoot;
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const rows: RuntimeCopyRow[] = [];
|
|
245
|
+
for (const abs of listTs(root).sort()) {
|
|
246
|
+
const rel = toPosix(relative(root, abs));
|
|
247
|
+
const path = `${OWNED_RUNTIME_DIR}/${rel}`;
|
|
248
|
+
const upstream = join(srcRoot, rel);
|
|
249
|
+
if (!existsSync(upstream)) {
|
|
250
|
+
rows.push({ path, verdict: "not-in-package", localOnly: 0, referenceOnly: 0 });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const cmp = await compareOwnedCopy(readFileSync(abs, "utf8"), readFileSync(upstream, "utf8"));
|
|
254
|
+
rows.push({ path, verdict: cmp.verdict, localOnly: cmp.localOnly, referenceOnly: cmp.referenceOnly });
|
|
255
|
+
}
|
|
256
|
+
return rows;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The command that shows exactly how one owned file differs from the installed package's. */
|
|
260
|
+
export function runtimeDiffCommand(path: string): string {
|
|
261
|
+
const rel = path.startsWith(`${OWNED_RUNTIME_DIR}/`) ? path.slice(OWNED_RUNTIME_DIR.length + 1) : path;
|
|
262
|
+
return `diff -u node_modules/${HTTP_RUNTIME_PACKAGE}/src/${rel} ${OWNED_RUNTIME_DIR}/${rel}`;
|
|
263
|
+
}
|