@adia-ai/a2ui-validator 0.4.0 → 0.4.2

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
7
7
 
8
8
  _No pending changes._
9
9
 
10
+ ## [0.4.2] - 2026-05-11
11
+
12
+ ### Ride-along (no source changes)
13
+
14
+ Lockstep PATCH cut alongside `@adia-ai/web-components@0.4.2` (`<input-ui type="number">` rewrite drops native `<input type=number>` wrapping) + `@adia-ai/web-modules@0.4.2` (`<editor-sidebar>` grid-track width-mirror fix). Source byte-identical to v0.4.1.
15
+
16
+ Internal `@adia-ai/*` dep ranges stay at `^0.4.0` (patch-cut asymmetry — `^0.4.0` covers `0.4.x` under semver). See root [CHANGELOG.md `## [0.4.2]`](../../../CHANGELOG.md) for the cut narrative.
17
+
18
+ ## [0.4.1] - 2026-05-10
19
+
20
+ ### Added
21
+
22
+ - **Semantic-validator Phase 3 foundation** (per [docs/specs/semantic-validator.md](../../../docs/specs/semantic-validator.md) § Phase 3 — rule-based fast-path):
23
+ - `semantic/intent-specs.js` — registry of 10 `IntentSpec` entries covering dominant intent classes: `auth.signup`, `auth.signin`, `auth.password-reset`, `dashboard.analytics`, `data-table.list`, `form.settings`, `form.contact`, `chat.conversation`, `commerce.cart`, `errors.not-found`.
24
+ - `semantic/classify-intent.js` — `classifyIntent()` regex classifier + `scoreAgainstSpec()` deterministic A2UI-tree scorer. Returns rule verdict with certainty 0..1 (≥ 0.9 = LLM-skip eligible per spec § 3c hybrid).
25
+ - 18 unit tests covering registry hygiene, regex match against 6 dominant intents, clean-pass / clean-fail / partial-verdict scoring, nested A2UI tree traversal.
26
+
27
+ ### Changed
28
+
29
+ - Spec status updated in `docs/specs/semantic-validator.md` to reflect Phase 3 foundation shipped (NOT yet wired into `validateSemantics()` — integration deferred until shadow-compare runs prove ≥ 95% agreement with LLM judge).
30
+
31
+ ### Notes
32
+
33
+ - The classifier is NOT yet called from `validateSemantics()`. Phase 3 integration requires (a) the shadow-compare runs from the spec's exit criteria, and (b) a `judge.js` extension to accept rule-verdict hints when falling through to LLM. Both planned for a follow-up cut.
34
+
10
35
  ## [0.4.0] - 2026-05-10
11
36
 
12
37
  ### Ride-along (no source changes)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-validator",
