Skip to content

Commit 61194c1

Browse files
dmealingclaude
andcommitted
feat(cli): meta verify warns about whitespace in node names
The loader accepts any string as a node name in every port. An adopter's legacy-XML-derived metadata carried a field named "defaultCurrencyId " and an identity "account_defaultCurrencyId _fk"; both loaded silently. A new advisory section (`names`, text + json/toon) reports every named node except requirements whose name contains whitespace (ECMAScript \s): WARN_NAME_SURROUNDING_WHITESPACE for leading/trailing (almost certainly unintended; rename, pin @column on a field), WARN_NAME_INTERNAL_WHITESPACE for internal. Inherited names are reported once, at their declaration. Requirement names stay with the requirement lint. Never fails the build; muted by --no-name-lint / META_NO_NAME_LINT=1. A hard load error was weighed against the correction bar and rejected: these names work on the core path (migrate quotes the column, the ObjectManager round-trips the key) and multi-word requirement names are documented to load. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 834500a commit 61194c1

10 files changed

Lines changed: 479 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,30 @@ here.**
1919
The always-on agent context links it, and now says a project that deliberately owns its schema
2020
outside MetaObjects follows its own migration rule.
2121

22+
### Changed
23+
24+
- **`meta verify` warns about whitespace in a node's `name`.** The loader accepts any
25+
string as a name, in every port. An adopter's metadata, copied from legacy XML, had a field
26+
named `"defaultCurrencyId "` (trailing space) and an identity named
27+
`"account_defaultCurrencyId _fk"`, and both loaded with no error. A new advisory section,
28+
`names`, reports every object, field, identity, relationship, validator, view, template or
29+
other named node whose name contains whitespace (the ECMAScript `\s` class, so a pasted
30+
no-break space counts). Leading or trailing whitespace gets `WARN_NAME_SURROUNDING_WHITESPACE`,
31+
which says it is almost certainly unintended. Whitespace inside a name gets the plainer
32+
`WARN_NAME_INTERNAL_WHITESPACE`. A name inherited through `extends` is reported once, where
33+
it is declared. Requirement names are left to the requirement lint, which already reports
34+
leading or trailing whitespace and still does not report a short multi-word label such as
35+
`"Order Recording"`. Like the other authoring lints it **never fails the build**. It appears
36+
in text output and as `names` in `--format json|toon`, and can be muted with
37+
`--no-name-lint` or `META_NO_NAME_LINT=1`.
38+
**Why a warning and not a load error:** refusing these names does not pass the correction
39+
bar in `docs/compatibility-policy.md`. They work on the core path: `meta migrate` quotes the
40+
column (`"default_currency_id "`) and the ObjectManager reads and writes the key. Multi-word
41+
requirement names are documented to load. So rejecting them would break metadata that does
42+
what its author declared. **Upgrading:** nothing stops loading. To clear a finding, rename
43+
the node. On a field whose physical column must keep its current name, pin `@column` when
44+
you rename it.
45+
2246
### Fixed
2347

2448
- **The missing-FK-index advisory suggests a name unique to the table.** It suggested

‎docs/features/cli.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ Rules of the contract:
102102
lacks `overlay: true`. Advisory only, like the anti-pattern pass below — it never
103103
changes the exit code — and is muted the same way: `--no-overlay-lint` or
104104
`META_NO_OVERLAY_LINT=1`. See [`metadata-dependencies.md`](metadata-dependencies.md).
105+
- **The node-name authoring lint also runs on every `meta verify`.** It reports any
106+
named node except a requirement whose `name` contains whitespace (the ECMAScript `\s`
107+
class). The loader accepts these names on every port. `WARN_NAME_SURROUNDING_WHITESPACE`
108+
flags leading or trailing whitespace, which is almost certainly unintended: rename the
109+
node, and on a field pin `@column` if the physical column must keep its name.
110+
`WARN_NAME_INTERNAL_WHITESPACE` flags whitespace inside a name. Requirement names
111+
belong to the requirement lint. This lint is advisory only, appears as `names` in
112+
`--format json|toon`, and is muted with `--no-name-lint` or `META_NO_NAME_LINT=1`.
105113

