@metaobjectsdev/cli 1.0.12 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -22
- package/dist/bin/meta.js +14 -2
- package/dist/bin/meta.js.map +1 -1
- package/dist/src/commands/docs.d.ts +1 -14
- package/dist/src/commands/docs.d.ts.map +1 -1
- package/dist/src/commands/docs.js +11 -835
- package/dist/src/commands/docs.js.map +1 -1
- package/dist/src/commands/eject.d.ts +24 -1
- package/dist/src/commands/eject.d.ts.map +1 -1
- package/dist/src/commands/eject.js +240 -69
- package/dist/src/commands/eject.js.map +1 -1
- package/dist/src/commands/gen.d.ts +7 -0
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +91 -21
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/generator.js +2 -2
- package/dist/src/commands/generator.js.map +1 -1
- package/dist/src/commands/init.d.ts +7 -0
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +71 -36
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/migrate.d.ts +1 -1
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +224 -125
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/types.d.ts.map +1 -1
- package/dist/src/commands/types.js +16 -2
- package/dist/src/commands/types.js.map +1 -1
- package/dist/src/commands/upgrade.d.ts.map +1 -1
- package/dist/src/commands/upgrade.js +4 -1
- package/dist/src/commands/upgrade.js.map +1 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +189 -125
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +59 -72
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/agent-context-staleness.d.ts +8 -1
- package/dist/src/lib/agent-context-staleness.d.ts.map +1 -1
- package/dist/src/lib/agent-context-staleness.js +20 -5
- package/dist/src/lib/agent-context-staleness.js.map +1 -1
- package/dist/src/lib/agent-schema.d.ts +9 -0
- package/dist/src/lib/agent-schema.d.ts.map +1 -0
- package/dist/src/lib/agent-schema.js +61 -0
- package/dist/src/lib/agent-schema.js.map +1 -0
- package/dist/src/lib/allow.d.ts +6 -0
- package/dist/src/lib/allow.d.ts.map +1 -1
- package/dist/src/lib/allow.js +49 -5
- package/dist/src/lib/allow.js.map +1 -1
- package/dist/src/lib/anti-patterns.d.ts.map +1 -1
- package/dist/src/lib/anti-patterns.js +4 -2
- package/dist/src/lib/anti-patterns.js.map +1 -1
- package/dist/src/lib/args.d.ts +51 -10
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +44 -18
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/catalog-listing.d.ts +21 -1
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +85 -16
- package/dist/src/lib/catalog-listing.js.map +1 -1
- package/dist/src/lib/catalog.d.ts +30 -0
- package/dist/src/lib/catalog.d.ts.map +1 -1
- package/dist/src/lib/catalog.js +40 -5
- package/dist/src/lib/catalog.js.map +1 -1
- package/dist/src/lib/codegen-drift.d.ts +31 -2
- package/dist/src/lib/codegen-drift.d.ts.map +1 -1
- package/dist/src/lib/codegen-drift.js +109 -4
- package/dist/src/lib/codegen-drift.js.map +1 -1
- package/dist/src/lib/collection-load-options.d.ts +1 -1
- package/dist/src/lib/collection-load-options.d.ts.map +1 -1
- package/dist/src/lib/collection-load-options.js +1 -0
- package/dist/src/lib/collection-load-options.js.map +1 -1
- package/dist/src/lib/config.d.ts +4 -2
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +4 -2
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/decimal-precision-advisory.d.ts +10 -0
- package/dist/src/lib/decimal-precision-advisory.d.ts.map +1 -0
- package/dist/src/lib/decimal-precision-advisory.js +34 -0
- package/dist/src/lib/decimal-precision-advisory.js.map +1 -0
- package/dist/src/lib/deprecation-lint.d.ts.map +1 -1
- package/dist/src/lib/deprecation-lint.js +17 -26
- package/dist/src/lib/deprecation-lint.js.map +1 -1
- package/dist/src/lib/docs-removed.d.ts +13 -0
- package/dist/src/lib/docs-removed.d.ts.map +1 -0
- package/dist/src/lib/docs-removed.js +31 -0
- package/dist/src/lib/docs-removed.js.map +1 -0
- package/dist/src/lib/docs-site-eject.d.ts +13 -0
- package/dist/src/lib/docs-site-eject.d.ts.map +1 -0
- package/dist/src/lib/docs-site-eject.js +40 -0
- package/dist/src/lib/docs-site-eject.js.map +1 -0
- package/dist/src/lib/field-lint.d.ts +25 -0
- package/dist/src/lib/field-lint.d.ts.map +1 -0
- package/dist/src/lib/field-lint.js +102 -0
- package/dist/src/lib/field-lint.js.map +1 -0
- package/dist/src/lib/fk-index-advisory.d.ts +1 -2
- package/dist/src/lib/fk-index-advisory.d.ts.map +1 -1
- package/dist/src/lib/fk-index-advisory.js +4 -3
- package/dist/src/lib/fk-index-advisory.js.map +1 -1
- package/dist/src/lib/format.d.ts +7 -0
- package/dist/src/lib/format.d.ts.map +1 -1
- package/dist/src/lib/format.js +13 -0
- package/dist/src/lib/format.js.map +1 -1
- package/dist/src/lib/gen-migrate-advice.d.ts +3 -1
- package/dist/src/lib/gen-migrate-advice.d.ts.map +1 -1
- package/dist/src/lib/gen-migrate-advice.js +3 -4
- package/dist/src/lib/gen-migrate-advice.js.map +1 -1
- package/dist/src/lib/install-set.d.ts +10 -1
- package/dist/src/lib/install-set.d.ts.map +1 -1
- package/dist/src/lib/install-set.js +20 -2
- package/dist/src/lib/install-set.js.map +1 -1
- package/dist/src/lib/load-metaobjects-config.d.ts +13 -11
- package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
- package/dist/src/lib/load-metaobjects-config.js +53 -75
- package/dist/src/lib/load-metaobjects-config.js.map +1 -1
- package/dist/src/lib/manifest-ignored-check.d.ts +4 -1
- package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -1
- package/dist/src/lib/manifest-ignored-check.js +9 -4
- package/dist/src/lib/manifest-ignored-check.js.map +1 -1
- package/dist/src/lib/output.d.ts +15 -0
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +9 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/payload-field-tree.d.ts +4 -5
- package/dist/src/lib/payload-field-tree.d.ts.map +1 -1
- package/dist/src/lib/payload-field-tree.js +16 -31
- package/dist/src/lib/payload-field-tree.js.map +1 -1
- package/dist/src/lib/requirement-check.d.ts +7 -0
- package/dist/src/lib/requirement-check.d.ts.map +1 -1
- package/dist/src/lib/requirement-check.js +14 -7
- package/dist/src/lib/requirement-check.js.map +1 -1
- package/dist/src/lib/strict-args.d.ts +28 -0
- package/dist/src/lib/strict-args.d.ts.map +1 -0
- package/dist/src/lib/strict-args.js +49 -0
- package/dist/src/lib/strict-args.js.map +1 -0
- package/dist/src/lib/template-eject.d.ts +108 -0
- package/dist/src/lib/template-eject.d.ts.map +1 -0
- package/dist/src/lib/template-eject.js +438 -0
- package/dist/src/lib/template-eject.js.map +1 -0
- package/dist/src/lib/version.d.ts +9 -0
- package/dist/src/lib/version.d.ts.map +1 -1
- package/dist/src/lib/version.js +15 -0
- package/dist/src/lib/version.js.map +1 -1
- package/package.json +10 -10
- package/src/commands/docs.ts +11 -952
- package/src/commands/eject.ts +318 -73
- package/src/commands/gen.ts +94 -22
- package/src/commands/generator.ts +2 -2
- package/src/commands/init.ts +80 -38
- package/src/commands/migrate.ts +248 -117
- package/src/commands/types.ts +18 -2
- package/src/commands/upgrade.ts +5 -1
- package/src/commands/verify.ts +229 -136
- package/src/index.ts +59 -72
- package/src/lib/agent-context-staleness.ts +20 -5
- package/src/lib/agent-schema.ts +74 -0
- package/src/lib/allow.ts +57 -6
- package/src/lib/anti-patterns.ts +4 -2
- package/src/lib/args.ts +83 -28
- package/src/lib/catalog-listing.ts +105 -25
- package/src/lib/catalog.ts +51 -6
- package/src/lib/codegen-drift.ts +138 -3
- package/src/lib/collection-load-options.ts +2 -1
- package/src/lib/config.ts +5 -3
- package/src/lib/decimal-precision-advisory.ts +50 -0
- package/src/lib/deprecation-lint.ts +16 -24
- package/src/lib/docs-removed.ts +41 -0
- package/src/lib/docs-site-eject.ts +48 -0
- package/src/lib/field-lint.ts +122 -0
- package/src/lib/fk-index-advisory.ts +4 -4
- package/src/lib/format.ts +12 -0
- package/src/lib/gen-migrate-advice.ts +6 -4
- package/src/lib/install-set.ts +20 -1
- package/src/lib/load-metaobjects-config.ts +57 -78
- package/src/lib/manifest-ignored-check.ts +14 -10
- package/src/lib/output.ts +18 -1
- package/src/lib/payload-field-tree.ts +18 -35
- package/src/lib/requirement-check.ts +21 -6
- package/src/lib/strict-args.ts +52 -0
- package/src/lib/template-eject.ts +526 -0
- package/src/lib/version.ts +18 -0
- package/dist/src/lib/docs-drift.d.ts +0 -42
- package/dist/src/lib/docs-drift.d.ts.map +0 -1
- package/dist/src/lib/docs-drift.js +0 -264
- package/dist/src/lib/docs-drift.js.map +0 -1
- package/src/lib/docs-drift.ts +0 -312
package/src/lib/codegen-drift.ts
CHANGED
|
@@ -42,11 +42,19 @@ import { join, relative, resolve, isAbsolute } from "node:path";
|
|
|
42
42
|
import {
|
|
43
43
|
runGen,
|
|
44
44
|
contentHash,
|
|
45
|
+
DOCS_TARGET_NAME,
|
|
45
46
|
readGeneratedHash,
|
|
46
47
|
listGeneratedPaths,
|
|
48
|
+
orphanVerdict,
|
|
47
49
|
NAMES_FILE_SUFFIX,
|
|
50
|
+
generatorRegistry,
|
|
51
|
+
normalizeConfig,
|
|
52
|
+
stableGeneratorName,
|
|
48
53
|
} from "@metaobjectsdev/codegen-ts";
|
|
49
|
-
import type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
|
|
54
|
+
import type { MetaobjectsGenConfig, RunGenOpts } from "@metaobjectsdev/codegen-ts";
|
|
55
|
+
import { DEFAULT_TARGET_NAME } from "@metaobjectsdev/codegen-ts/engine";
|
|
56
|
+
import { gitIgnored } from "./git-ignore.js";
|
|
57
|
+
import { toPosix } from "./rel-posix.js";
|
|
50
58
|
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
51
59
|
|
|
52
60
|
/** Per-target config as carried on MetaobjectsGenConfig (TargetConfig isn't
|
|
@@ -68,6 +76,12 @@ export interface CodegenDriftResult {
|
|
|
68
76
|
* to end. Two doors asking one question must not answer it twice.
|
|
69
77
|
*/
|
|
70
78
|
handEditedNames: string[];
|
|
79
|
+
/**
|
|
80
|
+
* The subset of `driftedFiles` that no generator emits any more AND that was edited
|
|
81
|
+
* since `meta gen` wrote it, sorted. `meta gen` deletes an untouched orphan but refuses
|
|
82
|
+
* one of these, so prescribing it would loop: the remedy is to delete the file.
|
|
83
|
+
*/
|
|
84
|
+
editedOrphans: string[];
|
|
71
85
|
/**
|
|
72
86
|
* Project-relative paths of generated files that carry a PRESERVED hand edit, sorted:
|
|
73
87
|
* the generated contribution is current (a fresh regen hashes to what we recorded
|
|
@@ -78,10 +92,43 @@ export interface CodegenDriftResult {
|
|
|
78
92
|
* Never overlaps `driftedFiles`.
|
|
79
93
|
*/
|
|
80
94
|
handEdited: string[];
|
|
95
|
+
/**
|
|
96
|
+
* Generated documentation pages the repository git-ignores, and so did not check. Only
|
|
97
|
+
* the directories documentation generators write to are asked: a project that generates
|
|
98
|
+
* many pages and commits a few on purpose is checked on the few it chose. Reported on
|
|
99
|
+
* the pass and the fail line so a gate that checked two of 589 pages cannot read as one
|
|
100
|
+
* that checked them all.
|
|
101
|
+
*/
|
|
102
|
+
docsIgnored: number;
|
|
103
|
+
/**
|
|
104
|
+
* Documentation directories, project-relative, whose every freshly generated page the
|
|
105
|
+
* repository git-ignores. Not compared at all: a project that generates its documentation
|
|
106
|
+
* without committing it passes, as it did when 1.0's `verify --codegen` never looked at
|
|
107
|
+
* documentation.
|
|
108
|
+
*/
|
|
109
|
+
docsIgnoredDirs: string[];
|
|
110
|
+
/** Why git could not be asked which pages are ignored (every page was then checked). */
|
|
111
|
+
docsIgnoreUnavailable?: string;
|
|
81
112
|
/** Set when the gate could not run (e.g. no outDir to compare against). */
|
|
82
113
|
error?: string;
|
|
83
114
|
}
|
|
84
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The output directories a documentation generator writes to, absolute. A generator is
|
|
118
|
+
* documentation when its registry row's layer is `docs`; an owned copy keeps its packaged
|
|
119
|
+
* name, so it is recognised too.
|
|
120
|
+
*/
|
|
121
|
+
function docsOutDirs(config: MetaobjectsGenConfig, projectRoot: string): Set<string> {
|
|
122
|
+
const dirs = new Set<string>();
|
|
123
|
+
for (const g of normalizeConfig(config).generators) {
|
|
124
|
+
if (generatorRegistry[stableGeneratorName(g, generatorRegistry)]?.layer !== "docs") continue;
|
|
125
|
+
const target = g.target ?? DOCS_TARGET_NAME;
|
|
126
|
+
const outDir = target === DEFAULT_TARGET_NAME ? config.outDir : config.targets?.[target]?.outDir;
|
|
127
|
+
if (outDir !== undefined && outDir.length > 0) dirs.add(resolve(projectRoot, outDir));
|
|
128
|
+
}
|
|
129
|
+
return dirs;
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
/** Collect the committed outDirs declared by the config (default + per-target),
|
|
86
133
|
* resolved against `projectRoot` — a relative outDir (the common, portable,
|
|
87
134
|
* `meta init`-scaffolded shape) must resolve against the project the CLI's
|
|
@@ -127,12 +174,20 @@ function listFiles(dir: string): string[] {
|
|
|
127
174
|
* never committed because the `meta gen` that produced the committed tree
|
|
128
175
|
* never emitted it either. Undefined ⇒ everything is in scope (byte-identical
|
|
129
176
|
* to a project with no `scope` declared).
|
|
177
|
+
* @param sourceFiles the collection's OWN source files, the same list `meta gen`
|
|
178
|
+
* threads onto `ctx.sourceFiles`. `shared-model` loads them standalone to build its
|
|
179
|
+
* artifact, so without them the regen asked it to publish nothing and this gate
|
|
180
|
+
* failed with "no source files to load" on every project that wires it.
|
|
181
|
+
* @param run what `meta gen` also hands the run and this regen must hand it the same way:
|
|
182
|
+
* the source directories (the docs site loads them) and the agent pages' schema builder.
|
|
130
183
|
*/
|
|
131
184
|
export async function computeCodegenDrift(
|
|
132
185
|
config: MetaobjectsGenConfig,
|
|
133
186
|
metadata: MetaData,
|
|
134
187
|
projectRoot: string,
|
|
135
188
|
scope?: (fqn: string) => boolean,
|
|
189
|
+
sourceFiles?: readonly string[],
|
|
190
|
+
run?: Pick<RunGenOpts, "sourceRoots" | "agentSchema">,
|
|
136
191
|
): Promise<CodegenDriftResult> {
|
|
137
192
|
const root = isAbsolute(projectRoot) ? projectRoot : resolve(projectRoot);
|
|
138
193
|
|
|
@@ -148,7 +203,10 @@ export async function computeCodegenDrift(
|
|
|
148
203
|
driftedFiles: [],
|
|
149
204
|
lines: [],
|
|
150
205
|
handEditedNames: [],
|
|
206
|
+
editedOrphans: [],
|
|
151
207
|
handEdited: [],
|
|
208
|
+
docsIgnored: 0,
|
|
209
|
+
docsIgnoredDirs: [],
|
|
152
210
|
error:
|
|
153
211
|
"verify --codegen: no outDir configured — cannot locate the committed " +
|
|
154
212
|
"generated output to diff against. Set 'outDir' (and/or per-target " +
|
|
@@ -204,35 +262,75 @@ export async function computeCodegenDrift(
|
|
|
204
262
|
// Generate fresh into the temp tree. "overwrite" + "fresh" + a temp
|
|
205
263
|
// gen-state dir guarantees every file is written (no merge/skip), so the
|
|
206
264
|
// temp tree is the canonical "what gen would produce right now".
|
|
207
|
-
await runGen({
|
|
265
|
+
const regen = await runGen({
|
|
208
266
|
config: tempConfig,
|
|
209
267
|
metadata,
|
|
210
268
|
projectRoot: tempRoot,
|
|
211
269
|
genStateDir: join(tempRoot, ".gen-state"),
|
|
212
270
|
mergeStrategy: "overwrite",
|
|
213
271
|
baseline: "fresh",
|
|
272
|
+
// The real project, for a generator that reads its files (`ctx.inputRoot`):
|
|
273
|
+
// `shared-model` takes its default version from the nearest package.json, and an
|
|
274
|
+
// owned generator may read anything. `projectRoot` stays the temp tree, which the
|
|
275
|
+
// owned route and entity generators compute their adapter import against.
|
|
276
|
+
inputRoot: root,
|
|
214
277
|
...(scope !== undefined ? { scope } : {}),
|
|
278
|
+
...(sourceFiles !== undefined ? { sourceFiles } : {}),
|
|
279
|
+
...(run?.sourceRoots !== undefined ? { sourceRoots: run.sourceRoots } : {}),
|
|
280
|
+
...(run?.agentSchema !== undefined ? { agentSchema: run.agentSchema } : {}),
|
|
215
281
|
});
|
|
216
282
|
|
|
283
|
+
// Where gen discards hand edits instead of keeping them (handEdits: "overwrite").
|
|
284
|
+
const overwrite = regen.handEditsOverwrite;
|
|
285
|
+
|
|
217
286
|
// Diff each committed outDir against its temp mirror.
|
|
218
287
|
const driftedFiles = new Set<string>();
|
|
219
288
|
const lines: string[] = [];
|
|
220
289
|
const handEditedNames = new Set<string>();
|
|
221
290
|
const handEdited = new Set<string>();
|
|
291
|
+
const editedOrphans = new Set<string>();
|
|
222
292
|
|
|
223
293
|
// Whether we have any record of what we wrote here. With records, the orphan
|
|
224
294
|
// branch below can scope itself to our own output; with none, it cannot tell
|
|
225
295
|
// our stale output from a stranger's file, so the old verdict stands.
|
|
226
296
|
const haveWriteRecords = listGeneratedPaths(projectGenStateDir).length > 0;
|
|
297
|
+
const docsDirs = docsOutDirs(config, root);
|
|
298
|
+
let docsIgnored = 0;
|
|
299
|
+
const docsIgnoredDirs: string[] = [];
|
|
300
|
+
let docsIgnoreUnavailable: string | undefined;
|
|
227
301
|
for (const committed of committedDirs) {
|
|
228
302
|
const fresh = tempFor(committed);
|
|
229
303
|
const committedFiles = new Set(listFiles(committed));
|
|
230
304
|
const freshFiles = new Set(listFiles(fresh));
|
|
231
305
|
const all = new Set([...committedFiles, ...freshFiles]);
|
|
306
|
+
|
|
307
|
+
// Documentation the project git-ignores is not checked (carried from 1.0's
|
|
308
|
+
// `verify --docs`). Asked of the FRESH set by name, present on disk or not, so the
|
|
309
|
+
// verdict is a property of the repository and not of the machine: a box that ran
|
|
310
|
+
// `meta gen` has every page on disk and a CI runner has the committed few. A TRACKED
|
|
311
|
+
// page is never reported ignored by git, so a committed page is always compared.
|
|
312
|
+
// honourGlobalExcludes: false — a personal ignore file must not change a gate.
|
|
313
|
+
let ignored: ReadonlySet<string> = new Set<string>();
|
|
314
|
+
if (docsDirs.has(committed) && all.size > 0) {
|
|
315
|
+
const prefix = toPosix(relative(root, committed));
|
|
316
|
+
const fromRoot = (rel: string): string => (prefix === "" ? toPosix(rel) : `${prefix}/${toPosix(rel)}`);
|
|
317
|
+
const lookup = gitIgnored(root, [...all].map(fromRoot), { honourGlobalExcludes: false });
|
|
318
|
+
if ("ignored" in lookup) ignored = new Set([...all].map(toPosix).filter((rel) => lookup.ignored.has(fromRoot(rel))));
|
|
319
|
+
else docsIgnoreUnavailable = lookup.unavailable;
|
|
320
|
+
const checked = [...freshFiles].filter((rel) => !ignored.has(toPosix(rel)));
|
|
321
|
+
if (freshFiles.size > 0 && checked.length === 0) {
|
|
322
|
+
docsIgnoredDirs.push(relative(root, committed) || ".");
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
232
326
|
for (const rel of [...all].sort()) {
|
|
233
327
|
const relKey = relative(root, join(committed, rel));
|
|
234
328
|
const inCommitted = committedFiles.has(rel);
|
|
235
329
|
const inFresh = freshFiles.has(rel);
|
|
330
|
+
if (ignored.has(toPosix(rel))) {
|
|
331
|
+
docsIgnored++;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
236
334
|
if (inCommitted && !inFresh) {
|
|
237
335
|
// JURISDICTION, not staleness. A regen not emitting a path means
|
|
238
336
|
// "stale generated output" only for a path we have ever WRITTEN; for
|
|
@@ -247,8 +345,33 @@ export async function computeCodegenDrift(
|
|
|
247
345
|
if (haveWriteRecords && readGeneratedHash(projectGenStateDir, relKey) === undefined) {
|
|
248
346
|
continue;
|
|
249
347
|
}
|
|
348
|
+
const verdict = haveWriteRecords
|
|
349
|
+
? orphanVerdict(regen.orphanSweepJobs ?? [], tempRoot, relative(tempRoot, join(fresh, rel)))
|
|
350
|
+
: undefined;
|
|
351
|
+
if (verdict?.kept === true) continue;
|
|
250
352
|
driftedFiles.add(relKey);
|
|
251
|
-
|
|
353
|
+
// `meta gen` deletes an orphan it can prove untouched and REFUSES one edited
|
|
354
|
+
// since it was written, so the remedy differs by which this is. Asked of the
|
|
355
|
+
// same evidence the sweep asks (the recorded hash), so the two cannot disagree.
|
|
356
|
+
// With no write records at all, neither verdict can be proven, so the line
|
|
357
|
+
// stays the plain one.
|
|
358
|
+
if (!haveWriteRecords) {
|
|
359
|
+
lines.push(`- ${relKey} (committed but regen would not emit it)`);
|
|
360
|
+
} else if (readGeneratedHash(projectGenStateDir, relKey) === contentHash(readFileSync(join(committed, rel), "utf8"))) {
|
|
361
|
+
lines.push(`- ${relKey} (committed but regen would not emit it; 'meta gen' deletes it)`);
|
|
362
|
+
} else if (verdict?.force === true) {
|
|
363
|
+
// The sweep force-deletes an edited orphan here, so gen deletes it.
|
|
364
|
+
lines.push(
|
|
365
|
+
`- ${relKey} (no generator emits it any more, and it was edited by hand; ` +
|
|
366
|
+
`a force or handEdits: "overwrite" orphan policy owns it, so 'meta gen' deletes it)`,
|
|
367
|
+
);
|
|
368
|
+
} else {
|
|
369
|
+
editedOrphans.add(relKey);
|
|
370
|
+
lines.push(
|
|
371
|
+
`- ${relKey} (no generator emits it any more, and it was edited by hand: ` +
|
|
372
|
+
`delete it, or move your edits into a file of your own and then delete it)`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
252
375
|
} else if (!inCommitted && inFresh) {
|
|
253
376
|
driftedFiles.add(relKey);
|
|
254
377
|
lines.push(`+ ${relKey} (regen would emit it; not committed — run 'meta gen')`);
|
|
@@ -298,6 +421,14 @@ export async function computeCodegenDrift(
|
|
|
298
421
|
// nothing is proven, so the old byte verdict stands.
|
|
299
422
|
driftedFiles.add(relKey);
|
|
300
423
|
lines.push(`~ ${relKey} (committed content differs from a fresh regen)`);
|
|
424
|
+
} else if (overwrite?.paths.has(join(fresh, rel)) === true) {
|
|
425
|
+
// Its generator says handEdits: "overwrite": `meta gen` replaces the edit
|
|
426
|
+
// with fresh output, so it is drift that gen fixes, not a kept edit.
|
|
427
|
+
driftedFiles.add(relKey);
|
|
428
|
+
lines.push(
|
|
429
|
+
`~ ${relKey} (edited by hand; its generator sets handEdits: "overwrite", so ` +
|
|
430
|
+
`'meta gen' replaces it with fresh output)`,
|
|
431
|
+
);
|
|
301
432
|
} else {
|
|
302
433
|
// The generated contribution is current and the bytes still differ: a
|
|
303
434
|
// hand edit `meta gen` preserved. Not drift, but never silent either.
|
|
@@ -314,7 +445,11 @@ export async function computeCodegenDrift(
|
|
|
314
445
|
driftedFiles: sorted,
|
|
315
446
|
lines,
|
|
316
447
|
handEditedNames: [...handEditedNames].sort(),
|
|
448
|
+
editedOrphans: [...editedOrphans].sort(),
|
|
317
449
|
handEdited: [...handEdited].sort(),
|
|
450
|
+
docsIgnored,
|
|
451
|
+
docsIgnoredDirs,
|
|
452
|
+
...(docsIgnoreUnavailable !== undefined ? { docsIgnoreUnavailable } : {}),
|
|
318
453
|
};
|
|
319
454
|
} finally {
|
|
320
455
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
@@ -26,7 +26,7 @@ import type { Collection, LoadMemoryOptions } from "@metaobjectsdev/sdk";
|
|
|
26
26
|
*/
|
|
27
27
|
export function collectionLoadOptions(
|
|
28
28
|
collection: Collection,
|
|
29
|
-
): Required<Pick<LoadMemoryOptions, "files" | "fileIds" | "importedPackages" | "importedNodes" | "libraries">> {
|
|
29
|
+
): Required<Pick<LoadMemoryOptions, "files" | "fileIds" | "importedPackages" | "importedNodes" | "libraries" | "physicalNaming">> {
|
|
30
30
|
return {
|
|
31
31
|
files: collection.files,
|
|
32
32
|
fileIds: collection.fileIds,
|
|
@@ -38,5 +38,6 @@ export function collectionLoadOptions(
|
|
|
38
38
|
// threaded by `loadMemoryOptionsFrom`, a SECOND helper, and #333 is on record as the
|
|
39
39
|
// bug where one of the two reached every command and the other reached none.
|
|
40
40
|
libraries: collection.libraries,
|
|
41
|
+
physicalNaming: collection.physicalNaming,
|
|
41
42
|
};
|
|
42
43
|
}
|
package/src/lib/config.ts
CHANGED
|
@@ -83,16 +83,18 @@ async function tryLoadConfig(metaRoot: string): Promise<Config | undefined> {
|
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* The migrations directory and
|
|
87
|
-
* `meta gen` needs to word its "now migrate" next step and
|
|
86
|
+
* The migrations directory, dialect and format `meta migrate` would use with no flags — what
|
|
87
|
+
* `meta gen` needs to word its "now migrate" next step and read the committed snapshot the
|
|
88
|
+
* way `meta migrate` reads it.
|
|
88
89
|
*/
|
|
89
90
|
export async function resolveMigrateDefaults(
|
|
90
91
|
metaRoot: string,
|
|
91
|
-
): Promise<{ outDir: string; dialect: Dialect | undefined }> {
|
|
92
|
+
): Promise<{ outDir: string; dialect: Dialect | undefined; format: MigrateFormat }> {
|
|
92
93
|
const config = await tryLoadConfig(metaRoot);
|
|
93
94
|
return {
|
|
94
95
|
outDir: config?.migrate?.outDir ?? MIGRATE_DEFAULTS.outDir,
|
|
95
96
|
dialect: config?.migrate?.dialect ?? MIGRATE_DEFAULTS.dialect,
|
|
97
|
+
format: config?.migrate?.format ?? MIGRATE_DEFAULTS.format,
|
|
96
98
|
};
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// ADR-0019 Amendment 1, W4 — the SQLite and D1 tier has no decimal type. A text value with
|
|
2
|
+
// NUMERIC affinity is stored as an INTEGER or REAL, keeping only its first 15 significant
|
|
3
|
+
// digits, so a `field.decimal` declaring `@precision` above 15 is stored lossily.
|
|
4
|
+
// Advisory, never a gate: `meta migrate` warns once per field, on those dialects only.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
FIELD_ATTR_PRECISION,
|
|
8
|
+
FIELD_SUBTYPE_DECIMAL,
|
|
9
|
+
OBJECT_SUBTYPE_ENTITY,
|
|
10
|
+
TYPE_FIELD,
|
|
11
|
+
TYPE_OBJECT,
|
|
12
|
+
type MetaData,
|
|
13
|
+
} from "@metaobjectsdev/metadata";
|
|
14
|
+
|
|
15
|
+
/** Significant digits a SQLite or D1 NUMERIC column keeps exactly. */
|
|
16
|
+
export const SQLITE_DECIMAL_EXACT_DIGITS = 15;
|
|
17
|
+
|
|
18
|
+
const LOSSY_DECIMAL_DIALECTS: readonly string[] = ["sqlite", "d1"];
|
|
19
|
+
|
|
20
|
+
export interface DecimalPrecisionOptions {
|
|
21
|
+
/** Skip an entity by FQN — imported (dependency) metadata is not the adopter's to fix. */
|
|
22
|
+
skip?: (fqn: string) => boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One message per `field.decimal` with `@precision` above 15, or [] on any other dialect. */
|
|
26
|
+
export function decimalPrecisionWarnings(
|
|
27
|
+
root: MetaData,
|
|
28
|
+
dialect: string,
|
|
29
|
+
opts: DecimalPrecisionOptions = {},
|
|
30
|
+
): string[] {
|
|
31
|
+
if (!LOSSY_DECIMAL_DIALECTS.includes(dialect)) return [];
|
|
32
|
+
const out: string[] = [];
|
|
33
|
+
for (const obj of root.children()) {
|
|
34
|
+
if (obj.type !== TYPE_OBJECT || obj.subType !== OBJECT_SUBTYPE_ENTITY || obj.isAbstract) continue;
|
|
35
|
+
const fqn = obj.resolutionKey();
|
|
36
|
+
if (opts.skip?.(fqn) === true) continue;
|
|
37
|
+
// ADR-0039: resolving — fields (and their @precision) inherited via extends count.
|
|
38
|
+
for (const f of obj.children()) {
|
|
39
|
+
if (f.type !== TYPE_FIELD || f.subType !== FIELD_SUBTYPE_DECIMAL) continue;
|
|
40
|
+
const precision = f.attr(FIELD_ATTR_PRECISION);
|
|
41
|
+
if (typeof precision !== "number" || precision <= SQLITE_DECIMAL_EXACT_DIGITS) continue;
|
|
42
|
+
out.push(
|
|
43
|
+
`${fqn}.${f.name} is a decimal with @precision ${precision}, but ${dialect} keeps only ` +
|
|
44
|
+
`${SQLITE_DECIMAL_EXACT_DIGITS} significant digits of a decimal (it stores NUMERIC as an integer or ` +
|
|
45
|
+
`a double). Digits beyond the ${SQLITE_DECIMAL_EXACT_DIGITS}th are lost; use Postgres for exact wide decimals.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
ORIGIN_AGGREGATE_ATTR_OF,
|
|
51
51
|
isMetaObject,
|
|
52
52
|
resolveObjectRef,
|
|
53
|
+
refTarget,
|
|
53
54
|
type MetaData,
|
|
54
55
|
type MetaObject,
|
|
55
56
|
} from "@metaobjectsdev/metadata";
|
|
@@ -166,12 +167,11 @@ function findHop(entity: MetaObject, hopName: string): MetaData | undefined {
|
|
|
166
167
|
);
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
/** The entity a resolved hop (relationship or identity.reference) points at,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return typeof v === "string" && v !== "" ? headOf(v) : undefined;
|
|
170
|
+
/** The entity a resolved hop (relationship or identity.reference) points at, as the
|
|
171
|
+
* loader bound it (ADR-0059 R7: resolved where the hop's reference was declared). */
|
|
172
|
+
function hopTarget(hop: MetaData): MetaObject | undefined {
|
|
173
|
+
const target = refTarget(hop, hop.type === TYPE_RELATIONSHIP ? RELATIONSHIP_ATTR_OBJECT_REF : IDENTITY_REFERENCE_ATTR_REFERENCES);
|
|
174
|
+
return target !== undefined && isMetaObject(target) ? target : undefined;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
/**
|
|
@@ -187,18 +187,15 @@ function hopTargetRef(hop: MetaData): string | undefined {
|
|
|
187
187
|
* directory takes.
|
|
188
188
|
*/
|
|
189
189
|
function checkEntityFieldPath(
|
|
190
|
-
root: MetaData,
|
|
191
190
|
referrer: MetaData,
|
|
192
191
|
referrerPath: string,
|
|
193
192
|
verb: string,
|
|
194
193
|
raw: string,
|
|
195
|
-
|
|
194
|
+
entity: MetaObject | undefined,
|
|
196
195
|
out: Diagnostic[],
|
|
197
196
|
): void {
|
|
198
|
-
const head = headOf(raw);
|
|
199
|
-
const entity = resolveEntityRef(root, head, referrerPkg);
|
|
200
197
|
if (entity === undefined) return;
|
|
201
|
-
const fieldName = raw.slice(
|
|
198
|
+
const fieldName = raw.slice(headOf(raw).length + 1);
|
|
202
199
|
const field = fieldName !== "" ? entity.fields().find((f) => f.name === fieldName) : undefined;
|
|
203
200
|
if (field !== undefined && considerTarget(referrer, referrerPath, verb, field, out)) {
|
|
204
201
|
return; // the field itself is the deprecated thing — don't ALSO warn about its (fine) entity.
|
|
@@ -232,9 +229,7 @@ function checkViaPath(
|
|
|
232
229
|
const hop = findHop(current, hopName);
|
|
233
230
|
if (hop === undefined) return;
|
|
234
231
|
considerTarget(origin, referrerPath, verb, hop, out);
|
|
235
|
-
const
|
|
236
|
-
if (targetRef === undefined) return;
|
|
237
|
-
const next = resolveEntityRef(root, targetRef, packageOf(current));
|
|
232
|
+
const next = hopTarget(hop);
|
|
238
233
|
if (next === undefined) return;
|
|
239
234
|
considerTarget(origin, referrerPath, verb, next, out);
|
|
240
235
|
current = next;
|
|
@@ -252,11 +247,11 @@ function checkOrigin(root: MetaData, origin: MetaData, referrerPath: string, out
|
|
|
252
247
|
// same stance every origin accessor in meta-origin.ts takes).
|
|
253
248
|
const from = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM);
|
|
254
249
|
if (typeof from === "string" && from !== "") {
|
|
255
|
-
checkEntityFieldPath(
|
|
250
|
+
checkEntityFieldPath(origin, referrerPath, `${subtypeLabel} @from references`, from, resolveEntityRef(root, headOf(from), referrerPkg), out);
|
|
256
251
|
}
|
|
257
252
|
const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF);
|
|
258
253
|
if (typeof of_ === "string" && of_ !== "") {
|
|
259
|
-
checkEntityFieldPath(
|
|
254
|
+
checkEntityFieldPath(origin, referrerPath, `${subtypeLabel} @of references`, of_, resolveEntityRef(root, headOf(of_), referrerPkg), out);
|
|
260
255
|
}
|
|
261
256
|
const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
|
|
262
257
|
if (typeof via === "string" && via !== "") {
|
|
@@ -273,12 +268,9 @@ function checkNode(root: MetaData, node: MetaData, path: string, out: Diagnostic
|
|
|
273
268
|
// @objectRef — relationship.* (its target object) and field.object/value (a
|
|
274
269
|
// value-typed field's own objectRef). Same attr name on both (naming-refs.ts).
|
|
275
270
|
if (node.type === TYPE_RELATIONSHIP || node.type === TYPE_FIELD) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const target = resolveEntityRef(root, ref, owner !== undefined ? packageOf(owner) : "");
|
|
280
|
-
if (target !== undefined) considerTarget(node, path, "references (@objectRef)", target, out);
|
|
281
|
-
}
|
|
271
|
+
// ADR-0059 R7: the target the loader bound, resolved where @objectRef was declared.
|
|
272
|
+
const target = refTarget(node, node.type === TYPE_RELATIONSHIP ? RELATIONSHIP_ATTR_OBJECT_REF : FIELD_ATTR_OBJECT_REF);
|
|
273
|
+
if (target !== undefined && isMetaObject(target)) considerTarget(node, path, "references (@objectRef)", target, out);
|
|
282
274
|
}
|
|
283
275
|
|
|
284
276
|
// @references — identity.reference's FK target. May be bare ("Entity",
|
|
@@ -290,8 +282,8 @@ function checkNode(root: MetaData, node: MetaData, path: string, out: Diagnostic
|
|
|
290
282
|
if (node.type === TYPE_IDENTITY && node.subType === IDENTITY_SUBTYPE_REFERENCE) {
|
|
291
283
|
const raw = node.attr(IDENTITY_REFERENCE_ATTR_REFERENCES);
|
|
292
284
|
if (typeof raw === "string" && raw !== "") {
|
|
293
|
-
const
|
|
294
|
-
checkEntityFieldPath(
|
|
285
|
+
const target = refTarget(node, IDENTITY_REFERENCE_ATTR_REFERENCES);
|
|
286
|
+
checkEntityFieldPath(node, path, "references (@references)", raw, target !== undefined && isMetaObject(target) ? target : undefined, out);
|
|
295
287
|
}
|
|
296
288
|
}
|
|
297
289
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ADR-0034 Amendment 9 — one way to generate. Documentation is written by generators the
|
|
2
|
+
// project lists in `generators`, and `meta gen` runs them. The docs command, `meta verify
|
|
3
|
+
// --docs` and the `docs` config block are gone, and each says so in the amendment's fixed
|
|
4
|
+
// words, pointing at the migration section an agent can apply by hand.
|
|
5
|
+
import { guideUrl } from "@metaobjectsdev/metadata/release-docs";
|
|
6
|
+
|
|
7
|
+
/** The migration guide every message below points at, and the section for this port. */
|
|
8
|
+
export const DOCS_MIGRATION_GUIDE = guideUrl("docs/features/migrations/docs-are-generators.md");
|
|
9
|
+
const SECTION = `section "TypeScript"`;
|
|
10
|
+
/** Where a TypeScript project declares a docs generator. */
|
|
11
|
+
const WHERE = "in `generators` in metaobjects.config.ts";
|
|
12
|
+
|
|
13
|
+
/** What `meta docs` prints before it exits 2. */
|
|
14
|
+
export const REMOVED_DOCS_COMMAND =
|
|
15
|
+
"`meta docs` was removed in 1.1.0. `meta gen` writes documentation now: declare each docs " +
|
|
16
|
+
`generator ${WHERE}. See ${DOCS_MIGRATION_GUIDE}, ${SECTION}.`;
|
|
17
|
+
|
|
18
|
+
/** What `meta verify --docs` prints before it exits 2. */
|
|
19
|
+
export const REMOVED_VERIFY_DOCS =
|
|
20
|
+
"`meta verify --docs` was removed in 1.1.0. `meta verify --codegen` checks documentation now, " +
|
|
21
|
+
`with everything else \`generators\` declares. See ${DOCS_MIGRATION_GUIDE}, ${SECTION}.`;
|
|
22
|
+
|
|
23
|
+
/** What a gen run prints, and fails on, when there is no config to read the list from. */
|
|
24
|
+
export const NO_CONFIG_MESSAGE =
|
|
25
|
+
"meta gen: no metaobjects.config.ts. Nothing is generated until a config declares it, " +
|
|
26
|
+
"documentation included; `meta init` writes one. A 1.0 project that ran `meta docs` with no " +
|
|
27
|
+
`config: see ${DOCS_MIGRATION_GUIDE}, ${SECTION}.`;
|
|
28
|
+
|
|
29
|
+
/** True when a loaded config still carries the 1.0 `docs` block. */
|
|
30
|
+
export function hasLegacyDocsBlock(config: object): boolean {
|
|
31
|
+
return Object.hasOwn(config, "docs");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What `command` prints, and fails on, before it writes anything, for a 1.0 `docs` block. */
|
|
35
|
+
export function legacyDocsConfigMessage(command: string): string {
|
|
36
|
+
return (
|
|
37
|
+
`${command}: found 1.0 docs configuration (the docs block in metaobjects.config.ts). ` +
|
|
38
|
+
"Nothing reads it any more, so this run would write no documentation. Declare each docs " +
|
|
39
|
+
`generator ${WHERE}. See ${DOCS_MIGRATION_GUIDE}, ${SECTION}.`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// `meta eject docs-site` (ADR-0034 Amendment 9): copy the HTML site's templates and assets
|
|
2
|
+
// into `codegen/docs-site/{templates,assets}`, where `docsSiteFile()` reads them in place of
|
|
3
|
+
// the bundled ones. The site's BUILDER stays packaged (it assembles the page data the
|
|
4
|
+
// templates read; Amendment 8 ruling 3); what an adopter changes is the look, and that is
|
|
5
|
+
// these files. It was `meta docs --scaffold-site` until 1.1.0, and writes the same files to
|
|
6
|
+
// the same place.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { OWNED_DOCS_SITE_DIR } from "@metaobjectsdev/codegen-ts";
|
|
12
|
+
import { readSiteFile, SITE_ASSET_NAMES, SITE_TEMPLATE_NAMES } from "@metaobjectsdev/docs-site";
|
|
13
|
+
|
|
14
|
+
/** The name `meta eject` takes for the site theme. A catalog entry, but not a generator copy. */
|
|
15
|
+
export const DOCS_SITE_NAME = "docs-site";
|
|
16
|
+
|
|
17
|
+
export interface DocsSiteFileResult {
|
|
18
|
+
/** Project-relative path. */
|
|
19
|
+
path: string;
|
|
20
|
+
status: "created" | "preserved" | "replaced";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write each template and asset that is missing. A file already there is kept unless
|
|
25
|
+
* `force`: a re-run never clobbers a theme the adopter has edited.
|
|
26
|
+
*/
|
|
27
|
+
export async function ejectDocsSite(projectRoot: string, force: boolean): Promise<DocsSiteFileResult[]> {
|
|
28
|
+
const out: DocsSiteFileResult[] = [];
|
|
29
|
+
const sets: Array<{ dir: string; names: readonly string[]; kind: "template" | "asset" }> = [
|
|
30
|
+
{ dir: `${OWNED_DOCS_SITE_DIR}/templates`, names: SITE_TEMPLATE_NAMES, kind: "template" },
|
|
31
|
+
{ dir: `${OWNED_DOCS_SITE_DIR}/assets`, names: SITE_ASSET_NAMES, kind: "asset" },
|
|
32
|
+
];
|
|
33
|
+
for (const { dir, names, kind } of sets) {
|
|
34
|
+
await mkdir(join(projectRoot, dir), { recursive: true });
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
const path = `${dir}/${name}`;
|
|
37
|
+
const abs = join(projectRoot, path);
|
|
38
|
+
const existed = existsSync(abs);
|
|
39
|
+
if (existed && !force) {
|
|
40
|
+
out.push({ path, status: "preserved" });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
await writeFile(abs, readSiteFile(kind, name), "utf8");
|
|
44
|
+
out.push({ path, status: existed ? "replaced" : "created" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// `meta verify` — the field AUTHORING lint.
|
|
2
|
+
//
|
|
3
|
+
// Two metadata mistakes about an object's FIELDS load with no error on every port:
|
|
4
|
+
//
|
|
5
|
+
// 1. An `identity.reference` whose `@fields` names a field the object does not have.
|
|
6
|
+
// The loader resolves `@references` (the target) and never looks at `@fields`, so a
|
|
7
|
+
// typo there produces a foreign key over a column nothing declares.
|
|
8
|
+
// 2. Two `field.*` children with the same `name` in one object's `children` list. The
|
|
9
|
+
// later declaration is folded into the first, and one of a different subtype is
|
|
10
|
+
// dropped, both silently.
|
|
11
|
+
//
|
|
12
|
+
// WHY THESE ARE WARNINGS AND NOT LOAD ERRORS. docs/compatibility-policy.md does not allow
|
|
13
|
+
// a new load error for metadata that loads today — name-lint.ts's header has the same
|
|
14
|
+
// ruling for whitespace names. So, like every sibling lint in this directory:
|
|
15
|
+
//
|
|
16
|
+
// EVERY FINDING IS A WARNING, BY CONSTRUCTION. Nothing here reaches the exit code.
|
|
17
|
+
//
|
|
18
|
+
// THE TWO HALVES READ DIFFERENT THINGS, and have to. The reference half reads the LOADED
|
|
19
|
+
// model, because "does this object have that field" is a question about the EFFECTIVE
|
|
20
|
+
// field set — inherited through `extends` and merged from overlay files. The duplicate
|
|
21
|
+
// half reads the RAW DOCUMENTS, because the merge has already erased the duplicate from
|
|
22
|
+
// the model by the time anything can ask (overlay-lint.ts reads files for the same reason).
|
|
23
|
+
//
|
|
24
|
+
// The codes, the message text and the fixtures are shared with the C#, Java and Python
|
|
25
|
+
// CLIs: fixtures/field-lint-conformance/.
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
IDENTITY_ATTR_FIELDS,
|
|
29
|
+
IDENTITY_SUBTYPE_REFERENCE,
|
|
30
|
+
TYPE_FIELD,
|
|
31
|
+
TYPE_IDENTITY,
|
|
32
|
+
TYPE_OBJECT,
|
|
33
|
+
type MetaData,
|
|
34
|
+
type MetaDataSource,
|
|
35
|
+
} from "@metaobjectsdev/metadata";
|
|
36
|
+
import { declaredDuplicateFields, type DeclaredDuplicateField } from "@metaobjectsdev/metadata/core";
|
|
37
|
+
import type { Diagnostic } from "./requirement-check.js";
|
|
38
|
+
|
|
39
|
+
/** An `identity.reference` lists a field its object does not have. */
|
|
40
|
+
export const WARN_REFERENCE_FIELD_NOT_FOUND = "WARN_REFERENCE_FIELD_NOT_FOUND";
|
|
41
|
+
/** One `children` list declares the same field name more than once. */
|
|
42
|
+
export const WARN_DUPLICATE_FIELD_NAME = "WARN_DUPLICATE_FIELD_NAME";
|
|
43
|
+
|
|
44
|
+
/** Resolve one metadata file path to the source the duplicate scan reads it through. */
|
|
45
|
+
export type ReadSource = (path: string) => MetaDataSource;
|
|
46
|
+
|
|
47
|
+
function warn(path: string, code: string, message: string): Diagnostic {
|
|
48
|
+
return { severity: "warn", code, path, message };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Report every `identity.reference` whose `@fields` names a field its object lacks.
|
|
53
|
+
* Returns `[]` for a model where every listed field exists.
|
|
54
|
+
*/
|
|
55
|
+
export function lintReferenceFields(root: MetaData): Diagnostic[] {
|
|
56
|
+
const out: Diagnostic[] = [];
|
|
57
|
+
// OWN-ONLY (ADR-0039 sanctioned case): a root has no super, and its own children are
|
|
58
|
+
// the declared objects.
|
|
59
|
+
for (const object of root.ownChildren()) {
|
|
60
|
+
if (object.type !== TYPE_OBJECT) continue;
|
|
61
|
+
const address = object.resolutionKey();
|
|
62
|
+
// RESOLVING: the effective field set — a field inherited through `extends` or added
|
|
63
|
+
// by an overlay file is a field the object has.
|
|
64
|
+
const fields = new Set(object.children().filter((c) => c.type === TYPE_FIELD).map((c) => c.name));
|
|
65
|
+
// OWN-ONLY (ADR-0039 sanctioned case): report each DECLARATION once, on the object
|
|
66
|
+
// that declares it. The resolving `children()` would repeat an inherited reference
|
|
67
|
+
// on every subtype.
|
|
68
|
+
for (const identity of object.ownChildren()) {
|
|
69
|
+
if (identity.type !== TYPE_IDENTITY || identity.subType !== IDENTITY_SUBTYPE_REFERENCE) continue;
|
|
70
|
+
// RESOLVING: `@fields` may itself be inherited through the identity's `extends`.
|
|
71
|
+
const listed = identity.attr(IDENTITY_ATTR_FIELDS);
|
|
72
|
+
const names = Array.isArray(listed) ? listed : typeof listed === "string" ? [listed] : [];
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
if (typeof name !== "string" || fields.has(name)) continue;
|
|
75
|
+
out.push(warn(`${address}.${identity.name}`, WARN_REFERENCE_FIELD_NOT_FOUND, referenceFieldMessage(identity.name, name, address)));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function referenceFieldMessage(identity: string, field: string, object: string): string {
|
|
83
|
+
return (
|
|
84
|
+
`identity.reference ${JSON.stringify(identity)} lists ${JSON.stringify(field)} in @fields, but ` +
|
|
85
|
+
`${object} has no field of that name, inherited and overlaid fields included. Nothing checks ` +
|
|
86
|
+
`this at load, so the reference is built on a field that does not exist. Rename the entry to ` +
|
|
87
|
+
`an existing field, or declare the field.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One raw-scan finding as a diagnostic. Exported so the scan and its text are testable
|
|
92
|
+
* without a filesystem. */
|
|
93
|
+
export function duplicateFieldDiagnostic(dup: DeclaredDuplicateField): Diagnostic {
|
|
94
|
+
return warn(
|
|
95
|
+
`${dup.object}.${dup.field}`,
|
|
96
|
+
WARN_DUPLICATE_FIELD_NAME,
|
|
97
|
+
`${dup.object} declares the field ${JSON.stringify(dup.field)} ${dup.count} times in one children ` +
|
|
98
|
+
`list. Nothing reports this at load, and only the first declaration is certain to take effect. ` +
|
|
99
|
+
`Remove or rename the duplicate.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Report every field name declared more than once in one object's `children` list,
|
|
105
|
+
* scanning each of `files` as raw content.
|
|
106
|
+
*
|
|
107
|
+
* Unreadable or unparsable files are skipped — the loader reports those itself.
|
|
108
|
+
*/
|
|
109
|
+
export async function lintDuplicateFields(files: readonly string[], readSource: ReadSource): Promise<Diagnostic[]> {
|
|
110
|
+
const out: Diagnostic[] = [];
|
|
111
|
+
for (const path of files) {
|
|
112
|
+
let declared: ReadonlyArray<DeclaredDuplicateField>;
|
|
113
|
+
try {
|
|
114
|
+
const source = readSource(path);
|
|
115
|
+
declared = declaredDuplicateFields(await source.read(), source.format);
|
|
116
|
+
} catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
out.push(...declared.map(duplicateFieldDiagnostic));
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|