Skip to content

Commit 8125ac5

Browse files
dmealingclaude
andcommitted
test(conformance): gate the extract engine's FieldKind vocabulary across ports
Every port's extract engine runs fixtures/extract-conformance/, and that corpus's schema.json `kind` values ARE this vocabulary. So a port that adds or drops a kind changes what the shared corpus can express while every existing fixture keeps passing — the drift is invisible by construction, and nothing looked. It had already drifted. C# carries a Decimal kind that TS, Python and Java do not have and no fixture exercises; the README had been documenting the seven-member set as the contract the whole time. The gate reads each port's REAL definition rather than a copied list, and treats an unrecognised declaration shape as a failure — a matcher that quietly returned an empty member set would report "no drift" and defeat the entire check. It also fails if Kotlin ever grows its own FieldKind: Kotlin ships no extract engine, driving the shared Java one through metadata-ktx, so a second definition would itself be the divergence. C#'s extra kind is recorded in expected-field-kinds.json rather than deleted, with the reason stated: it is live vocabulary (the engine coerces through it and ExtractMap.AsDecimal reads it), and removing it is not the one-liner it looks like because AsDecimal has no string arm — an engine that stopped producing `decimal` would silently return null for every decimal. Reconciling for real means either propagating DECIMAL to the other four engines or moving C# onto the generated-coercion pattern Java now uses, and either is a cross-port behaviour change that wants its own spec. Recording it makes the divergence a reviewed decision with a stated cost instead of an accident nobody can see; anything not recorded fails, and a record that is no longer true fails too, so it cannot go stale. Verified in all four directions: a new undeclared member, a dropped member, a stale sanctioned deviation, and the clean tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9c7100d commit 8125ac5

5 files changed

Lines changed: 222 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
5555
only the member was gone. The error now ends *"'app::Task' has no member 'dueDate'."* The
5656
did-you-mean hint still appears when the object itself does not resolve.
5757

58-
59-
### Fixed
60-
6158
- **C#: a `field.decimal` reached through a nested object generated code that did not
6259
compile.** `ExtractDelegateEmitter.ScalarReader` — the reader map for the runtime-DELEGATING
6360
extract mirror — had no `Decimal` branch, so a decimal fell through to the `DlgString`
@@ -130,6 +127,7 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
130127
parsed before still parses identically) plus ISO instant, zone-less date-time, and date-only
131128
forms. Unparseable input still throws, preserving the failure mode for callers outside the
132129
lenient tier.
130+
133131
### Added
134132

135133
- **C#: `DbContextGenerator.EmitsReferenceForeignKeys`, an opt-out for the reference-FK
@@ -146,6 +144,22 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
146144
nothing else the generator emits changes. Documented in
147145
[`relationships.md`](docs/features/relationships.md).
148146

147+
- **The extract engine's `FieldKind` vocabulary is now gated across ports**
148+
(`scripts/check-extract-field-kinds.mjs`, `gates` lane). Every port's extract engine runs
149+
`fixtures/extract-conformance/`, and that corpus's `schema.json` `kind` values ARE this
150+
vocabulary — so a port that adds or drops a kind changes what the shared corpus can express
151+
while every existing fixture keeps passing. The drift is invisible by construction, and it had
152+
already happened: C# carries a `Decimal` kind no other port has and no fixture exercises. The
153+
gate reads each port's real definition rather than a copied list (an unrecognised declaration
154+
shape fails loudly instead of reporting an empty set, which would read as "no drift"), and it
155+
fails if Kotlin ever grows its own `FieldKind` — it ships no extract engine, driving the shared
156+
Java one. Deviations pass only when
157+
[`expected-field-kinds.json`](fixtures/extract-conformance/expected-field-kinds.json) records
158+
them with a reason; the C# one is recorded there, including why deleting it is not the
159+
one-liner it looks like (`ExtractMap.AsDecimal` has no string arm, so an engine that stopped
160+
producing `decimal` would silently return null for every decimal). A recorded deviation that
161+
is no longer true also fails, so the record cannot go stale.
162+
149163
## [1.0.4] — 2026-09-14
150164