106114
### The prompt directory: `--prompts` everywhere (F101)
107115

‎server/typescript/packages/cli/src/commands/verify.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from "../lib/requirement-check.js";
4040
import { lintRequirements } from "../lib/requirement-lint.js";
4141
import { lintOverlays } from "../lib/overlay-lint.js";
42+
import { lintNodeNames } from "../lib/name-lint.js";
4243
import { FileSource } from "@metaobjectsdev/metadata/core";
4344
import { resolveD1Config, resolveMigrateConfig } from "../lib/config.js";
4445
import {
@@ -350,6 +351,11 @@ export async function verifyCommand(
350351
// way every other advisory pass does.
351352
let overlaySection: AdvisorySection<AdvisoryDiagnosticRow> =
352353
skippedSection("the overlay lint did not run");
354+
// The node-name authoring lint — its own section for the same reason: a model
355+
// carrying a few hundred legacy names must not push another section's findings
356+
// off the end of a capped run.
357+
let nameSection: AdvisorySection<AdvisoryDiagnosticRow> =
358+
skippedSection("the name lint did not run");
353359
// The ledger counts `meta verify` prints on every run. Undefined for a project
354360
// declaring no requirement.* node at all (opt-in by declaration) — the payload
355361
// then omits the block rather than reporting zeroes that would read as an empty
@@ -386,6 +392,10 @@ export async function verifyCommand(
386392
// Warnings ONLY — never changes the exit code.
387393
await runOverlayLintAdvisory();
388394

395+
// Whitespace in a node's `name` — loads clean on every port, almost never meant.
396+
// Runs on every `meta verify`; warnings ONLY, never changes the exit code.
397+
runNameLintAdvisory();
398+
389399
// Advisory verify-as-teacher pass: surface hand-rolled work the metadata could
390400
// model. Warnings ONLY — never changes the exit code (bias to under-flagging).
391401
// Suppressed with --no-antipatterns or META_NO_ANTIPATTERNS=1 for the rare
@@ -434,6 +444,7 @@ export async function verifyCommand(
434444
requirementCounts,
435445
antiPatterns: antiPatternSection,
436446
overlays: overlaySection,
447+
names: nameSection,
437448
}),
438449
fmt,
439450
);
@@ -808,6 +819,29 @@ export async function verifyCommand(
808819
}
809820
}
810821