3
- "version": "0.4.0",
4
- "description": "AdiaUI A2UI validator \u2014 JSON Schema structural validation plus catalog-aware semantic validation (component exists, props match YAML). Split out from the compose engine so non-compose tooling (tests, MCP validator tools, CI gates) can depend on validation without pulling the whole generator graph.",
3
+ "version": "0.4.2",
4
+ "description": "AdiaUI A2UI validator JSON Schema structural validation plus catalog-aware semantic validation (component exists, props match YAML). Split out from the compose engine so non-compose tooling (tests, MCP validator tools, CI gates) can depend on validation without pulling the whole generator graph.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "exports": {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Intent classifier — Phase 3 kickoff (semantic-validator)
3
+ *
4
+ * Spec: docs/specs/semantic-validator.md § Phase 3
5
+ *
6
+ * Two-stage classification:
7
+ *
8
+ * 1. Find the best-matching IntentSpec from the registry via regex
9
+ * 2. Score the emitted A2UI tree against the spec deterministically
10
+ *
11
+ * High-confidence matches (intent.confidence ≥ 0.9 AND verdict.certainty ≥ 0.9)
12
+ * can short-circuit the LLM judge per § 3c (hybrid).
13
+ *
14
+ * Status: kickoff — not yet wired into validateSemantics(). Use for
15
+ * shadow-compare runs (the test harness in classify-intent.test.js).
16
+ */
17
+
18
+ import { findIntentSpec } from './intent-specs.js';
19
+
20
+ /**
21
+ * Classify an intent prompt against the IntentSpec registry.
22
+ *
23
+ * @param {string} intent — natural-language prompt
24
+ * @returns {{ spec: IntentSpec | null, confidence: number }}
25
+ */
26
+ export function classifyIntent(intent) {
27
+ return findIntentSpec(intent);
28
+ }
29
+
30
+ /**
31
+ * Score an A2UI tree against an IntentSpec deterministically.
32
+ *
33
+ * Returns a SemanticVerdict-shaped object with rule-based certainty.
34
+ * The caller decides whether to skip the LLM judge based on `certainty`.
35
+ *
36
+ * @param {object} a2ui — emitted A2UI tree (root object with `nodes` or
37
+ * equivalent traversable shape)
38
+ * @param {IntentSpec} spec — the classified IntentSpec
39
+ * @returns {RuleVerdict}
40
+ */
41
+ export function scoreAgainstSpec(a2ui, spec) {
42
+ const { distinct, instances } = collectComponentTypes(a2ui);
43
+
44
+ const requiredMissing = spec.required.filter((t) => !distinct.includes(t));
45
+ const forbiddenPresent = spec.forbidden.filter((t) => distinct.includes(t));
46
+ const componentCount = instances; // total instance count (matches existing validator semantics)
47
+
48
+ // Clean pass: all required present, no forbidden, meets minComponents
49
+ const cleanPass =
50
+ requiredMissing.length === 0 &&
51
+ forbiddenPresent.length === 0 &&
52
+ componentCount >= spec.minComponents;
53
+
54
+ // Clean fail: 2+ required missing (per spec § 3c "cleanly fails")
55
+ const cleanFail = requiredMissing.length >= 2;
56
+
57
+ // Certainty: 1.0 on clean pass/fail; 0.5 on partial (ambiguous)
58
+ let certainty = 0.5;
59
+ let verdict = 'partial';
60
+ if (cleanPass) {
61
+ certainty = 1.0;
62
+ verdict = 'pass';
63
+ } else if (cleanFail) {
64
+ certainty = 1.0;
65
+ verdict = 'fail';
66
+ }
67
+
68
+ // Build a structured findings payload — useful as hints to the LLM
69
+ // judge on fall-through
70
+ return {
71
+ verdict,
72
+ certainty,
73
+ score: cleanPass ? 100 : cleanFail ? 0 : 50,
74
+ spec: spec.id,
75
+ findings: {
76
+ required_missing: requiredMissing,
77
+ forbidden_present: forbiddenPresent,
78
+ component_count: componentCount,
79
+ min_components: spec.minComponents,
80
+ },
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Walk an A2UI tree and collect (a) the set of distinct component types
86
+ * and (b) the total instance count. The A2UI tree shape varies — sometimes
87
+ * flat arrays, sometimes nested under `nodes` / `children` / `body`. This
88
+ * walker handles both.
89
+ *
90
+ * @param {*} node — root or nested A2UI shape
91
+ * @returns {{ distinct: string[], instances: number }}
92
+ */
93
+ function collectComponentTypes(node) {
94
+ const distinct = new Set();
95
+ const counter = { n: 0 };
96
+ walk(node, distinct, counter);
97
+ return { distinct: Array.from(distinct), instances: counter.n };
98
+ }
99
+
100
+ function walk(node, distinct, counter) {
101
+ if (!node) return;
102
+ if (Array.isArray(node)) {
103
+ for (const item of node) walk(item, distinct, counter);
104
+ return;
105
+ }
106
+ if (typeof node !== 'object') return;
107
+ // Count instances + collect distinct
108
+ if (typeof node.component === 'string') {
109
+ distinct.add(node.component);
110
+ counter.n += 1;
111
+ } else if (typeof node.type === 'string') {
112
+ distinct.add(node.type);
113
+ counter.n += 1;
114
+ }
115
+ // Recurse children
116
+ if (node.children) walk(node.children, distinct, counter);
117
+ if (node.nodes) walk(node.nodes, distinct, counter);
118
+ if (node.body) walk(node.body, distinct, counter);
119
+ }
120
+
121
+ /**
122
+ * @typedef {object} RuleVerdict
123
+ * @property {'pass' | 'fail' | 'partial'} verdict
124
+ * @property {number} certainty — 0..1; ≥ 0.9 allows LLM-skip per § 3c
125
+ * @property {number} score — 0..100; aligned with LLM judge scale
126
+ * @property {string} spec — IntentSpec.id that produced this verdict
127
+ * @property {object} findings — required_missing / forbidden_present / counts
128
+ */
@@ -0,0 +1,192 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { INTENT_SPECS, findIntentSpec } from './intent-specs.js';
3
+ import { classifyIntent, scoreAgainstSpec } from './classify-intent.js';
4
+
5
+ describe('intent-specs registry', () => {
6
+ it('exports at least 10 IntentSpec entries', () => {
7
+ expect(INTENT_SPECS.length).toBeGreaterThanOrEqual(10);
8
+ });
9
+
10
+ it('has unique IntentSpec.id values', () => {
11
+ const ids = INTENT_SPECS.map((s) => s.id);
12
+ expect(new Set(ids).size).toBe(ids.length);
13
+ });
14
+
15
+ it('every IntentSpec has the required fields', () => {
16
+ for (const spec of INTENT_SPECS) {
17
+ expect(spec.id).toBeTruthy();
18
+ expect(spec.keywords).toBeInstanceOf(RegExp);
19
+ expect(Array.isArray(spec.required)).toBe(true);
20
+ expect(spec.required.length).toBeGreaterThan(0);
21
+ expect(Array.isArray(spec.forbidden)).toBe(true);
22
+ expect(typeof spec.minComponents).toBe('number');
23
+ expect(spec.minComponents).toBeGreaterThan(0);
24
+ expect(typeof spec.domain).toBe('string');
25
+ expect(typeof spec.description).toBe('string');
26
+ }
27
+ });
28
+ });
29
+
30
+ describe('findIntentSpec', () => {
31
+ it('matches "create a signup form" to auth.signup', () => {
32
+ const { spec, confidence } = findIntentSpec('create a signup form for new users');
33
+ expect(spec?.id).toBe('auth.signup');
34
+ expect(confidence).toBe(1.0);
35
+ });
36
+
37
+ it('matches "sign in page" to auth.signin', () => {
38
+ const { spec, confidence } = findIntentSpec('build a sign in page with email and password');
39
+ expect(spec?.id).toBe('auth.signin');
40
+ expect(confidence).toBe(1.0);
41
+ });
42
+
43
+ it('matches "admin dashboard" to dashboard.analytics', () => {
44
+ const { spec, confidence } = findIntentSpec('an admin dashboard with charts and stats');
45
+ expect(spec?.id).toBe('dashboard.analytics');
46
+ expect(confidence).toBe(1.0);
47
+ });
48
+
49
+ it('matches "contact form" to form.contact', () => {
50
+ const { spec, confidence } = findIntentSpec('a contact us form with name email and message');
51
+ expect(spec?.id).toBe('form.contact');
52
+ expect(confidence).toBe(1.0);
53
+ });
54
+
55
+ it('matches "404 page" to errors.not-found', () => {
56
+ const { spec, confidence } = findIntentSpec('a 404 page not found error');
57
+ expect(spec?.id).toBe('errors.not-found');
58
+ expect(confidence).toBe(1.0);
59
+ });
60
+
61
+ it('matches "shopping cart" to commerce.cart', () => {
62
+ const { spec, confidence } = findIntentSpec('a shopping cart with line items');
63
+ expect(spec?.id).toBe('commerce.cart');
64
+ expect(confidence).toBe(1.0);
65
+ });
66
+
67
+ it('returns null + 0 confidence on unknown intent', () => {
68
+ const { spec, confidence } = findIntentSpec('a kaleidoscope of avian taxonomies');
69
+ expect(spec).toBeNull();
70
+ expect(confidence).toBe(0);
71
+ });
72
+
73
+ it('returns null + 0 confidence on empty/invalid input', () => {
74
+ expect(findIntentSpec('').spec).toBeNull();
75
+ expect(findIntentSpec(null).spec).toBeNull();
76
+ expect(findIntentSpec(undefined).spec).toBeNull();
77
+ expect(findIntentSpec(42).spec).toBeNull();
78
+ });
79
+ });
80
+
81
+ describe('classifyIntent (alias)', () => {
82
+ it('classifies the same as findIntentSpec', () => {
83
+ const a = classifyIntent('sign up new user');
84
+ const b = findIntentSpec('sign up new user');
85
+ expect(a.spec?.id).toBe(b.spec?.id);
86
+ expect(a.confidence).toBe(b.confidence);
87
+ });
88
+ });
89
+
90
+ describe('scoreAgainstSpec', () => {
91
+ const signupSpec = INTENT_SPECS.find((s) => s.id === 'auth.signup');
92
+
93
+ it('returns clean pass for an A2UI tree with all required + no forbidden', () => {
94
+ const a2ui = {
95
+ nodes: [
96
+ { component: 'Card', children: [
97
+ { component: 'Input' },
98
+ { component: 'Input' },
99
+ { component: 'Input' },
100
+ { component: 'Button' },
101
+ { component: 'CheckBox' },
102
+ { component: 'Text' },
103
+ { component: 'Link' },
104
+ ]},
105
+ ],
106
+ };
107
+ const verdict = scoreAgainstSpec(a2ui, signupSpec);
108
+ expect(verdict.verdict).toBe('pass');
109
+ expect(verdict.certainty).toBe(1.0);
110
+ expect(verdict.score).toBe(100);
111
+ expect(verdict.findings.required_missing).toEqual([]);
112
+ expect(verdict.findings.forbidden_present).toEqual([]);
113
+ });
114
+
115
+ it('returns clean fail when 2+ required missing', () => {
116
+ const a2ui = {
117
+ nodes: [{ component: 'Toast' }], // no Card, Input, Button
118
+ };
119
+ const verdict = scoreAgainstSpec(a2ui, signupSpec);
120
+ expect(verdict.verdict).toBe('fail');
121
+ expect(verdict.certainty).toBe(1.0);
122
+ expect(verdict.score).toBe(0);
123
+ expect(verdict.findings.required_missing.length).toBeGreaterThanOrEqual(2);
124
+ });
125
+
126
+ it('returns partial when 1 required missing (ambiguous — defer to LLM)', () => {
127
+ const a2ui = {
128
+ nodes: [
129
+ { component: 'Card', children: [
130
+ { component: 'Input' },
131
+ { component: 'Input' },
132
+ { component: 'Input' },
133
+ { component: 'Input' },
134
+ { component: 'CheckBox' },
135
+ { component: 'Text' },
136
+ { component: 'Link' },
137
+ ]},
138
+ ],
139
+ };
140
+ const verdict = scoreAgainstSpec(a2ui, signupSpec);
141
+ expect(verdict.verdict).toBe('partial');
142
+ expect(verdict.certainty).toBeLessThan(0.9); // signals LLM fallback
143
+ expect(verdict.findings.required_missing).toEqual(['Button']);
144
+ });
145
+
146
+ it('returns partial when forbidden present (even with all required)', () => {
147
+ const a2ui = {
148
+ nodes: [
149
+ { component: 'Card' },
150
+ { component: 'Input' },
151
+ { component: 'Input' },
152
+ { component: 'Input' },
153
+ { component: 'Button' },
154
+ { component: 'Toast' }, // forbidden
155
+ { component: 'CheckBox' },
156
+ { component: 'Text' },
157
+ ],
158
+ };
159
+ const verdict = scoreAgainstSpec(a2ui, signupSpec);
160
+ expect(verdict.verdict).toBe('partial');
161
+ expect(verdict.findings.forbidden_present).toContain('Toast');
162
+ });
163
+
164
+ it('returns partial when below minComponents (even with all required)', () => {
165
+ const a2ui = {
166
+ nodes: [
167
+ { component: 'Card' },
168
+ { component: 'Input' },
169
+ { component: 'Button' },
170
+ ],
171
+ };
172
+ const verdict = scoreAgainstSpec(a2ui, signupSpec);
173
+ expect(verdict.verdict).toBe('partial');
174
+ expect(verdict.findings.component_count).toBeLessThan(signupSpec.minComponents);
175
+ });
176
+
177
+ it('walks nested A2UI trees (children + nodes + body)', () => {
178
+ const a2ui = {
179
+ body: {
180
+ children: [
181
+ { component: 'Card', nodes: [
182
+ { component: 'Input' },
183
+ { component: 'Button' },
184
+ ]},
185
+ ],
186
+ },
187
+ };
188
+ const verdict = scoreAgainstSpec(a2ui, signupSpec);
189
+ // Card, Input, Button found — but Input only once + below min
190
+ expect(verdict.findings.component_count).toBe(3);
191
+ });
192
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * IntentSpec Registry — Phase 3 kickoff (semantic-validator)
3
+ *
4
+ * Spec: docs/specs/semantic-validator.md § 5.1, § Phase 3
5
+ *
6
+ * An IntentSpec is a deterministic recipe for one common intent class.
7
+ * The classify-intent.js classifier matches user prompts against these
8
+ * specs; high-confidence matches (confidence ≥ 0.9) can bypass the LLM
9
+ * judge entirely.
10
+ *
11
+ * Status: kickoff — 10 IntentSpec entries covering the dominant intent
12
+ * classes from the existing keyword map in validator.js. NOT YET wired
13
+ * into validateSemantics() (Phase 3 integration deferred to a follow-up
14
+ * cut once shadow-compare runs prove ≥95% agreement with the LLM judge).
15
+ *
16
+ * Schema (subset of § 5.2 in the spec):
17
+ *
18
+ * IntentSpec {
19
+ * id: string // e.g. 'auth.signup'
20
+ * keywords: RegExp // bag-of-words match
21
+ * required: string[] // component types that MUST appear
22
+ * forbidden: string[] // component types that MUST NOT appear
23
+ * minComponents:number // structural floor
24
+ * domain: 'data-entry' | 'data-display' | 'action' | 'identity' | 'navigation' | 'media' | 'layout' | 'notification' | 'overlay'
25
+ * description: string // for catalog / docs
26
+ * }
27
+ *
28
+ * Adding a new spec:
29
+ * 1. Author the entry below
30
+ * 2. Add a row to the test file: `classify-intent.test.js`
31
+ * 3. Run shadow-compare for 1 week (Phase 3 exit criteria § Phase 3)
32
+ * 4. Promote to fast-path if rule verdict agrees with LLM verdict ≥ 95%
33
+ */
34
+
35
+ /** @type {Array<IntentSpec>} */
36
+ export const INTENT_SPECS = [
37
+ {
38
+ id: 'auth.signup',
39
+ keywords: /\b(?:sign[\s-]*up|register|create\s+account|new\s+user)\b/i,
40
+ required: ['Card', 'Input', 'Button'],
41
+ forbidden: ['Toast', 'Alert'], // a signup page should not be primarily a toast/alert
42
+ minComponents: 7,
43
+ domain: 'data-entry',
44
+ description: 'New-user account creation flow — email/password Card with explicit submit Button',
45
+ },
46
+ {
47
+ id: 'auth.signin',
48
+ keywords: /\b(?:sign[\s-]*in|log[\s-]*in|login)\b/i,
49
+ required: ['Card', 'Input', 'Button'],
50
+ forbidden: ['Toast'],
51
+ minComponents: 6,
52
+ domain: 'data-entry',
53
+ description: 'Authentication form — credentials Card with submit Button',
54
+ },
55
+ {
56
+ id: 'auth.password-reset',
57
+ keywords: /\b(?:reset\s+password|forgot\s+password|recover\s+account)\b/i,
58
+ required: ['Card', 'Input', 'Button'],
59
+ forbidden: ['Toast'],
60
+ minComponents: 5,
61
+ domain: 'data-entry',
62
+ description: 'Password reset / forgot-password flow — single-email-input Card',
63
+ },
64
+ {
65
+ id: 'dashboard.analytics',
66
+ keywords: /\b(?:dashboard|analytics|metrics|admin\s+dashboard)\b/i,
67
+ required: ['Grid', 'Card'],
68
+ forbidden: ['Toast', 'Alert'],
69
+ minComponents: 10,
70
+ domain: 'data-display',
71
+ description: 'Metrics dashboard — Grid of Stat/Chart Cards with optional Nav',
72
+ },
73
+ {
74
+ id: 'data-table.list',
75
+ keywords: /\b(?:table|list|directory|roster|catalog|index)\b/i,
76
+ required: ['Table'],
77
+ forbidden: ['Toast'],
78
+ minComponents: 4,
79
+ domain: 'data-display',
80
+ description: 'Tabular data list — sortable/filterable Table',
81
+ },
82
+ {
83
+ id: 'form.settings',
84
+ keywords: /\b(?:settings|preferences|configuration|account\s+settings|profile\s+settings)\b/i,
85
+ required: ['Form', 'Input', 'Button'],
86
+ forbidden: ['Toast'],
87
+ minComponents: 8,
88
+ domain: 'data-entry',
89
+ description: 'Multi-field settings/preferences Form with explicit save Button',
90
+ },
91
+ {
92
+ id: 'form.contact',
93
+ keywords: /\b(?:contact\s+(?:us|form)|reach\s+out|get\s+in\s+touch|inquiry\s+form)\b/i,
94
+ required: ['Form', 'Input', 'TextArea', 'Button'],
95
+ forbidden: ['Toast'],
96
+ minComponents: 6,
97
+ domain: 'data-entry',
98
+ description: 'Contact form — name/email/message TextArea with submit Button',
99
+ },
100
+ {
101
+ id: 'chat.conversation',
102
+ keywords: /\b(?:chat|conversation|messaging|messenger)\b/i,
103
+ required: ['Input', 'Button'],
104
+ forbidden: ['Table', 'Grid'],
105
+ minComponents: 4,
106
+ domain: 'data-entry',
107
+ description: 'Conversational chat surface — Input + send Button stream',
108
+ },
109
+ {
110
+ id: 'commerce.cart',
111
+ keywords: /\b(?:cart|checkout|basket|shopping\s+cart)\b/i,
112
+ required: ['Card', 'Button'],
113
+ forbidden: ['Toast'],
114
+ minComponents: 6,
115
+ domain: 'data-display',
116
+ description: 'Shopping cart — line-item Cards with checkout Button',
117
+ },
118
+ {
119
+ id: 'errors.not-found',
120
+ keywords: /\b(?:404|not\s+found|page\s+not\s+found|page\s+missing)\b/i,
121
+ required: ['Card', 'Button'],
122
+ forbidden: ['Form', 'Table'],
123
+ minComponents: 3,
124
+ domain: 'action',
125
+ description: '404 error page — reassurance Card with back-to-home Button',
126
+ },
127
+ ];
128
+
129
+ /**
130
+ * Find the IntentSpec matching the prompt (if any), with a confidence score.
131
+ *
132
+ * Confidence semantics:
133
+ * - 1.0 = exact keyword regex match
134
+ * - 0.0 = no match
135
+ *
136
+ * Returns { spec, confidence } or { spec: null, confidence: 0 }.
137
+ *
138
+ * @param {string} intent — natural-language intent prompt
139
+ * @returns {{ spec: IntentSpec | null, confidence: number }}
140
+ */
141
+ export function findIntentSpec(intent) {
142
+ if (!intent || typeof intent !== 'string') {
143
+ return { spec: null, confidence: 0 };
144
+ }
145
+ for (const spec of INTENT_SPECS) {
146
+ if (spec.keywords.test(intent)) {
147
+ return { spec, confidence: 1.0 };
148
+ }
149
+ }
150
+ return { spec: null, confidence: 0 };
151
+ }
152
+
153
+ /**
154
+ * @typedef {object} IntentSpec
155
+ * @property {string} id — dot-namespaced identifier
156
+ * @property {RegExp} keywords — pattern that matches user prompts
157
+ * @property {string[]} required — A2UI component types that MUST appear
158
+ * @property {string[]} forbidden — component types that MUST NOT appear
159
+ * @property {number} minComponents — minimum total component count
160
+ * @property {string} domain — semantic category
161
+ * @property {string} description — human-readable summary
162
+ */