151165
_All four registries publish: npm `1.0.4` (full lockstep across all 14 `@metaobjectsdev/*`

‎fixtures/extract-conformance/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ is byte-identical across ports; raw coercion is not required to be.
1818
Each `fields[]` entry: `name`, `kind` (`STRING|INT|LONG|DOUBLE|BOOLEAN|ENUM|OBJECT`),
1919
`required` (bool). Kind-specific keys:
2020

21+
> **The `kind` vocabulary is a cross-port contract, and it is now gated.** Those seven values
22+
> are the coercion targets every port's extract engine understands, so a port that adds or
23+
> drops one changes what this corpus can express while every existing case keeps passing —
24+
> invisible drift by construction. It had already happened: C# carries a `Decimal` kind no
25+
> other port has and no fixture exercises. The machine-readable set lives in
26+
> [`expected-field-kinds.json`](expected-field-kinds.json), checked by
27+
> `scripts/check-extract-field-kinds.mjs` in `ci-local.sh`'s `gates` lane, which reads each
28+
> port's real definition rather than a copied list. Deviations pass only when that file
29+
> records them with a stated reason; the C# one is recorded there along with why it was not
30+
> simply deleted. Kotlin has no entry on purpose — it ships no extract engine, driving the
31+
> shared Java one through `metadata-ktx`, so a Kotlin `FieldKind` would itself be the drift,
32+
> and the gate fails if one appears.
33+
2134
- `INT|LONG|DOUBLE` — optional `min` / `max` (clamp range).
2235
- `ENUM` — `enumValues` (member symbols) plus the FR-011 coercion-pipeline keys:
2336
- `enumAlias` — `{ synonym: MEMBER }` (FR-010); keys matched under the field's mode.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"$comment": [
3+
"The extract engine's coercion-target vocabulary (FieldKind), pinned across ports.",
4+
"This is the machine-readable form of the `kind` values documented in README.md ->",
5+
"'schema.json field keys'. Every port's extract engine runs the same corpus, so a port",
6+
"that quietly adds or drops a kind changes what that corpus can even express.",
7+
"",
8+
"It was unpinned until now, and it had already drifted: C# carries a Decimal kind that",
9+
"no other port has and no fixture exercises. Nothing failed, because nothing looked.",
10+
"Gated by scripts/check-extract-field-kinds.mjs in ci-local.sh's `gates` lane."
11+
],
12+
"canonical": ["STRING", "INT", "LONG", "DOUBLE", "BOOLEAN", "ENUM", "OBJECT"],
13+
"ports": {
14+
"java": "server/java/render/src/main/java/com/metaobjects/render/extract/FieldKind.java",
15+
"csharp": "server/csharp/MetaObjects.Render/Extract/FieldKind.cs",
16+
"typescript": "server/typescript/packages/render/src/extract/types.ts",
17+
"python": "server/python/src/metaobjects/render/extract/types.py"
18+
},
19+
"kotlinReusesJava": true,
20+
"$kotlinNote": "Kotlin ships no extract engine of its own — metadata-ktx drives the shared Java one, so there is no Kotlin FieldKind to check and adding one would itself be the drift.",
21+
"sanctionedDeviations": {
22+
"csharp": {
23+
"extra": ["DECIMAL"],
24+
"missing": [],
25+
"recordedOn": "2026-09-13",
26+
"reason": [
27+
"C#'s extract engine coerces a field.decimal to a native `decimal` through this kind,",
28+
"and MetaObjects.Render's ExtractMap.AsDecimal reads it. It is NOT dead vocabulary.",
29+
"",
30+
"It is a deviation rather than the canonical shape because the outcome it buys does not",
31+
"require an engine kind: Java reaches the same place (a typed BigDecimal on the response",
32+
"record) by leaving the engine's STRING coercion alone and converting in GENERATED code.",
33+
"That keeps the engine's vocabulary small and puts the port's native-type choice at the",
34+
"port boundary, which is where ADR-0019 says it belongs.",
35+
"",
36+
"Not removed here because removal is not the one-line change it looks like: AsDecimal has",
37+
"no string arm, so an engine that stopped producing `decimal` would make it return null",
38+
"for EVERY decimal — silently. Reconciling properly means either propagating DECIMAL to",
39+
"the other four engines or moving C# onto the generated-coercion pattern, and either is a",
40+
"cross-port behaviour change that wants its own spec.",
41+
"",
42+
"This entry exists so the divergence is a reviewed decision with a stated cost, not an",
43+
"accident nobody can see. Anything NOT listed here fails the gate."
44+
]
45+
}
46+
}
47+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env node
2+
// Gate: the extract engine's FieldKind vocabulary is IDENTICAL across ports.
3+
//
4+
// Every port's extract engine runs fixtures/extract-conformance/, whose schema.json `kind`
5+
// values ARE this vocabulary. A port that adds or drops a kind changes what the shared corpus
6+
// can express while every existing fixture keeps passing — so the drift is invisible by
7+
// construction. It had already happened: C# carries a Decimal kind no other port has and no
8+
// fixture exercises.
9+
//
10+
// Reads each port's real definition (not a duplicated list) and compares against
11+
// fixtures/extract-conformance/expected-field-kinds.json. Deviations are allowed only when
12+
// that file records them, with a reason, under `sanctionedDeviations`.
13+
//
14+
// Offline, no build, no network.
15+
16+
import { readFileSync } from "node:fs";
17+
import { join, dirname } from "node:path";
18+
import { fileURLToPath } from "node:url";
19+
20+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
21+
const MANIFEST = "fixtures/extract-conformance/expected-field-kinds.json";
22+
23+
const expected = JSON.parse(readFileSync(join(ROOT, MANIFEST), "utf8"));
24+
const canonical = expected.canonical;
25+
26+
/**
27+
* Pull the member names out of one port's FieldKind definition.
28+
*
29+
* Deliberately per-language and deliberately narrow: each pattern matches that port's actual
30+
* declaration syntax and nothing else, so a definition that MOVED or was rewritten in a shape
31+
* this does not recognise fails loudly (unknown-shape) instead of silently reporting an empty
32+
* member set — which would read as "no drift" and is the one failure mode that would defeat
33+
* the whole gate.
34+
*/
35+
function membersOf(port, src) {
36+
switch (port) {
37+
case "java": {
38+
// public enum FieldKind { STRING, INT, ... }
39+
const m = src.match(/enum\s+FieldKind\s*\{([^}]*)\}/);
40+
if (!m) return null;
41+
return m[1].split(",").map((s) => s.trim()).filter(Boolean);
42+
}
43+
case "csharp": {
44+
// public enum FieldKind { String, Int, ... } (PascalCase members)
45+
const m = src.match(/enum\s+FieldKind\s*\{([^}]*)\}/);
46+
if (!m) return null;
47+
return m[1].split(",").map((s) => s.trim()).filter(Boolean)
48+
.map((s) => s.toUpperCase());
49+
}
50+
case "typescript": {
51+
// export const FieldKind = { STRING: "STRING", ... } as const;
52+
const m = src.match(/const\s+FieldKind\s*=\s*\{([\s\S]*?)\}\s*as\s+const/);
53+
if (!m) return null;
54+
return [...m[1].matchAll(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/gm)].map((x) => x[1]);
55+
}
56+
case "python": {
57+
// class FieldKind(Enum):\n STRING = "STRING"\n ...
58+
const m = src.match(/class\s+FieldKind\s*\(\s*Enum\s*\)\s*:([\s\S]*?)(?=\n\S|\nclass\s)/);
59+
if (!m) return null;
60+
return [...m[1].matchAll(/^\s+([A-Z][A-Z0-9_]*)\s*=/gm)].map((x) => x[1]);
61+
}
62+
default:
63+
return null;
64+
}
65+
}
66+
67+
const problems = [];
68+
69+
for (const [port, relPath] of Object.entries(expected.ports)) {
70+
let src;
71+
try {
72+
src = readFileSync(join(ROOT, relPath), "utf8");
73+
} catch {
74+
problems.push(`${port}: cannot read ${relPath} — did the definition move? Update ${MANIFEST}.`);
75+
continue;
76+
}
77+
78+
const found = membersOf(port, src);
79+
if (found === null || found.length === 0) {
80+
problems.push(
81+
`${port}: could not parse a FieldKind definition out of ${relPath}. ` +
82+
`The declaration shape changed; fix the matcher in this script rather than ` +
83+
`letting it report an empty set.`);
84+
continue;
85+
}
86+
87+
const allow = expected.sanctionedDeviations?.[port] ?? { extra: [], missing: [] };
88+
const allowedExtra = new Set(allow.extra ?? []);
89+
const allowedMissing = new Set(allow.missing ?? []);
90+
91+
const extra = found.filter((k) => !canonical.includes(k) && !allowedExtra.has(k));
92+
const missing = canonical.filter((k) => !found.includes(k) && !allowedMissing.has(k));
93+
// A recorded deviation that is no longer TRUE is also drift: it means someone fixed (or
94+
// re-broke) the port and left the record claiming otherwise, and the next reader trusts it.
95+
const staleExtra = [...allowedExtra].filter((k) => !found.includes(k));
96+
const staleMissing = [...allowedMissing].filter((k) => found.includes(k));
97+
98+
if (extra.length) problems.push(`${port}: unexpected FieldKind member(s): ${extra.join(", ")}`);
99+
if (missing.length) problems.push(`${port}: missing FieldKind member(s): ${missing.join(", ")}`);
100+
if (staleExtra.length)
101+
problems.push(`${port}: ${MANIFEST} records extra member(s) ${staleExtra.join(", ")} that are GONE — delete the stale sanctionedDeviations entry.`);
102+
if (staleMissing.length)
103+
problems.push(`${port}: ${MANIFEST} records missing member(s) ${staleMissing.join(", ")} that are now PRESENT — delete the stale sanctionedDeviations entry.`);
104+
}
105+
106+
// Kotlin must NOT grow its own engine vocabulary: it drives the shared Java one.
107+
if (expected.kotlinReusesJava) {
108+
let kotlinOwn = [];
109+
try {
110+
const out = (await import("node:child_process")).execFileSync(
111+
"git", ["grep", "-l", "-E", "(enum class|val)\\s+FieldKind", "--", "server/java/codegen-kotlin", "server/java/metadata-ktx"],
112+
{ cwd: ROOT, encoding: "utf8" });
113+
kotlinOwn = out.split("\n").filter(Boolean);
114+
} catch { /* git grep exits 1 with no matches — the good case */ }
115+
if (kotlinOwn.length)
116+
problems.push(
117+
`kotlin: declares its own FieldKind in ${kotlinOwn.join(", ")}. Kotlin drives the shared ` +
118+
`Java extract engine; a second definition IS the drift this gate exists to catch.`);
119+
}
120+
121+
if (problems.length) {
122+
console.error("extract FieldKind vocabulary drift:\n");
123+
for (const p of problems) console.error(` - ${p}`);
124+
console.error(`\n Canonical set (${MANIFEST}): ${canonical.join(", ")}`);
125+
console.error(" Every port's extract engine runs fixtures/extract-conformance/, so this");
126+
console.error(" vocabulary is a shared contract, not a per-port implementation detail.");
127+
process.exit(1);
128+
}
129+
130+
const dev = Object.keys(expected.sanctionedDeviations ?? {});
131+
console.log(
132+
`extract FieldKind: ${Object.keys(expected.ports).length} ports agree on ` +
133+
`[${canonical.join(", ")}]` +
134+
(dev.length ? ` (${dev.length} sanctioned deviation(s): ${dev.join(", ")})` : ""));