822+
// -- node-name authoring lint (advisory) -----------------------------------
823+
// Reads the LOADED model (see name-lint.ts). Its own section, its own cap, and
824+
// a skip that carries its reason rather than looking like a clean scan.
825+
function runNameLintAdvisory(): void {
826+
if (flags.noNameLint) {
827+
nameSection = skippedSection("suppressed by --no-name-lint");
828+
return;
829+
}
830+
if (process.env.META_NO_NAME_LINT === "1") {
831+
nameSection = skippedSection("suppressed by META_NO_NAME_LINT=1");
832+
return;
833+
}
834+
const findings = lintNodeNames(root);
835+
nameSection = ranSection(findings.map((d) => toDiagnosticRow(d, "lint")));
836+
if (findings.length > 0) {
837+
log.warn(
838+
`meta verify — names: ${findings.length} authoring warning(s) ` +
839+
`(advisory — does not fail the build):`,
840+
);
841+
warnCapped(findings.map(formatDiagnostic), flags.limit, { structured });
842+
}
843+
}
844+
811845
// -- verify-as-teacher (advisory) ------------------------------------------
812846
// Records its result EITHER WAY — a skip carries its reason rather than looking
813847
// like a clean scan. Warnings only; nothing here reaches the exit code (the
@@ -1821,6 +1855,7 @@ function buildVerifyPayload(input: {
18211855
requirementCounts: RequirementCounts | undefined;
18221856
antiPatterns: AdvisorySection<AdvisoryFindingRow>;
18231857
overlays: AdvisorySection<AdvisoryDiagnosticRow>;
1858+
names: AdvisorySection<AdvisoryDiagnosticRow>;
18241859
}): Record<string, unknown> {
18251860
const ran = input.gates.filter((g) => g.ran);
18261861
const failed = ran.filter((g) => !g.ok);
@@ -1840,6 +1875,9 @@ function buildVerifyPayload(input: {
18401875
if (input.overlays.status === "ran" && input.overlays.total > 0) {
18411876
parts.push(`${input.overlays.total} overlay authoring finding(s)`);
18421877
}
1878+
if (input.names.status === "ran" && input.names.total > 0) {
1879+
parts.push(`${input.names.total} name authoring finding(s)`);
1880+
}
18431881

18441882
const help: string[] = [];
18451883
if (failed.length > 0) {
@@ -1855,11 +1893,17 @@ function buildVerifyPayload(input: {
18551893
`${input.overlays.total} unflagged cross-file redeclaration(s) — see overlays.rows[]; add overlay: true so a renamed or removed target fails loudly instead of silently becoming a new object`,
18561894
);
18571895
}
1896+
if (input.names.total > 0) {
1897+
help.push(
1898+
`${input.names.total} node name(s) containing whitespace — see names.rows[]; rename each node, and pin @column on a field whose physical column must keep its name`,
1899+
);
1900+
}
18581901
if (
18591902
failed.length === 0 &&
18601903
input.antiPatterns.total === 0 &&
18611904
input.requirements.total === 0 &&
1862-
input.overlays.total === 0
1905+
input.overlays.total === 0 &&
1906+
input.names.total === 0
18631907
) {
18641908
help.push("no drift and nothing advisory to answer — nothing to do");
18651909
}
@@ -1872,6 +1916,7 @@ function buildVerifyPayload(input: {
18721916
antiPatterns: input.antiPatterns,
18731917
requirements: input.requirements,
18741918
overlays: input.overlays,
1919+
names: input.names,
18751920
...(input.requirementCounts !== undefined ? { requirementCounts: input.requirementCounts } : {}),
18761921
// The honest boundary. Everything named here is REACHABLE — it is printed as
18771922
// text on stderr — but it is not in this document, and a reader must not have

‎server/typescript/packages/cli/src/index.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif
127127
--no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate)
128128
--no-overlay-lint Suppress the advisory overlay-redeclaration AUTHORING lint
129129
(never a gate — this lint can't fail the build)
130+
--no-name-lint Suppress the advisory node-name AUTHORING lint (whitespace
131+
in a name; never a gate — this lint can't fail the build)
130132
--limit <n|all> How many advisory lines TEXT output prints per section before
131133
it truncates (default 20). Never applies to --format
132134
toon/json, which carry every finding and every diagnostic.
@@ -320,6 +322,8 @@ FLAGS:
320322
--no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate)
321323
--no-overlay-lint Suppress the advisory overlay-redeclaration AUTHORING lint —
322324
never a gate; this lint can't fail the build
325+
--no-name-lint Suppress the advisory node-name AUTHORING lint (whitespace in
326+
a name) — never a gate; this lint can't fail the build
323327
--limit <n|all> Advisory lines TEXT output prints PER SECTION before truncating
324328
(default 20; per-section so the authoring lint can never push
325329
the gate's own warnings off the end)
@@ -350,6 +354,13 @@ redeclaration lacks 'overlay: true' — today's default merge rule reuses it
350354
silently, but the same unflagged redeclaration silently becomes a NEW object
351355
the day the target is renamed or removed upstream. Warnings only — it can
352356
never fail the build. Opt out with --no-overlay-lint or META_NO_OVERLAY_LINT=1.
357+
358+
verify also prints an ADVISORY node-name authoring lint, in its own section: any
359+
node (object, field, identity, relationship, ...) whose name contains whitespace.
360+
Leading or trailing whitespace is almost certainly a copy-paste accident — rename
361+
the node, and pin @column on a field whose physical column must keep its name.
362+
Requirement names are left to the requirement lint above. Warnings only — it can
363+
never fail the build. Opt out with --no-name-lint or META_NO_NAME_LINT=1.
353364
`,
354365
export: `meta export — flatten loaded metadata to one canonical JSON artifact
355366

‎server/typescript/packages/cli/src/lib/args.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,13 @@ export interface VerifyFlags {
362362
* lint has no gate half at all — it can never fail the build).
363363
*/
364364
noOverlayLint: boolean;
365+
/**
366+
* Suppress the advisory node-NAME authoring lint — whitespace in a node's
367+
* `name` (a trailing space copied from legacy XML loads clean on every port).
368+
* Advisory only, like its siblings: it has no gate half and can never fail
369+
* the build.
370+
*/
371+
noNameLint: boolean;
365372
/**
366373
* ADR-0023 strict-attr load opt-OUT (#96). `verify` is strict-by-default — an
367374
* undeclared/typo'd own `@attr` fails verify (ERR_UNKNOWN_ATTR). `--lax`
@@ -403,6 +410,7 @@ export const VERIFY_OPTIONS = {
403410
"no-antipatterns": { type: "boolean", default: false },
404411
"no-requirement-lint": { type: "boolean", default: false },
405412
"no-overlay-lint": { type: "boolean", default: false },
413+
"no-name-lint": { type: "boolean", default: false },
406414
lax: { type: "boolean", default: false },
407415
"d1": { type: "string" },
408416
"remote": { type: "boolean", default: false },
@@ -487,6 +495,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
487495
noAntipatterns: !!values["no-antipatterns"],
488496
noRequirementLint: !!values["no-requirement-lint"],
489497
noOverlayLint: !!values["no-overlay-lint"],
498+
noNameLint: !!values["no-name-lint"],
490499
lax: !!values.lax,
491500
d1: values.d1 as string | undefined,
492501
remote: !!values.remote,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// `meta verify` — the node-name AUTHORING lint.
2+
//
3+
// The loader accepts any string as a node `name`, on every port. An adopter's metadata
4+
// carried a field named `"defaultCurrencyId "` (a trailing space, copied from legacy XML)
5+
// and an identity named `"account_defaultCurrencyId _fk"`; both loaded with no error.
6+
//
7+
// WHY THIS IS A WARNING AND NOT A LOAD ERROR. It was weighed against the correction bar
8+
// in docs/compatibility-policy.md and does not pass it: whitespace names WORK today on
9+
// the core path — `meta migrate` emits a quoted column (`"default_currency_id "`) and the
10+
// ObjectManager round-trips the key — and multi-word requirement names are documented to
11+
// load. Refusing them at load would break metadata that does what its author asked, so
12+
// the honest instrument is an advisory that names the edit.
13+
//
14+
// EVERY FINDING IS A WARNING, BY CONSTRUCTION — the same rule, and the same reasoning,
15+
// as requirement-lint.ts's header. Nothing here reaches the exit code.
16+
//
17+
// REQUIREMENTS ARE NOT LINTED HERE. A requirement's name is its dotted ADDRESS and the
18+
// requirement lint already owns it (WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE reports
19+
// leading/trailing whitespace; a short multi-word label such as "Order Recording" is
20+
// deliberately NOT reported, because renaming one moves its address and its emitted
21+
// stub file for no gain). Reporting the same name twice under two codes would make
22+
// one edit look like two problems.
23+
//
24+
// IT READS THE LOADED MODEL, NEVER THE FILES — and walks DECLARATIONS (see `walk`).
25+
26+
import { PACKAGE_SEPARATOR, TYPE_FIELD, TYPE_REQUIREMENT, type MetaData } from "@metaobjectsdev/metadata";
27+
import type { Diagnostic } from "./requirement-check.js";
28+
29+
/** Leading or trailing whitespace — the strong finding. */
30+
export const WARN_NAME_SURROUNDING_WHITESPACE = "WARN_NAME_SURROUNDING_WHITESPACE";
31+
/** Whitespace inside an otherwise trimmed name — the plain advisory. */
32+
export const WARN_NAME_INTERNAL_WHITESPACE = "WARN_NAME_INTERNAL_WHITESPACE";
33+
34+
/**
35+
* What counts as whitespace: the ECMAScript `\s` class. That is ASCII space, tab, LF,
36+
* CR, VT and FF, plus U+00A0 (no-break space), U+FEFF, U+1680, U+2000–U+200A, U+2028,
37+
* U+2029, U+202F, U+205F and U+3000. The non-ASCII members matter for this lint's
38+
* actual source of input: a no-break space survives a copy out of a document or a
39+
* legacy export and is invisible in every editor.
40+
*/
41+
const WHITESPACE = /\s/u;
42+
43+
function warn(path: string, code: string, message: string): Diagnostic {
44+
return { severity: "warn", code, path, message };
45+
}
46+
47+
/** One segment of a node's address. A name with whitespace is quoted so the
48+
* whitespace is visible in the printed path rather than vanishing into it. */
49+
function segment(name: string): string {
50+
return WHITESPACE.test(name) ? JSON.stringify(name) : name;
51+
}
52+
53+
function check(node: MetaData, path: string, out: Diagnostic[]): void {
54+
const name = node.name;
55+
if (name === "" || !WHITESPACE.test(name)) return;
56+
57+
const kind = `${node.type}.${node.subType}`;
58+
const quoted = JSON.stringify(name);
59+
// @column is a FIELD attribute. Advising an object or identity author to pin it
60+
// would send them looking for a slot that does not exist on the node.
61+
const pin = node.type === TYPE_FIELD ? ", and pin @column if the physical column must keep its name" : "";
62+
const trimmed = name.trim();
63+
64+
if (trimmed !== name) {
65+
const inner = WHITESPACE.test(trimmed) ? " It also contains whitespace inside it." : "";
66+
const target = trimmed === "" ? "" : ` (to ${JSON.stringify(trimmed)}, presumably)`;
67+
out.push(warn(path, WARN_NAME_SURROUNDING_WHITESPACE,
68+
`${kind} name ${quoted} has leading or trailing whitespace, which is almost certainly ` +
69+
`unintended — rename it${target}${pin}. The whitespace is carried into every identifier, ` +
70+
`column and wire key derived from the name.${inner}`));
71+
return;
72+
}
73+
74+
out.push(warn(path, WARN_NAME_INTERNAL_WHITESPACE,
75+
`${kind} name ${quoted} contains whitespace. A name is an identifier: derived column names ` +
76+
`and wire keys carry the space, and generated code cannot use it as one. Consider ` +
77+
`renaming it${pin}.`));
78+
}
79+
80+
/** A root-level node's address: package-qualified the way `resolutionKey()` is, so the
81+
* path reads like every other reference to the node. */
82+
function topLevelAddress(node: MetaData): string {
83+
const pkg = node.package ?? node.fileDefaultPackage;
84+
return pkg !== undefined && pkg !== "" ? `${pkg}${PACKAGE_SEPARATOR}${segment(node.name)}` : segment(node.name);
85+
}
86+
87+
/**
88+
* Lint the name of every named node in the loaded model, requirements excepted.
89+
* Returns `[]` for a model with no whitespace in any name.
90+
*/
91+
export function lintNodeNames(root: MetaData): Diagnostic[] {
92+
const out: Diagnostic[] = [];
93+
const walk = (n: MetaData, prefix: string): void => {
94+
// OWN-ONLY (ADR-0039 sanctioned case): this reports each DECLARATION once, at the
95+
// node that declares it. A field inherited through `extends` is one declaration and
96+
// one rename; the resolving `children()` would repeat it on every subtype.
97+
for (const c of n.ownChildren()) {
98+
if (c.type === TYPE_REQUIREMENT) continue; // owned by requirement-lint.ts — see header
99+
const path = prefix === "" ? topLevelAddress(c) : `${prefix}.${segment(c.name)}`;
100+
check(c, path, out);
101+
walk(c, path);
102+
}
103+
};
104+
walk(root, "");
105+
return out;
106+
}

‎server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif
107107
--no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate)
108108
--no-overlay-lint Suppress the advisory overlay-redeclaration AUTHORING lint
109109
(never a gate — this lint can't fail the build)
110+
--no-name-lint Suppress the advisory node-name AUTHORING lint (whitespace
111+
in a name; never a gate — this lint can't fail the build)
110112
--limit <n|all> How many advisory lines TEXT output prints per section before
111113
it truncates (default 20). Never applies to --format
112114
toon/json, which carry every finding and every diagnostic.

0 commit comments

Comments
 (0)