@metaobjectsdev/cli 1.0.10 → 1.0.12

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.
Files changed (55) hide show
  1. package/dist/src/commands/eject.d.ts.map +1 -1
  2. package/dist/src/commands/eject.js +5 -1
  3. package/dist/src/commands/eject.js.map +1 -1
  4. package/dist/src/commands/fmt.d.ts +2 -0
  5. package/dist/src/commands/fmt.d.ts.map +1 -0
  6. package/dist/src/commands/fmt.js +80 -0
  7. package/dist/src/commands/fmt.js.map +1 -0
  8. package/dist/src/commands/gen.d.ts.map +1 -1
  9. package/dist/src/commands/gen.js +7 -0
  10. package/dist/src/commands/gen.js.map +1 -1
  11. package/dist/src/commands/migrate.d.ts.map +1 -1
  12. package/dist/src/commands/migrate.js +1 -0
  13. package/dist/src/commands/migrate.js.map +1 -1
  14. package/dist/src/commands/verify.d.ts.map +1 -1
  15. package/dist/src/commands/verify.js +74 -1
  16. package/dist/src/commands/verify.js.map +1 -1
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/index.js +76 -0
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/lib/args.d.ts +36 -0
  21. package/dist/src/lib/args.d.ts.map +1 -1
  22. package/dist/src/lib/args.js +20 -0
  23. package/dist/src/lib/args.js.map +1 -1
  24. package/dist/src/lib/deprecation-lint.d.ts +11 -0
  25. package/dist/src/lib/deprecation-lint.d.ts.map +1 -0
  26. package/dist/src/lib/deprecation-lint.js +273 -0
  27. package/dist/src/lib/deprecation-lint.js.map +1 -0
  28. package/dist/src/lib/fk-index-advisory.d.ts.map +1 -1
  29. package/dist/src/lib/fk-index-advisory.js +4 -1
  30. package/dist/src/lib/fk-index-advisory.js.map +1 -1
  31. package/dist/src/lib/fmt-engine.d.ts +22 -0
  32. package/dist/src/lib/fmt-engine.d.ts.map +1 -0
  33. package/dist/src/lib/fmt-engine.js +128 -0
  34. package/dist/src/lib/fmt-engine.js.map +1 -0
  35. package/dist/src/lib/name-lint.d.ts +12 -0
  36. package/dist/src/lib/name-lint.d.ts.map +1 -0
  37. package/dist/src/lib/name-lint.js +95 -0
  38. package/dist/src/lib/name-lint.js.map +1 -0
  39. package/dist/src/lib/runtime-boundary-advisory.d.ts +24 -0
  40. package/dist/src/lib/runtime-boundary-advisory.d.ts.map +1 -0
  41. package/dist/src/lib/runtime-boundary-advisory.js +47 -0
  42. package/dist/src/lib/runtime-boundary-advisory.js.map +1 -0
  43. package/package.json +10 -10
  44. package/src/commands/eject.ts +6 -1
  45. package/src/commands/fmt.ts +90 -0
  46. package/src/commands/gen.ts +9 -0
  47. package/src/commands/migrate.ts +1 -0
  48. package/src/commands/verify.ts +90 -1
  49. package/src/index.ts +76 -0
  50. package/src/lib/args.ts +47 -0
  51. package/src/lib/deprecation-lint.ts +327 -0
  52. package/src/lib/fk-index-advisory.ts +4 -1
  53. package/src/lib/fmt-engine.ts +179 -0
  54. package/src/lib/name-lint.ts +106 -0
  55. package/src/lib/runtime-boundary-advisory.ts +74 -0
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ COMMANDS:
41
41
  deps list One line per locked dependency: name, version, hash, node/package summary
42
42
  types [query] Search the metadata vocabulary (types, subtypes, @attrs) by name or description
43
43
  export Flatten loaded metadata to one canonical JSON artifact
44
+ fmt [<file>...] Rewrite metadata files into canonical form; --check lists drift, exits non-zero
44
45
  docs [<project-root>] --out <dir> Generate neutral metadata documentation (entity + template pages; --site for HTML site)
