@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.
- package/CHANGELOG.md +44 -27
- package/README.md +94 -29
- package/TOOLS.md +178 -16
- package/bin/adia-mcp +17 -8
- package/factory/public-surface.json +20 -0
- package/factory/resources/data-wiring.md +108 -0
- package/factory/resources/pattern-index.md +786 -0
- package/factory/resources/shell-selection.md +86 -0
- package/factory/resources/token-pairing-laws.md +68 -0
- package/factory/server.d.ts +12 -0
- package/factory/server.js +72 -0
- package/factory/tools/factory.js +277 -0
- package/factory/vendor/MANIFEST.json +29 -0
- package/factory/vendor/adia-contract-check.mjs +432 -0
- package/factory/vendor/adia-info +312 -0
- package/factory/vendor/adia-lint +396 -0
- package/factory/vendor/adia-probe.mjs +413 -0
- package/factory/vendor/adia-scaffold +801 -0
- package/factory/vendor/record-lint +278 -0
- package/gen-ui/server.d.ts +1 -1
- package/gen-ui/tools/corpus.js +1 -1
- package/gen-ui/tools/discovery.js +82 -0
- package/gen-ui/tools/feedback.js +1 -1
- package/package.json +13 -3
- package/protocol/server.d.ts +3 -2
- package/protocol/server.js +1 -1
- package/protocol/tools/protocol.js +123 -0
|
@@ -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));
|