‎scripts/ci-local.sh‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ gate_publish_set() { node scripts/publish-set.mjs --check && node scripts/test-p
184184
# checks names. Offline; one manifest.
185185
gate_script_name_hooks() { node scripts/check-script-name-hooks.mjs; }
186186

187+
# ── the extract engine's coercion vocabulary is a CROSS-PORT contract ────────
188+
# Every port's extract engine runs fixtures/extract-conformance/, and that corpus's
189+
# schema.json `kind` values ARE this vocabulary. So a port that adds or drops a kind
190+
# changes what the shared corpus can express while every existing fixture still passes:
191+
# the drift is invisible by construction. It had already happened — C# carries a Decimal
192+
# kind no other port has and no fixture exercises. Reads each port's real definition;
193+
# deviations pass only when expected-field-kinds.json records them with a reason.
194+
# Offline; four files.
195+
gate_extract_field_kinds() { node scripts/check-extract-field-kinds.mjs; }
196+
187197
# ── release hygiene: uv.lock must agree with pyproject.toml ──────────────────
188198
# Every lane runs plain `uv run`, which REWRITES a disagreeing lockfile in place, so a
189199
# release that bumps pyproject and forgets the lock stays green forever. Twice now: the
@@ -743,6 +753,7 @@ if want gates; then step "publish-set parity" gate_publish_set;
743753
if want gates; then step "no committed pre-release version" gate_no_prerelease_versions; fi
744754
if want gates; then step "script-name hook collisions" gate_script_name_hooks; fi
745755
if want gates; then step "ci lane selection" gate_ci_port_selection; fi
756+
if want gates; then step "extract field kinds" gate_extract_field_kinds; fi
746757
if want gates; then step "no-magic gate wired (5 ports)" gate_no_magic_coverage; fi
747758
if want gates; then step "test-file references resolve" gate_test_references; fi
748759
if want gates; then step "metamodel-version bump" gate_metamodel_version; fi

0 commit comments

Comments
 (0)