45
46
  verify Drift gate — subverbs: --templates / --db / --codegen / --docs / --deps (bare = --templates)
46
47
  upgrade Rewrite retired metadata vocabulary (previews; --apply writes)
@@ -86,6 +87,21 @@ GEN FLAGS:
86
87
  EXPORT FLAGS:
87
88
  --out <file> Write output to a file (default: stdout)
88
89
 
90
+ FMT FLAGS:
91
+ [<file>...] Optional explicit file(s) to format — must be members of this
92
+ project's resolved metadata sources (resolveCollection()); omit
93
+ to format every one of them.
94
+ --check List files that are not canonical and exit non-zero; changes nothing.
95
+ A file whose formatting would change the LOADED MODEL's meaning
96
+ is never rewritten — reported as an error instead (safety check).
97
+ A YAML file is always reported as skipped: no canonical YAML
98
+ emitter exists yet (ADR-0006 — JSON is the canonical interchange
99
+ form). A file declaring 'overlay: true' anywhere — whether or not
100
+ a same-file base exists to merge into — is skipped too: a
101
+ formatter never changes structure, and buildTree's ordinary
102
+ find-or-reuse behavior would otherwise silently fold a same-file
103
+ base + overlay into one node, dropping the overlay marker.
104
+
89
105
  DOCS FLAGS:
90
106
  [<project-root>] PROJECT ROOT to resolve metadata from — the directory that CONTAINS
91
107
  your metadata, NOT the metadata directory. Passing it SCOPES the run
@@ -127,6 +143,10 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif
127
143
  --no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate)
128
144
  --no-overlay-lint Suppress the advisory overlay-redeclaration AUTHORING lint
