@adia-ai/mcp 0.8.37 → 0.8.39

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.
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env node
2
+ // adia-contract-check — the class-5 contract checker (REQ-03, factory-dx-ws5-
3
+ // consumer-verify SPEC): authored markup attributes vs the shipped component
4
+ // contracts. This is the class with no gate anywhere (gh#1024, gh#982, gh#924;
5
+ // D8's class-5 anchor) — a `color=` on <icon-ui> (gh#982's most frequent dead
6
+ // attribute) silently no-ops today; this script turns it red.
7
+ //
8
+ // Contract source: the SHIPPED JSON derivatives, never the yaml SoT (which
9
+ // does not ship) — node_modules/@adia-ai/web-components/custom-elements.json
10
+ // (attribute-name existence) and node_modules/@adia-ai/web-components/
11
+ // components/<slug>/<slug>.a2ui.json (enum-value membership, best-effort per
12
+ // tag — see resolveA2uiDoc()).
13
+ //
14
+ // v1 scope (stated honestly, per the SPEC's Non-goals): attribute names +
15
+ // enum values on `*-ui` tags; class/id/slot/style/role/data-*/aria-*/
16
+ // framework-global attributes allowlisted; one content-model rule — raw <thead>/
17
+ // <tbody> children of <table-ui> (3 of 3 occurrences broken, gh#924). Slot
18
+ // and composition semantics are v2.
19
+ //
20
+ // The shipped contract mixes kebab-case and camelCase attribute names across
21
+ // components (an existing inconsistency in the generated manifest — e.g.
22
+ // pane-ui ships `maxWidth` while tour-ui ships `auto-start`) — this script
23
+ // normalizes both the authored name and every contract name to lowercase-
24
+ // no-hyphens before comparing, so that drift never produces a false flag.
25
+ // The a2ui.json `$ref`s a `common_types.json` that does NOT ship in the
26
+ // package — this checker never attempts full $ref resolution; only inline
27
+ // enums are read.
28
+ //
29
+ // KNOWN LIMITATION (gh#1154): the shipped custom-elements.json manifest
30
+ // itself is missing a small number of real `reflect: true` properties for at
31
+ // least 14 tags (e.g. icon-ui's `tone`) — a generator defect, not a bug in
32
+ // this checker, which faithfully asserts against whatever the contract says
33
+ // per REQ-03. Until gh#1154 lands, this checker can false-flag those
34
+ // specific attributes; consumers should check the tag's yaml SoT (or gh#1154)
35
+ // before assuming a flagged attribute is truly dead.
36
+ //
37
+ // Usage:
38
+ // node adia-contract-check.mjs <dir-or-file>...
39
+ // node adia-contract-check.mjs selftest # embedded fixtures, no node_modules needed
40
+ //
41
+ // Exit: 0 = clean, 1 = violation(s) found, 2 = setup error (contract missing).
42
+
43
+ import fs from 'node:fs';
44
+ import os from 'node:os';
45
+ import path from 'node:path';
46
+ import process from 'node:process';
47
+ import { execFileSync } from 'node:child_process';
48
+ import { fileURLToPath } from 'node:url';
49
+ import { realpathSync } from 'node:fs';
50
+
51
+ const __filename = fileURLToPath(import.meta.url);
52
+
53
+ const MARKUP_EXT = new Set(['.html', '.htm', '.vue', '.svelte', '.astro']);
54
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.next', 'coverage']);
55
+
56
+ // `role` is a host-language global (like `aria-*`) — text.yaml itself documents
57
+ // role="heading" + aria-level as the sanctioned semantic-heading path (gh#1256);
58
+ // globals are never component contract members and never CLASS-5-UNKNOWN.
59
+ const ALLOWLIST_EXACT = new Set(['class', 'classname', 'id', 'slot', 'style', 'key', 'ref', 'part', 'role']);
60
+ const ALLOWLIST_PREFIX = ['data-', 'aria-'];
61
+
62
+ function normalize(name) {
63
+ return name.toLowerCase().replace(/-/g, '');
64
+ }
65
+
66
+ function isAllowlisted(attrName) {
67
+ const lower = attrName.toLowerCase();
68
+ if (ALLOWLIST_EXACT.has(lower)) return true;
69
+ return ALLOWLIST_PREFIX.some((p) => lower.startsWith(p));
70
+ }
71
+
72
+ // ---- contract index (pure — no I/O; testable with inline fixtures) --------
73
+
74
+ export function buildAttrIndex(customElementsJson) {
75
+ const index = new Map(); // tag -> Map(normalized -> originalName)
76
+ for (const mod of customElementsJson.modules || []) {
77
+ for (const decl of mod.declarations || []) {
78
+ if (!decl.tagName) continue;
79
+ const m = index.get(decl.tagName) || new Map();
80
+ for (const attr of decl.attributes || []) {
81
+ m.set(normalize(attr.name), attr.name);
82
+ }
83
+ index.set(decl.tagName, m);
84
+ }
85
+ }
86
+ return index;
87
+ }
88
+
89
+ export function buildEnumIndex(a2uiDoc) {
90
+ const m = new Map(); // normalized attr -> enum string[]
91
+ const props = (a2uiDoc && a2uiDoc.properties) || {};
92
+ for (const [key, val] of Object.entries(props)) {
93
+ if (val && Array.isArray(val.enum)) {
94
+ m.set(normalize(key), val.enum);
95
+ }
96
+ }
97
+ return m;
98
+ }
99
+
100
+ function editDistance(a, b) {
101
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
102
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
103
+ for (let i = 1; i <= a.length; i++) {
104
+ for (let j = 1; j <= b.length; j++) {
105
+ dp[i][j] = a[i - 1] === b[j - 1]
106
+ ? dp[i - 1][j - 1]
107
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
108
+ }
109
+ }
110
+ return dp[a.length][b.length];
111
+ }
112
+
113
+ function nearestAttr(attrName, attrMap) {
114
+ let best = null;
115
+ let bestDist = Infinity;
116
+ for (const original of attrMap.values()) {
117
+ const d = editDistance(attrName.toLowerCase(), original.toLowerCase());
118
+ if (d < bestDist) {
119
+ bestDist = d;
120
+ best = original;
121
+ }
122
+ }
123
+ return best;
124
+ }
125
+
126
+ // ---- markup scanning --------------------------------------------------------
127
+
128
+ const TAG_RE = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)*-ui)\b([^>]*)>/g;
129
+ const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("[^"]*"|'[^']*'|\{[^}]*\}|[^\s>]+))?/g;
130
+ const TABLE_CONTENT_MODEL_RE = /<table-ui\b[^>]*>(?:(?!<\/table-ui>)[\s\S])*?<t(?:head|body)\b/g;
131
+
132
+ function lineOf(text, index) {
133
+ return text.slice(0, index).split('\n').length;
134
+ }
135
+
136
+ export function checkMarkup(text, filePath, attrIndex) {
137
+ const findings = [];
138
+
139
+ for (const m of text.matchAll(TAG_RE)) {
140
+ const tag = m[1];
141
+ const attrString = m[2];
142
+ const attrMap = attrIndex.get(tag);
143
+ if (!attrMap) continue; // unknown tag (third-party or typo'd) — not this checker's v1 concern
144
+
145
+ ATTR_RE.lastIndex = 0;
146
+ let am;
147
+ while ((am = ATTR_RE.exec(attrString))) {
148
+ const attrName = am[1];
149
+ if (attrName === tag) continue;
150
+ if (isAllowlisted(attrName)) continue;
151
+ const norm = normalize(attrName);
152
+ if (!attrMap.attrs.has(norm)) {
153
+ const suggestion = nearestAttr(attrName, attrMap.attrs);
154
+ findings.push({
155
+ type: 'CLASS-5-UNKNOWN-ATTR',
156
+ line: lineOf(text, m.index),
157
+ tag,
158
+ attr: attrName,
159
+ message: `${tag} has no attribute "${attrName}" — it renders as a silent no-op.`,
160
+ suggestion: suggestion ? `Did you mean: ${suggestion}="..."?` : null,
161
+ });
162
+ continue;
163
+ }
164
+ const rawValue = am[2];
165
+ if (!rawValue) continue;
166
+ const quoted = /^"([^"]*)"$|^'([^']*)'$/.exec(rawValue);
167
+ if (!quoted) continue; // {expr} / bare token — not a static literal we can enum-check
168
+ const value = quoted[1] !== undefined ? quoted[1] : quoted[2];
169
+ const allowed = attrMap.enums.get(norm);
170
+ if (allowed && !allowed.includes(value)) {
171
+ findings.push({
172
+ type: 'CLASS-5-ENUM-VIOLATION',
173
+ line: lineOf(text, m.index),
174
+ tag,
175
+ attr: attrName,
176
+ message: `${tag}[${attrName}="${value}"] is not a recognized value.`,
177
+ suggestion: `Allowed: ${allowed.join(', ')}`,
178
+ });
179
+ }
180
+ }
181
+ }
182
+
183
+ for (const m of text.matchAll(TABLE_CONTENT_MODEL_RE)) {
184
+ findings.push({
185
+ type: 'CLASS-5-CONTENT-MODEL',
186
+ line: lineOf(text, m.index),
187
+ tag: 'table-ui',
188
+ attr: null,
189
+ message: 'raw <thead>/<tbody> markup as a <table-ui> child is not a supported content model.',
190
+ suggestion: 'use the `.columns`/`.data` properties (or <col-def> children) instead of raw table markup.',
191
+ });
192
+ }
193
+
194
+ findings.sort((a, b) => a.line - b.line);
195
+ return findings;
196
+ }
197
+
198
+ function render(filePath, findings) {
199
+ const lines = [];
200
+ for (const f of findings) {
201
+ lines.push(`${filePath}:${f.line} ${f.type}`);
202
+ lines.push(` ${f.message}`);
203
+ if (f.suggestion) lines.push(` ${f.suggestion}`);
204
+ }
205
+ return lines.join('\n');
206
+ }
207
+
208
+ // ---- consumer-side contract loading (I/O — not exercised by selftest) ------
209
+
210
+ function findPackageRoot(startDir) {
211
+ let dir = path.resolve(startDir);
212
+ while (true) {
213
+ const candidate = path.join(dir, 'node_modules', '@adia-ai', 'web-components');
214
+ if (fs.existsSync(path.join(candidate, 'custom-elements.json'))) return candidate;
215
+ const parent = path.dirname(dir);
216
+ if (parent === dir) return null;
217
+ dir = parent;
218
+ }
219
+ }
220
+
221
+ function slugFromTag(tag) {
222
+ return tag.endsWith('-ui') ? tag.slice(0, -3) : null;
223
+ }
224
+
225
+ function loadContract(cwd) {
226
+ const pkgRoot = findPackageRoot(cwd);
227
+ if (!pkgRoot) return null;
228
+ const customElementsJson = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'custom-elements.json'), 'utf8'));
229
+ const attrsByTag = buildAttrIndex(customElementsJson);
230
+ const index = new Map(); // tag -> { attrs, enums }
231
+ for (const [tag, attrs] of attrsByTag) {
232
+ let enums = new Map();
233
+ const slug = slugFromTag(tag);
234
+ if (slug) {
235
+ const a2uiPath = path.join(pkgRoot, 'components', slug, `${slug}.a2ui.json`);
236
+ if (fs.existsSync(a2uiPath)) {
237
+ try {
238
+ enums = buildEnumIndex(JSON.parse(fs.readFileSync(a2uiPath, 'utf8')));
239
+ } catch {
240
+ // malformed a2ui.json for this tag — existence check still runs, enum check just skips
241
+ }
242
+ }
243
+ }
244
+ index.set(tag, { attrs, enums });
245
+ }
246
+ return index;
247
+ }
248
+
249
+ function collectFiles(target) {
250
+ const stat = fs.statSync(target);
251
+ if (stat.isFile()) return [target];
252
+ const out = [];
253
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
254
+ if (entry.name.startsWith('.')) continue;
255
+ const full = path.join(target, entry.name);
256
+ if (entry.isDirectory()) {
257
+ if (SKIP_DIRS.has(entry.name)) continue;
258
+ out.push(...collectFiles(full));
259
+ } else if (MARKUP_EXT.has(path.extname(entry.name))) {
260
+ out.push(full);
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+
266
+ // ---- selftest — embedded fixtures, no real node_modules required -----------
267
+
268
+ function selftest() {
269
+ const fails = [];
270
+
271
+ const customElementsJson = {
272
+ modules: [
273
+ {
274
+ path: 'components/icon/icon.js',
275
+ declarations: [{
276
+ tagName: 'icon-ui',
277
+ attributes: [
278
+ { name: 'name' }, { name: 'label' }, { name: 'size' }, { name: 'weight' }, { name: 'tone' },
279
+ ],
280
+ }],
281
+ },
282
+ {
283
+ path: 'components/table/table.js',
284
+ declarations: [{ tagName: 'table-ui', attributes: [{ name: 'sortable' }, { name: 'density' }] }],
285
+ },
286
+ {
287
+ path: 'components/pane/pane.js',
288
+ declarations: [{ tagName: 'pane-ui', attributes: [{ name: 'maxWidth' }, { name: 'side' }] }],
289
+ },
290
+ {
291
+ path: 'components/text/text.js',
292
+ declarations: [{ tagName: 'text-ui', attributes: [{ name: 'variant' }, { name: 'tone' }] }],
293
+ },
294
+ ],
295
+ };
296
+ const iconA2ui = {
297
+ properties: {
298
+ tone: { type: 'string', enum: ['accent', 'info', 'success', 'warning', 'danger', 'muted', 'neutral'] },
299
+ weight: { type: 'string', enum: ['thin', 'light', 'regular', 'bold', 'fill', 'duotone'] },
300
+ },
301
+ };
302
+
303
+ const attrsByTag = buildAttrIndex(customElementsJson);
304
+ const index = new Map();
305
+ index.set('icon-ui', { attrs: attrsByTag.get('icon-ui'), enums: buildEnumIndex(iconA2ui) });
306
+ index.set('table-ui', { attrs: attrsByTag.get('table-ui'), enums: new Map() });
307
+ index.set('pane-ui', { attrs: attrsByTag.get('pane-ui'), enums: new Map() });
308
+ index.set('text-ui', { attrs: attrsByTag.get('text-ui'), enums: new Map() });
309
+
310
+ // 1. gh#982's named example: icon-ui color= is a dead attribute.
311
+ const deadAttr = checkMarkup('<icon-ui color="primary"></icon-ui>', 'f.html', index);
312
+ if (deadAttr.length !== 1 || deadAttr[0].type !== 'CLASS-5-UNKNOWN-ATTR') fails.push('icon-ui color= not flagged');
313
+
314
+ // 2. A clean, real icon-ui markup must stay clean.
315
+ const clean = checkMarkup('<icon-ui name="house" tone="success" weight="fill" class="x" data-id="1" aria-label="y"></icon-ui>', 'f.html', index);
316
+ if (clean.length !== 0) fails.push(`clean markup flagged: ${JSON.stringify(clean)}`);
317
+
318
+ // 3. Enum violation on a real attribute.
319
+ const enumBad = checkMarkup('<icon-ui name="house" tone="purple"></icon-ui>', 'f.html', index);
320
+ if (enumBad.length !== 1 || enumBad[0].type !== 'CLASS-5-ENUM-VIOLATION') fails.push('bad enum value not flagged');
321
+
322
+ // 4. camelCase (pane-ui maxWidth) authored as kebab-case must still resolve — the
323
+ // normalization this checker exists to provide (no false flag on manifest drift).
324
+ const camelOk = checkMarkup('<pane-ui max-width="300"></pane-ui>', 'f.html', index);
325
+ if (camelOk.length !== 0) fails.push(`kebab-authored maxWidth should normalize-match, got: ${JSON.stringify(camelOk)}`);
326
+
327
+ // 5. gh#924: raw thead/tbody inside table-ui is a content-model violation.
328
+ const tableBad = checkMarkup('<table-ui><thead><tr><th>x</th></tr></thead></table-ui>', 'f.html', index);
329
+ if (!tableBad.some((f) => f.type === 'CLASS-5-CONTENT-MODEL')) fails.push('raw thead/tbody in table-ui not flagged');
330
+
331
+ // 6. table-ui using the real API stays clean.
332
+ const tableOk = checkMarkup('<table-ui sortable density="compact"></table-ui>', 'f.html', index);
333
+ if (tableOk.length !== 0) fails.push(`table-ui with real props flagged: ${JSON.stringify(tableOk)}`);
334
+
335
+ // 7. An unrecognized *-ui tag (not in the contract) is silently skipped, not flagged —
336
+ // v1 doesn't judge tags outside the shipped catalog.
337
+ const unknownTag = checkMarkup('<some-third-party-ui bogus="x"></some-third-party-ui>', 'f.html', index);
338
+ if (unknownTag.length !== 0) fails.push('unknown tag should be skipped, not flagged');
339
+
340
+ // 8. Removing the seeded violation restores green (AC-3's both-directions check).
341
+ const fixed = checkMarkup('<icon-ui name="house"></icon-ui>', 'f.html', index);
342
+ if (fixed.length !== 0) fails.push('fixed markup still flagged');
343
+
344
+ // 9. gh#1256 regression: `role` (+ aria-*) are host-language globals — text.yaml's
345
+ // own sanctioned semantic-heading pattern must exit clean, never CLASS-5-UNKNOWN.
346
+ const roleGlobal = checkMarkup('<text-ui variant="title" role="heading" aria-level="1">Dashboard</text-ui>', 'f.html', index);
347
+ if (roleGlobal.length !== 0) fails.push(`role="heading" aria-level flagged (gh#1256): ${JSON.stringify(roleGlobal)}`);
348
+
349
+ // 10. Setup-error path (AC-4's 0/1/2 matrix): no @adia-ai/web-components in
350
+ // node_modules anywhere up the tree must resolve to null (drives exit 2 in
351
+ // main()), never a thrown exception or a silent empty-clean pass.
352
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adia-contract-check-empty-'));
353
+ try {
354
+ if (loadContract(emptyDir) !== null) fails.push('loadContract should return null when the contract is missing');
355
+ } finally {
356
+ fs.rmSync(emptyDir, { recursive: true, force: true });
357
+ }
358
+
359
+ // 11. AC-4 — -h/--help exits 0 and prints usage, never a data payload.
360
+ // Spawned for real (main() calls process.exit() directly).
361
+ for (const flag of ['-h', '--help']) {
362
+ let out, code;
363
+ try {
364
+ out = execFileSync(process.execPath, [__filename, flag], { encoding: 'utf8' });
365
+ code = 0;
366
+ } catch (e) {
367
+ out = e.stdout || '';
368
+ code = e.status;
369
+ }
370
+ if (code !== 0) fails.push(`${flag} did not exit 0 (got ${code})`);
371
+ if (!out.startsWith('usage:')) fails.push(`${flag} did not print usage (got ${JSON.stringify(out)})`);
372
+ }
373
+
374
+ if (fails.length) {
375
+ console.error('selftest FAIL: ' + fails.join(' | '));
376
+ return 1;
377
+ }
378
+ console.log('selftest OK — 11 fixtures (unknown attr, clean, enum, camelCase-normalize, table content-model, unknown tag, seed+un-seed, role/aria globals, setup-error, -h/--help)');
379
+ return 0;
380
+ }
381
+
382
+ function main(argv) {
383
+ if (argv[0] === 'selftest') {
384
+ process.exit(selftest());
385
+ }
386
+ if (argv.includes('-h') || argv.includes('--help')) {
387
+ console.log('usage: adia-contract-check.mjs <dir-or-file>...');
388
+ process.exit(0);
389
+ }
390
+ const targets = argv.filter((a) => !a.startsWith('-'));
391
+ if (targets.length === 0) {
392
+ console.error('usage: adia-contract-check.mjs <dir-or-file>...');
393
+ process.exit(2);
394
+ }
395
+ const index = loadContract(process.cwd());
396
+ if (!index) {
397
+ console.error('adia-contract-check: @adia-ai/web-components not found in node_modules — npm install');
398
+ process.exit(2);
399
+ }
400
+ let total = 0;
401
+ for (const target of targets) {
402
+ let files;
403
+ try {
404
+ files = collectFiles(target);
405
+ } catch (e) {
406
+ console.error(`adia-contract-check: cannot read ${target}: ${(e && e.message) || e}`);
407
+ process.exit(2);
408
+ }
409
+ for (const file of files) {
410
+ const text = fs.readFileSync(file, 'utf8');
411
+ const findings = checkMarkup(text, file, index);
412
+ if (findings.length) {
413
+ total += findings.length;
414
+ console.log(render(file, findings));
415
+ }
416
+ }
417
+ }
418
+ process.exit(total ? 1 : 0);
419
+ }
420
+
421
+ // Only runs when invoked directly — never on import (this module is also loaded
422
+ // as a library for its pure functions, e.g. by tests). realpathSync guards
423
+ // against a symlinked invocation the way gate-roster.mjs's identical check does.
424
+ const isMain = (() => {
425
+ if (!process.argv[1]) return false;
426
+ try {
427
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
428
+ } catch {
429
+ return false;
430
+ }
431
+ })();
432
+ if (isMain) main(process.argv.slice(2));