Skip to content

Commit 2743207

Browse files
committed
Merge branch 'worktree-fr5a-ts-error-envelope'
2 parents d29c44c + 69f3a8d commit 2743207

34 files changed

Lines changed: 1000 additions & 146 deletions

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
88
## [Unreleased]
99

1010
### Added
11+
- **Loader error envelope + source-on-node** (`@metaobjectsdev/metadata`) —
12+
per [ADR-0009](spec/decisions/ADR-0009-loader-error-envelope-and-source-on-node.md),
13+
every `MetaData` node now carries a `source: ErrorSource` provenance field
14+
(`{ format: "json", files: [...], jsonPath: "..." }` for loaded nodes;
15+
`{ format: "code" }` for programmatically constructed). `ParseError` now
16+
conforms to the cross-port `LoaderError` schema: required `code`, required
17+
`message`, required `source` envelope. New `LoadResult.warnings:
18+
LoaderWarning[]` channel (legacy parser/validator strings are wrapped at
19+
the loader boundary as `WARN_LEGACY` envelopes; future overlay-merge
20+
detection in FR5c will be the first feature to emit native envelope-shaped
21+
warnings). New public exports from `@metaobjectsdev/metadata`:
22+
`ErrorSource`, `LoaderError`, `LoaderWarning`, `NodeContext`, `Contributor`
23+
types, plus the `codeSource()` helper. Foundation for FR5b (YAML
24+
positions), FR5c (multi-file merge attribution), FR5d (reference-resolution
25+
errors), FR5e (database-source errors).
1126
- **`outputParser()` stock generator** in `@metaobjectsdev/codegen-ts/generators`
1227
for every declared `template.output`, emits a typed Zod parser file with a
1328
dual-API surface (`parseXxx(text)` throws, `safeParseXxx(text)` returns
@@ -42,6 +57,19 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
4257
Enables Cloudflare Workers / edge consumers to drop their typecheck stubs;
4358
enables multi-tenant servers + test-isolated `db` setups. `routesFile()` is
4459
unchanged.
60+
- **BREAKING (metadata):** `ParseError` constructor signature changed. Was
61+
`new ParseError(msg, { code?, source?: string, path? })`; now
62+
`new ParseError(msg, { code, source: ErrorSource })`. Direct construction
63+
outside the metadata package is rare (loader-internal API), but anyone
64+
catching + repackaging a `ParseError` reads `.source` as the new envelope
65+
type, not a string. Legacy `error.path` is gone — read
66+
`error.source.jsonPath` instead.
67+
- **BREAKING (metadata):** `LoadResult.warnings` retyped from `string[]` to
68+
`LoaderWarning[]` per ADR-0009. Consumers that inspected warning content
69+
via `result.warnings[i].includes(...)` should now read
70+
`result.warnings[i].message.includes(...)`. The public
71+
`ExportResult.warnings` (returned by `loadAndExportJson()`) keeps its
72+
`string[]` shape — extracted via `.map((w) => w.message)`.
4573

4674
See [ADR-0010](spec/decisions/ADR-0010-template-output-parser-codegen.md)
4775
for the cross-port design.

server/typescript/packages/metadata/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,57 @@ const result = await new MetaDataLoader().load([new InMemoryStringSource(json)])
1919

2020
The public loader API is `MetaDataLoader` + `InMemoryStringSource`. A `MetaDataLoader` instance is single-use; construct a new one per load.
2121

22+
## Loader errors (FR5a)
23+
24+
Every loader error conforms to the cross-port `LoaderError` envelope
25+
(ADR-0009):
26+
27+
```ts
28+
interface LoaderError {
29+
code: string; // ERR_UNKNOWN_TYPE, ERR_BAD_ATTR_VALUE, ...
30+
message: string;
31+
source: ErrorSource; // always populated
32+
}
33+
34+
type ErrorSource =
35+
| { format: "json"; files: [string]; jsonPath: string }
36+
| { format: "yaml"; files: [string]; jsonPath: string; yamlPosition?: { line: number; col: number } }
37+
| { format: "merged"; files: string[]; jsonPath: string; contributors: Contributor[] }
38+
| { format: "resolved"; files: string[]; jsonPath?: string; referrer?: string; target?: string }
39+
| { format: "database"; dbLocation: { table: string; id: string }; jsonPath?: string }
40+
| { format: "code"; caller?: string };
41+
```
42+
43+
Every `MetaData` node also carries a populated `source` field, so
44+
post-load consumers (drift detection, MCP, debug tools) can answer
45+
"where did this node come from?" without an extra lookup table.
46+
47+
`LoadResult.warnings` is a parallel `LoaderWarning[]` channel — same
48+
envelope shape, `WARN_*` prefixed codes. Pre-FR5a string warnings are
49+
wrapped at the loader boundary as `WARN_LEGACY` envelopes; FR5c will
50+
retire the legacy code by routing each emit site through a proper
51+
envelope-shaped helper.
52+
53+
The package re-exports the envelope types and the `codeSource()`
54+
helper for consumers that catch + repackage `ParseError`s or build
55+
synthetic envelopes for programmatic-construction tests:
56+
57+
```ts
58+
import type {
59+
ErrorSource,
60+
LoaderError,
61+
LoaderWarning,
62+
NodeContext,
63+
Contributor,
64+
} from "@metaobjectsdev/metadata";
65+
import { codeSource } from "@metaobjectsdev/metadata";
66+
```
67+
68+
See [ADR-0009](../../../../spec/decisions/ADR-0009-loader-error-envelope-and-source-on-node.md)
69+
for the full schema and the FR5 family for the per-error-class
70+
rollout plan (5a JSON shape, 5b YAML positions, 5c multi-file merge
71+
attribution, 5d reference resolution, 5e database sources).
72+
2273
## Links
2374

2475
- [Spec](https://github.com/metaobjectsdev/metaobjects/tree/main/spec)

server/typescript/packages/metadata/src/attr-schema-validate.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ function validateNode(
100100
errors.push(
101101
new ParseError(
102102
`Common attr '${ca.name}' conflicts with per-type attr on ${typeKey}`,
103-
{ code: "ERR_PROVIDER_ATTR_CONFLICT" },
103+
{ code: "ERR_PROVIDER_ATTR_CONFLICT", source: node.source },
104104
),
105105
);
106106
reportedConflicts.add(typeKey);
@@ -126,7 +126,7 @@ function validateNode(
126126
errors.push(
127127
new ParseError(
128128
`${nodeLabel(node)} is missing required attribute '@${spec.name}'`,
129-
{ code: "ERR_MISSING_REQUIRED_ATTR" },
129+
{ code: "ERR_MISSING_REQUIRED_ATTR", source: node.source },
130130
),
131131
);
132132
}
@@ -145,7 +145,7 @@ function validateNode(
145145
const valueErrors = inst.validateValue(value);
146146
if (valueErrors.length > 0) {
147147
for (const ve of valueErrors) {
148-
errors.push(new ParseError(`${nodeLabel(node)} ${ve.message}`, { code: "ERR_BAD_ATTR_VALUE" }));
148+
errors.push(new ParseError(`${nodeLabel(node)} ${ve.message}`, { code: "ERR_BAD_ATTR_VALUE", source: node.source }));
149149
}
150150
continue; // type wrong → skip allowedValues
151151
}
@@ -159,7 +159,7 @@ function validateNode(
159159
`${nodeLabel(node)} attribute '@${inst.name}' has value ` +
160160
`'${String(value)}' which is not one of the allowed values: ` +
161161
`${spec.allowedValues.map((v) => String(v)).join(", ")}`,
162-
{ code: "ERR_BAD_ATTR_VALUE" },
162+
{ code: "ERR_BAD_ATTR_VALUE", source: node.source },
163163
),
164164
);
165165
}
@@ -180,7 +180,7 @@ function validateNode(
180180
errors.push(
181181
new ParseError(
182182
`${nodeLabel(node)} must declare at least one value in '@${FIELD_ATTR_VALUES}'.`,
183-
{ code: "ERR_BAD_ATTR_VALUE" },
183+
{ code: "ERR_BAD_ATTR_VALUE", source: node.source },
184184
),
185185
);
186186
} else {
@@ -194,14 +194,14 @@ function validateNode(
194194
`${nodeLabel(node)} attribute '@${FIELD_ATTR_VALUES}' member '${member}' ` +
195195
`is not a valid identifier (must match ${ENUM_MEMBER_PATTERN.source}). ` +
196196
`Non-identifier-safe member strings require a symbol↔value mapping (deferred).`,
197-
{ code: "ERR_BAD_ATTR_VALUE" },
197+
{ code: "ERR_BAD_ATTR_VALUE", source: node.source },
198198
),
199199
);
200200
} else if (seen.has(member)) {
201201
errors.push(
202202
new ParseError(
203203
`${nodeLabel(node)} attribute '@${FIELD_ATTR_VALUES}' has duplicate member '${member}'.`,
204-
{ code: "ERR_BAD_ATTR_VALUE" },
204+
{ code: "ERR_BAD_ATTR_VALUE", source: node.source },
205205
),
206206
);
207207
} else {

server/typescript/packages/metadata/src/core/export-json.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export async function loadAndExportJson(
5555
return {
5656
json,
5757
errors: result.errors,
58-
warnings: result.warnings,
58+
// FR5a: LoadResult.warnings is now LoaderWarning[]; ExportResult preserves
59+
// its public string[] shape (callers print warnings as text). Extract the
60+
// message for back-compat.
61+
warnings: result.warnings.map((w) => w.message),
5962
};
6063
}

server/typescript/packages/metadata/src/core/parser-yaml.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@
77

88
import { parse as parseYamlText } from "yaml";
99
import { ParseError } from "../errors.js";
10-
import { buildTree, errOpts } from "../parser-core.js";
10+
import { buildTree } from "../parser-core.js";
1111
import type { ParseOptions, ParseResult } from "../parser-core.js";
12+
import type { ErrorSource } from "../source.js";
1213
import { desugar } from "./yaml-desugar.js";
1314

15+
/** FR5a / ADR-0009 — build a YAML-source envelope rooted at "$".
16+
* yamlPosition is FR5b territory and not populated here. */
17+
function yamlSource(sourceName: string | undefined): ErrorSource {
18+
return {
19+
format: "yaml",
20+
files: [sourceName ?? "<unknown>"],
21+
jsonPath: "$",
22+
};
23+
}
24+
1425
export function parseYaml(content: string, opts: ParseOptions): ParseResult {
1526
// Strip UTF-8 BOM if present (consistent with parseJson).
1627
const normalizedContent =
@@ -24,7 +35,7 @@ export function parseYaml(content: string, opts: ParseOptions): ParseResult {
2435
} catch (err) {
2536
throw new ParseError(
2637
`Invalid YAML: ${(err as Error).message}`,
27-
{ ...errOpts(opts.sourceName), code: "ERR_MALFORMED_YAML" },
38+
{ code: "ERR_MALFORMED_YAML", source: yamlSource(opts.sourceName) },
2839
);
2940
}
3041

@@ -37,7 +48,7 @@ export function parseYaml(content: string, opts: ParseOptions): ParseResult {
3748
const first = desugarErrors[0]!;
3849
throw new ParseError(
3950
first.message,
40-
{ ...errOpts(opts.sourceName), code: first.code ?? "ERR_MALFORMED_YAML" },
51+
{ code: first.code ?? "ERR_MALFORMED_YAML", source: yamlSource(opts.sourceName) },
4152
);
4253
}
4354

@@ -50,8 +61,8 @@ export function parseYaml(content: string, opts: ParseOptions): ParseResult {
5061
const desugarParseErrors = desugarErrors.map(
5162
(e) =>
5263
new ParseError(e.message, {
53-
...errOpts(opts.sourceName),
5464
code: e.code ?? "ERR_MALFORMED_YAML",
65+
source: yamlSource(opts.sourceName),
5566
}),
5667
);
5768
return {

server/typescript/packages/metadata/src/errors.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Typed error classes for the metadata parser.
22

3+
import type { ErrorSource, LoaderError, NodeContext } from "./source.js";
4+
35
/** Stable, language-neutral error codes — mirrors fixtures/conformance/ERROR-CODES.json. */
46
// NOTE: The following codes are forward-declared (no emitting site in the current
57
// TS parser/loader — the condition is not yet detected):
@@ -55,20 +57,52 @@ export const ERROR_CODES = [
5557

5658
export type ErrorCode = (typeof ERROR_CODES)[number];
5759

58-
export class ParseError extends Error {
59-
readonly source: string | undefined;
60-
readonly path: string | undefined; // logical path within the JSON, e.g. "metadata.children[2].field"
61-
readonly code: ErrorCode | undefined;
60+
/**
61+
* Loader error carrying the ADR-0009 LoaderError envelope.
62+
*
63+
* Public shape (FR5a):
64+
* new ParseError(message, { code, source, suggestions?, fixture?, node? })
65+
*
66+
* - `code` and `source` are required.
67+
* - `source` is the ErrorSource discriminated union (json/yaml/merged/resolved/
68+
* database/code) — the same envelope every cross-language port emits.
69+
* - `suggestions[]`, `fixture`, `node` are optional per ADR-0009 §RECOMMENDED;
70+
* FR5a does not populate them, FR5b–FR5e may.
71+
*
72+
* Legacy fields (`path?: string`, `source?: string`) were superseded by the
73+
* envelope's `jsonPath` and `files` and have been dropped — see CHANGELOG.
74+
*/
75+
export class ParseError extends Error implements LoaderError {
76+
readonly code: ErrorCode;
77+
readonly source: ErrorSource;
78+
readonly suggestions?: string[];
79+
readonly fixture?: string;
80+
readonly node?: NodeContext;
6281

6382
constructor(
6483
message: string,
65-
opts?: { source?: string; path?: string; code?: ErrorCode },
84+
opts: {
85+
code: ErrorCode;
86+
source: ErrorSource;
87+
suggestions?: string[];
88+
fixture?: string;
89+
node?: NodeContext;
90+
},
6691
) {
6792
super(message);
6893
this.name = "ParseError";
69-
this.source = opts?.source;
70-
this.path = opts?.path;
71-
this.code = opts?.code;
94+
this.code = opts.code;
95+
this.source = opts.source;
96+
// exactOptionalPropertyTypes: only assign when defined.
97+
if (opts.suggestions !== undefined) {
98+
(this as { suggestions?: string[] }).suggestions = opts.suggestions;
99+
}
100+
if (opts.fixture !== undefined) {
101+
(this as { fixture?: string }).fixture = opts.fixture;
102+
}
103+
if (opts.node !== undefined) {
104+
(this as { node?: NodeContext }).node = opts.node;
105+
}
72106
}
73107
}
74108

server/typescript/packages/metadata/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ export {
168168
export { ParseError, MetaModelError, ERROR_CODES } from "./errors.js";
169169
export type { ErrorCode } from "./errors.js";
170170

171+
// FR5a — loader error envelope + source-on-node (ADR-0009).
172+
// Re-exported from the package root so consumers that catch + repackage
173+
// ParseErrors (or narrow on `err.source.format === "json"`) can import the
174+
// envelope types alongside the runtime discriminator.
175+
export type {
176+
ErrorSource,
177+
LoaderError,
178+
LoaderWarning,
179+
NodeContext,
180+
Contributor,
181+
} from "./source.js";
182+
export { codeSource } from "./source.js";
183+
171184
// Attribute-schema validation pass (Phase A3)
172185
export { validateAttrSchema } from "./attr-schema-validate.js";
173186
export type { AttrSchemaValidationResult } from "./attr-schema-validate.js";
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// server/typescript/packages/metadata/src/json-path.ts
2+
//
3+
// FR5a / ADR-0009 — Canonical JSONPath builder.
4+
//
5+
// Construction rules (cross-port-aligned):
6+
// - Root is `$`.
7+
// - Object keys matching /^[A-Za-z_][A-Za-z0-9_]*$/ use dot notation: `.foo`.
8+
// - All other keys use single-quoted bracket form: `['my-key']`, `['@attr']`.
9+
// - Array indices use bracket form: `[N]`.
10+
// - No trailing dots, no whitespace.
11+
12+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
13+
14+
type Segment =
15+
| { kind: "key"; value: string }
16+
| { kind: "index"; value: number };
17+
18+
export class JsonPathBuilder {
19+
private readonly segments: Segment[] = [];
20+
21+
pushKey(key: string): void {
22+
this.segments.push({ kind: "key", value: key });
23+
}
24+
25+
pushIndex(idx: number): void {
26+
this.segments.push({ kind: "index", value: idx });
27+
}
28+
29+
pop(): void {
30+
this.segments.pop();
31+
}
32+
33+
toString(): string {
34+
let out = "$";
35+
for (const seg of this.segments) {
36+
if (seg.kind === "index") {
37+
out += `[${seg.value}]`;
38+
} else if (IDENT_RE.test(seg.value)) {
39+
out += `.${seg.value}`;
40+
} else {
41+
out += `['${seg.value.replace(/'/g, "\\'")}']`;
42+
}
43+
}
44+
return out;
45+
}
46+
}

0 commit comments

Comments
 (0)