129
145
  (never a gate — this lint can't fail the build)
146
+ --no-name-lint Suppress the advisory node-name AUTHORING lint (whitespace
147
+ in a name; never a gate — this lint can't fail the build)
148
+ --no-deprecation-lint Suppress the advisory deprecated-reference AUTHORING lint
149
+ (never a gate — this lint can't fail the build)
130
150
  --limit <n|all> How many advisory lines TEXT output prints per section before
131
151
  it truncates (default 20). Never applies to --format
132
152
  toon/json, which carry every finding and every diagnostic.
@@ -320,6 +340,10 @@ FLAGS:
320
340
  --no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate)
321
341
  --no-overlay-lint Suppress the advisory overlay-redeclaration AUTHORING lint —
322
342
  never a gate; this lint can't fail the build
343
+ --no-name-lint Suppress the advisory node-name AUTHORING lint (whitespace in
344
+ a name) — never a gate; this lint can't fail the build
345
+ --no-deprecation-lint Suppress the advisory deprecated-reference AUTHORING lint —
346
+ never a gate; this lint can't fail the build
323
347
  --limit <n|all> Advisory lines TEXT output prints PER SECTION before truncating
324
348
  (default 20; per-section so the authoring lint can never push
325
349
  the gate's own warnings off the end)
@@ -350,6 +374,21 @@ redeclaration lacks 'overlay: true' — today's default merge rule reuses it
350
374
  silently, but the same unflagged redeclaration silently becomes a NEW object
351
375
  the day the target is renamed or removed upstream. Warnings only — it can
352
376
  never fail the build. Opt out with --no-overlay-lint or META_NO_OVERLAY_LINT=1.
377
+
378
+ verify also prints an ADVISORY node-name authoring lint, in its own section: any
379
+ node (object, field, identity, relationship, ...) whose name contains whitespace.
380
+ Leading or trailing whitespace is almost certainly a copy-paste accident — rename
381
+ the node, and pin @column on a field whose physical column must keep its name.
382
+ Requirement names are left to the requirement lint above. Warnings only — it can
383
+ never fail the build. Opt out with --no-name-lint or META_NO_NAME_LINT=1.
384
+
385
+ verify also prints an ADVISORY deprecated-reference authoring lint, in its own
386
+ section: any node that depends on a node carrying the documentation @deprecated
387
+ attr, via extends, @objectRef, @references, or an origin's @from/@of/@via. Where
388
+ the deprecated node also carries @replacedBy, the finding names the replacement.
389
+ A node referencing itself (a recursive FK, a same-entity passthrough) is not a
390
+ finding. Warnings only — it can never fail the build. Opt out with
391
+ --no-deprecation-lint or META_NO_DEPRECATION_LINT=1.
353
392
  `,
354
393
  export: `meta export — flatten loaded metadata to one canonical JSON artifact
355
394
 
@@ -359,6 +398,39 @@ USAGE:
359
398
  FLAGS:
360
399
  --out <file> Write output to a file (default: stdout)
361
400
  --help, -h Print this help
401
+ `,
402
+ fmt: `meta fmt — rewrite metadata files into canonical form
403
+
404
+ USAGE:
405
+ meta fmt [<file>...] [flags]
406
+
407
+ Formats every JSON metadata file this project's resolved sources contain
408
+ (resolveCollection() — never a hardcoded default directory), or just the
409
+ files named. Each file is formatted as its OWN content (own-mode,
410
+ declared-here layer only) — never inlining an inherited member or another
411
+ file's overlay.
412
+
413
+ FLAGS:
414
+ [<file>...] Optional explicit file(s) — must be members of this project's
415
+ resolved metadata sources; omit to format every one of them.
416
+ --check List files that are not canonical and exit non-zero; changes
417
+ nothing on disk.
418
+ --help, -h Print this help
419
+
420
+ SAFETY: before writing any file, fmt reloads the whole project with that file's
421
+ candidate content swapped in and requires the loaded model's canonical form to
422
+ be byte-identical to the untouched baseline. A file that fails this check is
423
+ left exactly as it was and reported as an error — fmt never changes meaning.
424
+
425
+ YAML: left untouched and reported as skipped — no canonical YAML emitter
426
+ exists yet (ADR-0006: JSON is the canonical interchange form; YAML is an
427
+ authoring format).
428
+
429
+ OVERLAY: a file declaring 'overlay: true' ANYWHERE in its tree is reported as
430
+ skipped — not only when no base exists in the same file. A formatter never
431
+ changes structure: even when a same-file base IS present, merging it with
432
+ the overlay (what the ordinary loader would do) would drop the overlay
433
+ marker from the output, so fmt refuses that too and leaves the file as-is.
362
434
  `,
363
435
  docs: `meta docs — generate neutral metadata documentation (entity + template pages)
364
436
 
@@ -634,6 +706,10 @@ export async function run(argv: string[]): Promise<number> {
634
706
  const { exportCommand } = await import("./commands/export.js");
635
707
  return exportCommand(rest, cwd);
636
708
  }
709
+ case "fmt": {
710
+ const { fmtCommand } = await import("./commands/fmt.js");
711
+ return fmtCommand(rest, cwd);
712
+ }
637
713
  case "types": {
638
714
  const { typesCommand } = await import("./commands/types.js");
639
715
  return typesCommand(rest, explicitFormat);
package/src/lib/args.ts CHANGED
@@ -190,6 +190,35 @@ export function parseExportArgs(argv: string[]): ExportFlags {
190
190
  };
191
191
  }
192
192
 
193
+ // ---------------------------------------------------------------------------
194
+ // fmt flags (#304)
195
+ // ---------------------------------------------------------------------------
196
+
197
+ export interface FmtFlags {
198
+ check: boolean;
199
+ /** Optional explicit file list — narrows the run to these files (must be
200
+ * members of the project's resolved metadata sources). Empty = every file. */
201
+ files: string[];
202
+ }
203
+
204
+ /** The flag table `parseFmtArgs` parses. Exported so the help text can be gated against it. */
205
+ export const FMT_OPTIONS = {
206
+ check: { type: "boolean", default: false },
207
+ } as const;
208
+
209
+ export function parseFmtArgs(argv: string[]): FmtFlags {
210
+ const { values, positionals } = parseArgs({
211
+ args: argv,
212
+ options: FMT_OPTIONS,
213
+ strict: true,
214
+ allowPositionals: true,
215
+ });
216
+ return {
217
+ check: !!values.check,
218
+ files: positionals,
219
+ };
220
+ }
221
+
193
222
  // ---------------------------------------------------------------------------
194
223
  // shared DB-connection vocab (used by both verify --db and migrate)
195
224
  // ---------------------------------------------------------------------------
@@ -362,6 +391,20 @@ export interface VerifyFlags {
362
391
  * lint has no gate half at all — it can never fail the build).
363
392
  */
364
393
  noOverlayLint: boolean;
394
+ /**
395
+ * Suppress the advisory node-NAME authoring lint — whitespace in a node's
396
+ * `name` (a trailing space copied from legacy XML loads clean on every port).
397
+ * Advisory only, like its siblings: it has no gate half and can never fail
398
+ * the build.
399
+ */
400
+ noNameLint: boolean;
401
+ /**
402
+ * Suppress the advisory deprecated-REFERENCE authoring lint (#305) — a node
403
+ * depending (extends/@objectRef/@references/origin @from/@of/@via) on a
404
+ * `deprecated` node. Advisory only, like its siblings: it has no gate half
405
+ * and can never fail the build.
406
+ */
407
+ noDeprecationLint: boolean;
365
408
  /**
366
409
  * ADR-0023 strict-attr load opt-OUT (#96). `verify` is strict-by-default — an
367
410
  * undeclared/typo'd own `@attr` fails verify (ERR_UNKNOWN_ATTR). `--lax`
@@ -403,6 +446,8 @@ export const VERIFY_OPTIONS = {
403
446
  "no-antipatterns": { type: "boolean", default: false },
404
447
  "no-requirement-lint": { type: "boolean", default: false },
405
448
  "no-overlay-lint": { type: "boolean", default: false },
449
+ "no-name-lint": { type: "boolean", default: false },
450
+ "no-deprecation-lint": { type: "boolean", default: false },
406
451
  lax: { type: "boolean", default: false },
407
452
  "d1": { type: "string" },
408
453
  "remote": { type: "boolean", default: false },
@@ -487,6 +532,8 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
487
532
  noAntipatterns: !!values["no-antipatterns"],
488
533
  noRequirementLint: !!values["no-requirement-lint"],
489
534
  noOverlayLint: !!values["no-overlay-lint"],
535
+ noNameLint: !!values["no-name-lint"],
536
+ noDeprecationLint: !!values["no-deprecation-lint"],
490
537
  lax: !!values.lax,
491
538
  d1: values.d1 as string | undefined,
492
539
  remote: !!values.remote,
@@ -0,0 +1,327 @@
1
+ // `meta verify` — the deprecated-REFERENCE authoring lint (#305).
2
+ //
3
+ // `deprecated` / `replacedBy` are registered documentation common-attrs in every
4
+ // port (doc-provider.ts) and nothing reads them: no validation pass, no `verify`
5
+ // check, no codegen behavior. A deprecation you cannot enforce is a comment.
6
+ //
7
+ // Smallest useful version (approved scope): warn when a node DEPENDS on a node
8
+ // carrying `deprecated`, through the reference kinds the metamodel already has —
9
+ // `extends`, `@objectRef`, `@references`, and an origin's `@from` / `@of` / `@via`
10
+ // path. Where the deprecated node also carries `replacedBy`, the diagnostic names
11
+ // the replacement. Never fails the build — ADVISORY ONLY, the same discipline as
12
+ // every sibling authoring lint in this directory (name-lint.ts, overlay-lint.ts,
13
+ // requirement-lint.ts).
14
+ //
15
+ // Explicitly NOT in scope here (tracked separately): a deprecation DATE that
16
+ // escalates warn -> error once passed, and per-finding suppressions with a
17
+ // recorded reason (#302) — both need their own design.
18
+ //
19
+ // SELF-REFERENCE IS NOT A FINDING, but ONLY the literal case: the same node, or
20
+ // one of the node's own ANCESTORS (the recursive-FK shape: `Category.parentId`
21
+ // is a child of `Category` and points back at `Category` itself — not a
22
+ // dependency on a different thing, it IS the deprecated thing). A DIFFERENT
23
+ // node that merely happens to live under the same entity — e.g. a passthrough
24
+ // field reading a DEPRECATED SIBLING field on its own entity — is a real
25
+ // cross-node dependency and DOES warn; sharing an ancestor is not sharing an
26
+ // identity. Guarded by `isSelfReference` below.
27
+ //
28
+ // Resolution reads the LOADED, FROZEN model only, via the RESOLVING accessors
29
+ // (ADR-0039: `attr()`, `children()`, `superData`) — so a target that inherits
30
+ // `deprecated` from its own abstract base still counts, and a reference
31
+ // inherited through `extends` is reported once, at its declaration
32
+ // (`ownChildren()`, the same sanctioned case name-lint.ts uses).
33
+
34
+ import {
35
+ DOC_ATTR_DEPRECATED,
36
+ DOC_ATTR_REPLACED_BY,
37
+ TYPE_FIELD,
38
+ TYPE_IDENTITY,
39
+ TYPE_RELATIONSHIP,
40
+ TYPE_ORIGIN,
41
+ IDENTITY_SUBTYPE_REFERENCE,
42
+ IDENTITY_REFERENCE_ATTR_REFERENCES,
43
+ RELATIONSHIP_ATTR_OBJECT_REF,
44
+ FIELD_ATTR_OBJECT_REF,
45
+ // @via is spelled identically on every origin subtype (passthrough / aggregate /
46
+ // first); @of is identical on aggregate and first. One constant each, reused —
47
+ // see origin-constants.ts.
48
+ ORIGIN_PASSTHROUGH_ATTR_FROM,
49
+ ORIGIN_PASSTHROUGH_ATTR_VIA,
50
+ ORIGIN_AGGREGATE_ATTR_OF,
51
+ isMetaObject,
52
+ resolveObjectRef,
53
+ type MetaData,
54
+ type MetaObject,
55
+ } from "@metaobjectsdev/metadata";
56
+ import type { Diagnostic } from "./requirement-check.js";
57
+
58
+ export const WARN_DEPRECATED_REFERENCE = "WARN_DEPRECATED_REFERENCE";
59
+
60
+ function warn(path: string, message: string): Diagnostic {
61
+ return { severity: "warn", code: WARN_DEPRECATED_REFERENCE, path, message };
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // addressing — mirrors name-lint.ts's topLevelAddress/segment, applied to every
66
+ // node (not only ones whose own name is the finding).
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function packageOf(node: MetaData): string {
70
+ return node.package ?? node.fileDefaultPackage ?? "";
71
+ }
72
+
73
+ /** Walk up to the nearest enclosing `object.*` — every field/identity/relationship/
74
+ * origin is nested under exactly one (objects are never nested in this metamodel). */
75
+ function owningObject(node: MetaData): MetaObject | undefined {
76
+ let cur: MetaData | undefined = node.parent;
77
+ while (cur !== undefined && !isMetaObject(cur)) cur = cur.parent;
78
+ return cur;
79
+ }
80
+
81
+ /** A human-readable address for a node: the object's FQN, or `<object FQN>.<name>` for
82
+ * something nested inside one. Falls back to the bare name (an abstract field
83
+ * declared at root, outside any object) when there is no enclosing object. */
84
+ function addressOf(node: MetaData): string {
85
+ if (isMetaObject(node)) return node.resolutionKey();
86
+ const owner = owningObject(node);
87
+ return owner !== undefined ? `${owner.resolutionKey()}.${node.name}` : node.name;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // self-reference guard
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function isAncestorOf(maybeAncestor: MetaData, node: MetaData): boolean {
95
+ let cur: MetaData | undefined = node.parent;
96
+ while (cur !== undefined) {
97
+ if (cur === maybeAncestor) return true;
98
+ cur = cur.parent;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * True when `target` is not really an OTHER node from `referrer`'s point of
105
+ * view: the same node, or an ANCESTOR of it (the referrer's own enclosing
106
+ * object/field — the recursive-FK shape). Deliberately NOT symmetric: a
107
+ * DESCENDANT of `referrer` is a real, different thing to depend on (and no
108
+ * call site here ever resolves a target nested inside the specific referrer
109
+ * node anyway — a sibling field, the common case, is nested in the shared
110
+ * parent ENTITY, not in the referrer).
111
+ */
112
+ function isSelfReference(referrer: MetaData, target: MetaData): boolean {
113
+ return referrer === target || isAncestorOf(target, referrer);
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // the check: does `target` carry `deprecated`, resolved?
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Returns true when a finding was pushed — callers checking a MORE SPECIFIC
121
+ * target first (e.g. a field) use this to decide whether to also check the
122
+ * less-specific one (its entity): only when the specific one was silent. */
123
+ function considerTarget(referrer: MetaData, referrerPath: string, verb: string, target: MetaData, out: Diagnostic[]): boolean {
124
+ if (isSelfReference(referrer, target)) return false;
125
+ // Registry contract (documentation-definition.embedded.ts): "Presence ⇒
126
+ // deprecated" — an empty-string reason still means deprecated (codegen-ts's
127
+ // jsdoc.ts reads it the same way, via `!== undefined`, not a truthiness
128
+ // check). Only an ABSENT attr (undefined) means "not deprecated".
129
+ const reason = target.attr(DOC_ATTR_DEPRECATED);
130
+ if (typeof reason !== "string") return false;
131
+ const replacedByRaw = target.attr(DOC_ATTR_REPLACED_BY);
132
+ const replacedBy = typeof replacedByRaw === "string" && replacedByRaw !== "" ? replacedByRaw : undefined;
133
+ const targetKind = `${target.type}.${target.subType}`;
134
+ const reasonClause = reason !== "" ? `: ${reason}` : "";
135
+ out.push(
136
+ warn(
137
+ referrerPath,
138
+ `${verb} deprecated ${targetKind} ${addressOf(target)}${reasonClause}` +
139
+ (replacedBy !== undefined ? ` Replaced by ${replacedBy}.` : ""),
140
+ ),
141
+ );
142
+ return true;
143
+ }
144
+
145
+ /** Split a dotted reference into its entity HEAD and member tail ("Entity.field" ->
146
+ * "Entity" / "field"). `@objectRef` carries no tail (a pure object ref) and is
147
+ * resolved whole by the caller; `@references`/`@from`/`@of`/`@via` do. */
148
+ function headOf(raw: string): string {
149
+ const dot = raw.indexOf(".");
150
+ return dot === -1 ? raw : raw.slice(0, dot);
151
+ }
152
+
153
+ function resolveEntityRef(root: MetaData, ref: string, referrerPkg: string): MetaObject | undefined {
154
+ const node = resolveObjectRef(root, ref, referrerPkg).node;
155
+ return node !== undefined && isMetaObject(node) ? node : undefined;
156
+ }
157
+
158
+ /** The relationship / identity.reference CHILD on `entity` named `hopName` — a
159
+ * `@via` path segment. Resolving `children()` (ADR-0039): an inherited hop still
160
+ * counts. */
161
+ function findHop(entity: MetaObject, hopName: string): MetaData | undefined {
162
+ return entity.children().find(
163
+ (c) =>
164
+ (c.type === TYPE_RELATIONSHIP || (c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_REFERENCE)) &&
165
+ c.name === hopName,
166
+ );
167
+ }
168
+
169
+ /** The entity a resolved hop (relationship or identity.reference) points at, raw. */
170
+ function hopTargetRef(hop: MetaData): string | undefined {
171
+ const v = hop.type === TYPE_RELATIONSHIP
172
+ ? hop.attr(RELATIONSHIP_ATTR_OBJECT_REF)
173
+ : hop.attr(IDENTITY_REFERENCE_ATTR_REFERENCES);
174
+ return typeof v === "string" && v !== "" ? headOf(v) : undefined;
175
+ }
176
+
177
+ /**
178
+ * Check a dotted "Entity" or "Entity.field" reference — shared by origin
179
+ * `@from`/`@of` AND identity.reference's `@references` (ADR-0042 §5: the
180
+ * dotted form is not origin-specific; `Team.code` names an explicit FK target
181
+ * field exactly the way `Country.name` names a passthrough source field — see
182
+ * `fixtures/conformance/relationship-one-two-refs-dotted-references/`). The
183
+ * field is the more specific target (checked first); the entity is checked
184
+ * independently so a field on an otherwise-fine entity, OR a whole deprecated
185
+ * entity, each warn. Resolution failure (either half) is silent — bias to
186
+ * under-flagging, the same stance every sibling advisory pass in this
187
+ * directory takes.
188
+ */
189
+ function checkEntityFieldPath(
190
+ root: MetaData,
191
+ referrer: MetaData,
192
+ referrerPath: string,
193
+ verb: string,
194
+ raw: string,
195
+ referrerPkg: string,
196
+ out: Diagnostic[],
197
+ ): void {
198
+ const head = headOf(raw);
199
+ const entity = resolveEntityRef(root, head, referrerPkg);
200
+ if (entity === undefined) return;
201
+ const fieldName = raw.slice(head.length + 1);
202
+ const field = fieldName !== "" ? entity.fields().find((f) => f.name === fieldName) : undefined;
203
+ if (field !== undefined && considerTarget(referrer, referrerPath, verb, field, out)) {
204
+ return; // the field itself is the deprecated thing — don't ALSO warn about its (fine) entity.
205
+ }
206
+ considerTarget(referrer, referrerPath, verb, entity, out);
207
+ }
208
+
209
+ /**
210
+ * Walk an origin `@via` relationship path ("Entity.relOrRef[.relOrRef...]"),
211
+ * checking the head entity and every hop it names. Stops silently the moment a
212
+ * segment fails to resolve — a half-walked path still warned on everything it
213
+ * DID resolve, which is the right bias for an advisory that must never crash
214
+ * `meta verify` on a path shape it does not fully understand.
215
+ */
216
+ function checkViaPath(
217
+ root: MetaData,
218
+ origin: MetaData,
219
+ referrerPath: string,
220
+ verb: string,
221
+ via: string,
222
+ referrerPkg: string,
223
+ out: Diagnostic[],
224
+ ): void {
225
+ const segments = via.split(".");
226
+ const headSegment = segments[0];
227
+ if (headSegment === undefined || headSegment === "") return;
228
+ let current = resolveEntityRef(root, headSegment, referrerPkg);
229
+ if (current === undefined) return;
230
+ considerTarget(origin, referrerPath, verb, current, out);
231
+ for (const hopName of segments.slice(1)) {
232
+ const hop = findHop(current, hopName);
233
+ if (hop === undefined) return;
234
+ considerTarget(origin, referrerPath, verb, hop, out);
235
+ const targetRef = hopTargetRef(hop);
236
+ if (targetRef === undefined) return;
237
+ const next = resolveEntityRef(root, targetRef, packageOf(current));
238
+ if (next === undefined) return;
239
+ considerTarget(origin, referrerPath, verb, next, out);
240
+ current = next;
241
+ }
242
+ }
243
+
244
+ function checkOrigin(root: MetaData, origin: MetaData, referrerPath: string, out: Diagnostic[]): void {
245
+ const referrerPkg = (() => {
246
+ const owner = owningObject(origin);
247
+ return owner !== undefined ? packageOf(owner) : "";
248
+ })();
249
+ const subtypeLabel = `origin.${origin.subType}`;
250
+
251
+ // ADR-0039: own — origin.* never inherits (ADR-0029), so own is correct (the
252
+ // same stance every origin accessor in meta-origin.ts takes).
253
+ const from = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM);
254
+ if (typeof from === "string" && from !== "") {
255
+ checkEntityFieldPath(root, origin, referrerPath, `${subtypeLabel} @from references`, from, referrerPkg, out);
256
+ }
257
+ const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF);
258
+ if (typeof of_ === "string" && of_ !== "") {
259
+ checkEntityFieldPath(root, origin, referrerPath, `${subtypeLabel} @of references`, of_, referrerPkg, out);
260
+ }
261
+ const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
262
+ if (typeof via === "string" && via !== "") {
263
+ checkViaPath(root, origin, referrerPath, `${subtypeLabel} @via references`, via, referrerPkg, out);
264
+ }
265
+ }
266
+
267
+ function checkNode(root: MetaData, node: MetaData, path: string, out: Diagnostic[]): void {
268
+ // extends — every node type may carry a resolved super (generic, type-agnostic).
269
+ if (node.superData !== undefined) {
270
+ considerTarget(node, path, "extends", node.superData, out);
271
+ }
272
+
273
+ // @objectRef — relationship.* (its target object) and field.object/value (a
274
+ // value-typed field's own objectRef). Same attr name on both (naming-refs.ts).
275
+ if (node.type === TYPE_RELATIONSHIP || node.type === TYPE_FIELD) {
276
+ const ref = node.attr(node.type === TYPE_RELATIONSHIP ? RELATIONSHIP_ATTR_OBJECT_REF : FIELD_ATTR_OBJECT_REF);
277
+ if (typeof ref === "string" && ref !== "") {
278
+ const owner = owningObject(node);
279
+ const target = resolveEntityRef(root, ref, owner !== undefined ? packageOf(owner) : "");
280
+ if (target !== undefined) considerTarget(node, path, "references (@objectRef)", target, out);
281
+ }
282
+ }
283
+
284
+ // @references — identity.reference's FK target. May be bare ("Entity",
285
+ // defaulting to the primary identity) or dotted ("Entity.field" /
286
+ // "Entity.fieldA,fieldB", an explicit field/compound target) — the dotted
287
+ // field half is a deprecation-relevant target in its own right (#305 review:
288
+ // `@references: "Team.code"` where only `code` is `@deprecated` was missed
289
+ // when this checked the entity head alone).
290
+ if (node.type === TYPE_IDENTITY && node.subType === IDENTITY_SUBTYPE_REFERENCE) {
291
+ const raw = node.attr(IDENTITY_REFERENCE_ATTR_REFERENCES);
292
+ if (typeof raw === "string" && raw !== "") {
293
+ const owner = owningObject(node);
294
+ checkEntityFieldPath(root, node, path, "references (@references)", raw, owner !== undefined ? packageOf(owner) : "", out);
295
+ }
296
+ }
297
+
298
+ // origin.* — @from / @of / @via.
299
+ if (node.type === TYPE_ORIGIN) {
300
+ checkOrigin(root, node, path, out);
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Lint the loaded model for references INTO a `deprecated` node, across every
306
+ * reference kind the metamodel resolves: `extends`, `@objectRef`, `@references`,
307
+ * and an origin's `@from` / `@of` / `@via`. Returns `[]` for a model with no
308
+ * `deprecated` attr anywhere reachable from a reference (additive, opt-in).
309
+ */
310
+ export function lintDeprecatedReferences(root: MetaData): Diagnostic[] {
311
+ const out: Diagnostic[] = [];
312
+ const walk = (n: MetaData, prefix: string): void => {
313
+ for (const c of n.ownChildren()) {
314
+ // origin carries no name of its own (always a singleton child of a field) —
315
+ // report against the FIELD it belongs to, not a path ending in a blank segment.
316
+ const path = c.type === TYPE_ORIGIN
317
+ ? prefix
318
+ : prefix === ""
319
+ ? c.resolutionKey()
320
+ : `${prefix}.${c.name}`;
321
+ checkNode(root, c, path, out);
322
+ walk(c, path);
323
+ }
324
+ };
325
+ walk(root, "");
326
+ return out;
327
+ }
@@ -96,7 +96,10 @@ export function scanForUnindexedForeignKeys(root: MetaData, opts: UnindexedFkOpt
96
96
  });
97
97
  const fields = ref !== undefined ? ref.fields : fk.columns;
98
98
  const label = ref !== undefined ? `${entity.name}.${ref.name}` : `${entity.name} (${fk.name})`;
99
- const indexName = `by${fields.map(pascal).join("")}`;
99
+ // Entity-prefixed: an index name is schema-wide in Postgres, so a bare `by<Field>`
100
+ // suggested the SAME name to every entity sharing the FK field, and applying the advice
101
+ // verbatim failed `meta migrate` with ERR_DUPLICATE_SQL_NAME.
102
+ const indexName = `${entity.name.charAt(0).toLowerCase()}${entity.name.slice(1)}By${fields.map(pascal).join("")}`;
100
103
  const fieldList = fields.length === 1 ? fields[0]! : `[${fields.join(", ")}]`;
101
104
  out.push({
102
105
  file: files[0] ?? "",