Skip to content

Commit 3cf2b37

Browse files
committed
feat: universal validate() (accepts markup, JSON IR, or AST) + crash-fix
1 parent b8be990 commit 3cf2b37

10 files changed

Lines changed: 247 additions & 58 deletions

File tree

‎src/cli/validate-command.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ import path from 'node:path';
1616
import type { MCIssue, MailcConfig } from '../types.js';
1717
import { tokenize } from '../tokenizer/index.js';
1818
import { parse } from '../parser/index.js';
19-
import { validate } from '../validator/index.js';
20-
import { validateJSON } from '../json/index.js';
19+
import { validate } from '../validate.js';
2120
import type { MCNode } from '../json/schema.js';
2221
import {
2322
success,
@@ -224,7 +223,7 @@ function validateJsonFile(source: string, filePath: string): FileValidationResul
224223
? (obj['template'] as MCNode)
225224
: (parsed as MCNode);
226225

227-
const result = validateJSON(rootNode);
226+
const result = validate(rootNode);
228227

229228
return {
230229
file: filePath,

‎src/errors/codes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,6 @@ export enum ErrorCode {
109109
// ── Compiler safety ────────────────────────────────────────────────────
110110
MAX_RECURSION_DEPTH = 'MAX_RECURSION_DEPTH',
111111
JSON_PARSE_ERROR = 'JSON_PARSE_ERROR',
112+
// ── Public API contract ────────────────────────────────────────────────
113+
INVALID_INPUT = 'INVALID_INPUT',
112114
}

‎src/index.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ export type { Token } from './tokenizer/index.js';
6363
export { parse } from './parser/index.js';
6464

6565
// Validator (Phase 3)
66-
export { validate } from './validator/index.js';
66+
export { validate } from './validate.js';
67+
export type { ValidateInput } from './validate.js';
6768

6869
// CSS Checking — top-level convenience API (Phase 15)
6970
export { checkCss } from './css/index.js';
@@ -112,7 +113,7 @@ export type {
112113
} from './types.js';
113114

114115
// JSON → Email (Phase 10)
115-
export { compileFromJSON, validateJSON, validateDocument, jsonToAST, parseContent, jsonToMarkup, markupToJSON, astToMCNode, normalizeJSON } from './json/index.js';
116+
export { compileFromJSON, validateDocument, jsonToAST, parseContent, jsonToMarkup, markupToJSON, astToMCNode, normalizeJSON } from './json/index.js';
116117
export type {
117118
MCNode,
118119
MCDocument,

‎src/json/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ import { checkEmailBudget } from '../compiler/email-budget.js';
4040
import { ErrorCode } from '../errors/codes.js';
4141

4242
// Re-export sub-modules for barrel
43-
export { validateJSON, validateDocument, validateDataAgainstSchema } from './validator.js';
43+
// Note: `validateJSON` is intentionally NOT re-exported from the public surface —
44+
// the universal `validate()` (in src/validate.ts) covers JSON IR input.
45+
// `validateJSON` remains an internal helper used by `validateDocument` and
46+
// `compileFromJSON` (see imports above and below).
47+
export { validateDocument, validateDataAgainstSchema } from './validator.js';
4448
export { jsonToAST, parseContent } from './json-to-ast.js';
4549
export { jsonToMarkup } from './json-to-markup.js';
4650
export { markupToJSON, astToMCNode } from './markup-to-json.js';

‎src/utils/fuzzy-match.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,16 @@ export function fuzzyMatch(
6666
candidates: readonly string[],
6767
maxDistance: number = DEFAULT_MAX_DISTANCE,
6868
): FuzzyMatch[] {
69+
// Defensive: a public util must never crash on a non-string input.
70+
// Returning [] makes `suggest()` cleanly return undefined for callers
71+
// that hand us undefined/null (e.g. an AST node whose `.type` was
72+
// unexpectedly missing — historically the source of a TypeError here).
73+
if (typeof input !== 'string' || input.length === 0) return [];
74+
6975
const matches: FuzzyMatch[] = [];
7076

7177
for (const candidate of candidates) {
78+
if (typeof candidate !== 'string') continue;
7279
const distance = levenshtein(input.toLowerCase(), candidate.toLowerCase());
7380
if (distance <= maxDistance) {
7481
matches.push({ candidate, distance });

‎src/validate.ts‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Public `validate()` entry point — universal dispatcher.
3+
*
4+
* Accepts a markup string, JSON IR (MCNode), or AST, and dispatches to the
5+
* appropriate underlying walker:
6+
*
7+
* - `string` → tokenize + parse, then run the AST validator.
8+
* Parse errors are returned as structured `errors`, not thrown.
9+
* - `ASTNode` → AST validator (identified by the presence of `loc`).
10+
* - `MCNode` → JSON-IR validator (different walker — it does extra checks
11+
* the AST walker does not, e.g. `id` uniqueness).
12+
*
13+
* Anything else (number, null, MCDocument, etc.) returns a structured
14+
* `INVALID_INPUT` error rather than throwing — public APIs must never crash
15+
* the caller on a bad input type.
16+
*
17+
* @module validate
18+
*/
19+
import type { ASTNode, ValidationResult } from './types.js';
20+
import type { MCNode } from './json/schema.js';
21+
import { tokenize } from './tokenizer/index.js';
22+
import { parse } from './parser/index.js';
23+
import { validate as validateAST } from './validator/index.js';
24+
import { validateJSON as validateMCNode } from './json/validator.js';
25+
import { MCError } from './errors/mc-error.js';
26+
import { ErrorCode } from './errors/codes.js';
27+
28+
/** Accepted shapes for `validate()`. */
29+
export type ValidateInput = string | ASTNode | MCNode;
30+
31+
/**
32+
* Validates markup, JSON IR, or AST and returns all errors/warnings.
33+
*
34+
* Replaces the older AST-only `validate(ast)` (still used internally) with a
35+
* universal entry point that mirrors `compile()`'s input flexibility.
36+
*
37+
* @param input - Markup string, JSON IR (`MCNode`), or parsed `ASTNode`.
38+
* @returns A `ValidationResult` with `isValid`, `errors`, and `warnings`.
39+
*/
40+
export function validate(input: ValidateInput): ValidationResult {
41+
// ── (1) Markup string → tokenize + parse + AST walk ─────────────────
42+
if (typeof input === 'string') {
43+
try {
44+
const tokens = tokenize(input);
45+
const ast = parse(tokens);
46+
return validateAST(ast);
47+
} catch (e) {
48+
// Parse/tokenize errors arrive as MCError. Anything else is unexpected
49+
// (defensive — should not happen with valid string input). Either way,
50+
// surface as a structured validation error instead of throwing.
51+
if (e instanceof MCError) {
52+
return {
53+
isValid: false,
54+
errors: [{
55+
code: e.code,
56+
message: e.message,
57+
severity: 'error',
58+
...(e.loc ? { loc: { line: e.loc.start.line, col: e.loc.start.col } } : {}),
59+
...(e.fix ? { fix: e.fix } : {}),
60+
}],
61+
warnings: [],
62+
};
63+
}
64+
return {
65+
isValid: false,
66+
errors: [{
67+
code: ErrorCode.INVALID_INPUT,
68+
message: e instanceof Error ? e.message : String(e),
69+
severity: 'error',
70+
}],
71+
warnings: [],
72+
};
73+
}
74+
}
75+
76+
// ── (2) Reject non-object inputs cleanly ─────────────────────────────
77+
if (input === null || typeof input !== 'object') {
78+
return {
79+
isValid: false,
80+
errors: [{
81+
code: ErrorCode.INVALID_INPUT,
82+
message:
83+
`validate() expects a markup string, JSON IR (MCNode), or ASTNode. Received: ${input === null ? 'null' : typeof input}.`,
84+
severity: 'error',
85+
}],
86+
warnings: [],
87+
};
88+
}
89+
90+
// ── (3) Reject objects that lack `type` — likely an MCDocument or junk ─
91+
// Catching this here keeps the JSON walker simple and gives users
92+
// a clear pointer rather than a cryptic crash inside fuzzyMatch.
93+
const obj = input as { type?: unknown; template?: unknown };
94+
if (typeof obj.type !== 'string' || obj.type === '') {
95+
const looksLikeDocument =
96+
'template' in obj && typeof obj.template === 'object' && obj.template !== null;
97+
const hint = looksLikeDocument
98+
? ' This looks like an MCDocument — use validateDocument(doc) instead.'
99+
: '';
100+
return {
101+
isValid: false,
102+
errors: [{
103+
code: ErrorCode.INVALID_INPUT,
104+
message: `validate() input is missing a "type" field.${hint}`,
105+
severity: 'error',
106+
}],
107+
warnings: [],
108+
};
109+
}
110+
111+
// ── (4) Discriminate AST vs JSON IR by presence of `loc` ─────────────
112+
// AST nodes always carry a SourceLocation; JSON IR nodes never do.
113+
if ('loc' in obj) {
114+
return validateAST(input as ASTNode);
115+
}
116+
return validateMCNode(input as MCNode);
117+
}

‎tests/api/public-api.test.ts‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ import {
7474
} from '../../src/index.js';
7575

7676
// ── JSON ─────────────────────────────────────────────────────────────────
77+
// Note: `validateJSON` was deliberately dropped from the public surface —
78+
// the universal `validate()` now handles JSON IR input as one of its dispatch
79+
// branches. See src/validate.ts.
7780
import {
78-
validateJSON,
7981
validateDocument,
8082
jsonToAST,
8183
parseContent,
@@ -305,10 +307,6 @@ describe('Phase 15: Public API exports', () => {
305307
// ── 15.1 — JSON ──────────────────────────────────────────────────────
306308

307309
describe('JSON pipeline exports', () => {
308-
it('exports validateJSON as a function', () => {
309-
expect(typeof validateJSON).toBe('function');
310-
});
311-
312310
it('exports validateDocument as a function', () => {
313311
expect(typeof validateDocument).toBe('function');
314312
});
@@ -458,6 +456,46 @@ describe('Phase 15: Headline API smoke tests', () => {
458456
expect(result.errors.length).toBeGreaterThan(0);
459457
});
460458

459+
// ── validate() universal input dispatch ─────────────────────────────
460+
// These tests cover the *new* dispatch behaviour added by src/validate.ts.
461+
// Existing AST-input behaviour (above) and JSON-IR-input behaviour
462+
// (tests/json/validator.test.ts) are intentionally not re-tested here —
463+
// these focus only on what the universal dispatcher introduces.
464+
465+
it('validate() accepts a markup string directly (no manual tokenize+parse)', () => {
466+
const source = `<mc><mc-body><mc-section><mc-column><mc-text>Hi</mc-text></mc-column></mc-section></mc-body></mc>`;
467+
const result = validate(source);
468+
expect(result.isValid).toBe(true);
469+
expect(result.errors).toHaveLength(0);
470+
});
471+
472+
it('validate() on unparseable markup returns a structured error instead of throwing', () => {
473+
// Unclosed tag — would historically throw an MCError out of the parser.
474+
const bad = `<mc><mc-body><mc-text>oops`;
475+
expect(() => validate(bad)).not.toThrow();
476+
const result = validate(bad);
477+
expect(result.isValid).toBe(false);
478+
expect(result.errors.length).toBeGreaterThan(0);
479+
});
480+
481+
it('validate() returns INVALID_INPUT for non-object inputs (no throw)', () => {
482+
for (const bad of [42, null, undefined, true]) {
483+
expect(() => validate(bad as never)).not.toThrow();
484+
const result = validate(bad as never);
485+
expect(result.isValid).toBe(false);
486+
expect(result.errors[0]!.code).toBe('INVALID_INPUT');
487+
}
488+
});
489+
490+
it('validate() on an MCDocument-shaped object points users to validateDocument', () => {
491+
// A user with an MCDocument naturally tries validate(doc) — give a useful hint.
492+
const docLike = { version: '1.0', metadata: { id: 'x', name: 'y' }, template: { type: 'mc-body', attributes: {} } };
493+
expect(() => validate(docLike as never)).not.toThrow();
494+
const result = validate(docLike as never);
495+
expect(result.errors[0]!.code).toBe('INVALID_INPUT');
496+
expect(result.errors[0]!.message).toContain('validateDocument');
497+
});
498+
461499
it('checkCss() returns a result with success flag', () => {
462500
const result = checkCss('color: red; font-size: 16px');
463501
expect(result).toBeDefined();

‎tests/compiler/mc-class.test.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616

1717
import { describe, it, expect } from 'vitest';
1818
import { compile } from '../../src/compile.js';
19-
import { compileFromJSON, validateJSON } from '../../src/json/index.js';
19+
import { compileFromJSON } from '../../src/json/index.js';
20+
import { validate } from '../../src/validate.js';
2021
import type { MCNode } from '../../src/json/schema.js';
2122

2223
// ---------------------------------------------------------------------------
@@ -806,7 +807,7 @@ describe('mc-class: JSON round-trip via compileFromJSON', () => {
806807
// ---------------------------------------------------------------------------
807808

808809
describe('mc-class: JSON validator integration', () => {
809-
it('validateJSON accepts mc-class with valid name (no errors)', () => {
810+
it('validate accepts mc-class with valid name (no errors)', () => {
810811
const node: MCNode = {
811812
type: 'mc',
812813
attributes: {},
@@ -827,11 +828,11 @@ describe('mc-class: JSON validator integration', () => {
827828
{ type: 'mc-body', attributes: {} },
828829
],
829830
};
830-
const result = validateJSON(node);
831+
const result = validate(node);
831832
expect(result.errors).toHaveLength(0);
832833
});
833834

834-
it('validateJSON rejects mc-class without name (exactly one error)', () => {
835+
it('validate rejects mc-class without name (exactly one error)', () => {
835836
const node: MCNode = {
836837
type: 'mc',
837838
attributes: {},
@@ -852,15 +853,15 @@ describe('mc-class: JSON validator integration', () => {
852853
{ type: 'mc-body', attributes: {} },
853854
],
854855
};
855-
const result = validateJSON(node);
856+
const result = validate(node);
856857
const nameErrors = result.errors.filter(
857858
(e) => e.code === 'MISSING_ATTRIBUTE' && e.message.includes('mc-class'),
858859
);
859860
// Exactly one — no duplicate from double-validation
860861
expect(nameErrors).toHaveLength(1);
861862
});
862863

863-
it('validateJSON accepts mc-class with extends attribute (no errors)', () => {
864+
it('validate accepts mc-class with extends attribute (no errors)', () => {
864865
const node: MCNode = {
865866
type: 'mc',
866867
attributes: {},
@@ -882,11 +883,11 @@ describe('mc-class: JSON validator integration', () => {
882883
{ type: 'mc-body', attributes: {} },
883884
],
884885
};
885-
const result = validateJSON(node);
886+
const result = validate(node);
886887
expect(result.errors).toHaveLength(0);
887888
});
888889

889-
it('validateJSON does not produce INVALID_NESTING for mc-class inside mc-attributes', () => {
890+
it('validate does not produce INVALID_NESTING for mc-class inside mc-attributes', () => {
890891
const node: MCNode = {
891892
type: 'mc',
892893
attributes: {},
@@ -907,12 +908,12 @@ describe('mc-class: JSON validator integration', () => {
907908
{ type: 'mc-body', attributes: {} },
908909
],
909910
};
910-
const result = validateJSON(node);
911+
const result = validate(node);
911912
const nestingErrors = result.errors.filter((e) => e.code === 'INVALID_NESTING');
912913
expect(nestingErrors).toHaveLength(0);
913914
});
914915

915-
it('validateJSON rejects truly invalid mc-attributes child (mc-body)', () => {
916+
it('validate rejects truly invalid mc-attributes child (mc-body)', () => {
916917
// mc-section is valid inside mc-attributes (means "set defaults for mc-section").
917918
// mc-body is NOT in VALID_ATTRIBUTES_CHILDREN — it should produce INVALID_NESTING.
918919
const node: MCNode = {
@@ -935,7 +936,7 @@ describe('mc-class: JSON validator integration', () => {
935936
{ type: 'mc-body', attributes: {} },
936937
],
937938
};
938-
const result = validateJSON(node);
939+
const result = validate(node);
939940
expect(result.errors.some((e) => e.code === 'INVALID_NESTING')).toBe(true);
940941
});
941942
});

0 commit comments

Comments
 (0)