@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,801 @@
1
+ #!/usr/bin/env python3
2
+ """adia-scaffold — lay the minimal bones of an adia-ui app (structure, not opinions).
3
+
4
+ It scaffolds the load-bearing skeleton and stops — exact package paths, versions, and the first
5
+ real screen are yours (and the a2ui MCP's), because those are what drift. Modes:
6
+
7
+ adia-scaffold spa <name> [-o DIR] [--force]
8
+ A client-rendered app: the four-axis layout (spec/ plan/ app/ skills/), a static host
9
+ document (cascade-ordered links + one registration script), and a self-booting placeholder
10
+ surface.
11
+
12
+ adia-scaffold ssr <name> --framework {next,nuxt,sveltekit,astro} [-o DIR] [--force]
13
+ The adia integration layer to drop into an EXISTING framework app: a client-boundary provider
14
+ (deferred registration) for the chosen framework + a README integration checklist.
15
+
16
+ adia-scaffold page <name> [-o DIR] [--duo] [--force]
17
+ Add a page to a surface: a page-trio (<name>.html + .contents.html + .contents.js exporting
18
+ setup) — or a page-DUO (no .contents.js) with --duo, for a purely declarative page.
19
+
20
+ adia-scaffold component <tag> [-o DIR] [--force]
21
+ Add a light-DOM component folder: components/<tag>/<tag>.{js,css} (a lint-clean skeleton —
22
+ self-booting container, two-block @scope, token-only).
23
+
24
+ adia-scaffold selftest
25
+ Scaffold each shape to a temp dir and assert the expected files exist. Exit 1 on failure.
26
+
27
+ Refuses to overwrite existing files unless --force. Stdlib only (Python 3.8+).
28
+ """
29
+ import argparse
30
+ import json
31
+ import os
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ import tarfile
36
+ import tempfile
37
+
38
+
39
+ def _slug(name):
40
+ s = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
41
+ return s or "app"
42
+
43
+
44
+ def _tag(name):
45
+ """A valid custom-element tag (must contain a hyphen)."""
46
+ s = _slug(name)
47
+ return s if "-" in s else f"{s}-app"
48
+
49
+
50
+ def _cls(tag):
51
+ return "UI" + "".join(p.capitalize() for p in tag.split("-"))
52
+
53
+
54
+ # ---- SPA templates ---------------------------------------------------------
55
+
56
+ def _spa_files(name):
57
+ tag, title = _tag(name), name.strip() or _slug(name)
58
+ cls = _cls(tag)
59
+ return {
60
+ "spec/BRIEF.md": f"# {title} — brief\n\nWhat this app is, who it's for, the one job it does.\n",
61
+ "plan/ROADMAP.md": f"# {title} — roadmap\n\n- [ ] First surface\n",
62
+ "skills/.gitkeep": "# app-specific expert skill goes here (optional)\n",
63
+ "app/shared/.gitkeep": "# cross-surface source: DataClient, loaders, mappers, images\n",
64
+ f"app/{tag}/src/index.html": _SPA_HTML.format(tag=tag, title=title),
65
+ f"app/{tag}/src/index.css": _SPA_PAGE_CSS.format(tag=tag),
66
+ f"app/{tag}/src/components/{tag}/{tag}.js": _SPA_JS.format(tag=tag, cls=cls, title=title),
67
+ f"app/{tag}/src/components/{tag}/{tag}.css": _SPA_CSS.format(tag=tag),
68
+ f"app/{tag}/vite.config.js": _VITE_CONFIG.format(tag=tag),
69
+ f"app/{tag}/package.json": _PKG_JSON.format(tag=tag, title=title),
70
+ }
71
+
72
+
73
+ _SPA_HTML = """<!doctype html>
74
+ <html lang="en" data-theme="auto">
75
+ <head>
76
+ <meta charset="utf-8" />
77
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
78
+ <title>{title}</title>
79
+
80
+ <!--
81
+ HOW TO SERVE @adia-ai/web-components — pick one:
82
+
83
+ A) Vite (recommended, npm consumer): run `npm install` then `vite` from app/{tag}/.
84
+ Vite resolves @adia-ai/* bare specifiers — CSS *and* JS — through its module graph
85
+ automatically; the importmap below is NOT needed in this mode. That's why the
86
+ foundation + barrel CSS below is imported from the registration script
87
+ (./components/{tag}/{tag}.js), never linked as a raw `/node_modules/...` URL — this
88
+ file's own vite.config.js sets `root: 'src'`, so a *static* URL at that literal path
89
+ resolves against src/ (where node_modules doesn't exist) and 404s — silently, behind
90
+ Vite's SPA-fallback middleware, which serves this index.html back with a 200.
91
+
92
+ B) Import-map (no bundler, e.g. native browser modules or a CDN):
93
+ Uncomment the importmap block below and point the paths to wherever the
94
+ @adia-ai packages are served — a local static server, esm.sh, or unpkg. There's no
95
+ module graph to carry a CSS import without a bundler, so also uncomment the matching
96
+ <link> pair below and point it at the same location.
97
+
98
+ C) Monorepo / dev server (framework contributors only):
99
+ The /packages/web-components/... paths resolve when the monorepo's own vite dev
100
+ server is running (its root serves the whole repo). This is NOT a consumer
101
+ deployment mode.
102
+
103
+ Cascade order is load-bearing (later wins):
104
+ foundation (host.css) → barrel components (index.css) → page framing → surface chrome
105
+ — the first two arrive via the registration script's CSS import, in that order.
106
+ -->
107
+
108
+ <!--
109
+ OPTION B — import-map (uncomment + adjust URLs when not using Vite):
110
+ <script type="importmap">
111
+ {{
112
+ "imports": {{
113
+ "@adia-ai/web-components": "https://esm.sh/@adia-ai/web-components",
114
+ "@adia-ai/web-components/": "https://esm.sh/@adia-ai/web-components/"
115
+ }}
116
+ }}
117
+ </script>
118
+ <link rel="stylesheet" href="https://esm.sh/@adia-ai/web-components/styles/host.css" />
119
+ <link rel="stylesheet" href="https://esm.sh/@adia-ai/web-components/styles/index.css" />
120
+ -->
121
+
122
+ <link rel="stylesheet" href="./index.css" /> <!-- page framing -->
123
+ <link rel="stylesheet" href="./components/{tag}/{tag}.css" /> <!-- the surface's chrome -->
124
+
125
+ <!-- One registration script: imports the foundation + barrel CSS (cascade order preserved
126
+ by import order), then registers every primitive (including router-ui). Bare @adia-ai/*
127
+ specifiers resolve via Vite's module graph (node_modules) or the importmap above —
128
+ never a static /node_modules/... URL. -->
129
+ <script type="module" src="./components/{tag}/{tag}.js"></script>
130
+ </head>
131
+ <body>
132
+ <{tag}></{tag}>
133
+ </body>
134
+ </html>
135
+ """
136
+
137
+ _VITE_CONFIG = """import {{ defineConfig }} from 'vite';
138
+ import {{ resolve }} from 'node:path';
139
+
140
+ // Consumer vite config for {tag}.
141
+ // Run: npm install && npx vite
142
+ //
143
+ // root: 'src' makes src/index.html the dev-server entry, and its relative CSS/JS
144
+ // paths resolve against src/ — but a *static* URL like `/node_modules/...` in an
145
+ // <link>/<script src> would resolve against src/ too (node_modules lives outside
146
+ // it) and 404 silently, behind Vite's SPA fallback. @adia-ai/* CSS and JS bare
147
+ // specifiers avoid this: Vite's MODULE resolver (not its static file server)
148
+ // walks node_modules the normal Node way — see src/index.html's own comment.
149
+ export default defineConfig({{
150
+ root: 'src',
151
+ server: {{
152
+ open: true,
153
+ }},
154
+ build: {{
155
+ outDir: '../dist',
156
+ emptyOutDir: true,
157
+ }},
158
+ }});
159
+ """
160
+
161
+ _PKG_JSON = """{{
162
+ "name": "{tag}",
163
+ "version": "0.1.0",
164
+ "description": "{title}",
165
+ "private": true,
166
+ "type": "module",
167
+ "scripts": {{
168
+ "dev": "vite",
169
+ "build": "vite build",
170
+ "preview": "vite preview"
171
+ }},
172
+ "dependencies": {{
173
+ "@adia-ai/web-components": "latest"
174
+ }},
175
+ "devDependencies": {{
176
+ "vite": "^5.0.0"
177
+ }}
178
+ }}
179
+ """
180
+
181
+ _SPA_PAGE_CSS = """/* Page framing — size + center the surface. Tokens only; never re-roll :where(html,body). */
182
+ {tag} {{
183
+ display: block;
184
+ max-inline-size: 80rem;
185
+ margin-inline: auto;
186
+ }}
187
+ """
188
+
189
+ _SPA_JS = """import '@adia-ai/web-components'; // registration barrel — defines every *-ui tag (JS side-effect; gh#1257)
190
+ import '@adia-ai/web-components/css'; // foundation + barrel CSS — keep BOTH: this import carries no JS
191
+ import {{ defineIfFree }} from '@adia-ai/web-components/core/register';
192
+ import {{ UIElement }} from '@adia-ai/web-components/core/element';
193
+
194
+ class {cls} extends UIElement {{
195
+ #booted = false;
196
+ connected() {{
197
+ if (this.#booted) return; // the callback re-fires whenever the element moves in the DOM
198
+ this.#booted = true;
199
+ // a11y baked in (gh#1252): region landmark on page-ui + a SEMANTIC heading —
200
+ // text-ui variants are presentational-only (text.yaml), so the heading is an
201
+ // authored slot child carrying role="heading" aria-level="1" explicitly.
202
+ this.innerHTML = `
203
+ <page-ui role="region" aria-label="{title}">
204
+ <header-ui>
205
+ <text-ui slot="heading" variant="title" role="heading" aria-level="1">{title}</text-ui>
206
+ </header-ui>
207
+ <section-ui>
208
+ <text-ui>Scaffolded by adia-ui-factory. Build the real surface with /screen-composition.</text-ui>
209
+ </section-ui>
210
+ </page-ui>`;
211
+ }}
212
+ }}
213
+ defineIfFree('{tag}', {cls});
214
+ export {{ {cls} }};
215
+ """
216
+
217
+ _SPA_CSS = """@scope ({tag}) {{
218
+ :where(:scope) {{ /* zero-specificity tokens — themes + consumers override cleanly */
219
+ --{tag}-gap: var(--a-space-4);
220
+ }}
221
+ :scope {{ /* base — size-agnostic: the CONSUMER owns width/height */
222
+ display: block;
223
+ padding: var(--a-space-4);
224
+ }}
225
+ }}
226
+ """
227
+
228
+
229
+ # ---- SSR templates ---------------------------------------------------------
230
+
231
+ _SSR = {
232
+ "next": {
233
+ "path": "app/providers/adia-provider.tsx",
234
+ "hook": "useEffect",
235
+ "link": "<Link> from next/link + app/**/page.tsx",
236
+ "bind": "a ref + useEffect to set non-string props",
237
+ "body": """'use client';
238
+ import { useEffect } from 'react';
239
+
240
+ // Client-boundary registration. A top-level `import '@adia-ai/web-components'` throws
241
+ // `HTMLElement is not defined` during SSR — defer it into useEffect. Mount <AdiaProvider> high.
242
+ export function AdiaProvider({ children }: { children: React.ReactNode }) {
243
+ useEffect(() => {
244
+ import('@adia-ai/web-components')
245
+ .then(() => import('@adia-ai/web-modules/shell'))
246
+ .then(() => import('@adia-ai/web-components/css'));
247
+ }, []);
248
+ return <>{children}</>;
249
+ }
250
+ """,
251
+ },
252
+ "nuxt": {
253
+ "path": "components/AdiaKit.client.vue",
254
+ "hook": "onMounted",
255
+ "link": "<NuxtLink> + pages/**.vue",
256
+ "bind": ":prop= (property binding)",
257
+ "body": """<script setup lang=\"ts\">
258
+ import { onMounted } from 'vue';
259
+
260
+ // .client.vue is Nuxt's SSR boundary; register on the client only.
261
+ onMounted(async () => {
262
+ await import('@adia-ai/web-components');
263
+ await import('@adia-ai/web-modules/shell');
264
+ await import('@adia-ai/web-components/css');
265
+ });
266
+ </script>
267
+
268
+ <template>
269
+ <slot />
270
+ </template>
271
+ """,
272
+ },
273
+ "sveltekit": {
274
+ "path": "src/lib/AdiaKit.svelte",
275
+ "hook": "onMount",
276
+ "link": "<a href> + src/routes/**/+page.svelte",
277
+ "bind": "bind:value (two-way)",
278
+ "body": """<script>
279
+ import { onMount } from 'svelte';
280
+
281
+ // onMount is SvelteKit's hydration barrier — register on the client only.
282
+ onMount(async () => {
283
+ await import('@adia-ai/web-components');
284
+ await import('@adia-ai/web-modules/shell');
285
+ await import('@adia-ai/web-components/css');
286
+ });
287
+ </script>
288
+
289
+ <slot />
290
+ """,
291
+ },
292
+ "astro": {
293
+ "path": "src/components/AdiaKit.astro",
294
+ "hook": "a client <script>",
295
+ "link": "<a href> (or <ViewTransitions/>)",
296
+ "bind": "drop to a framework island for reactive props",
297
+ "body": """---
298
+ // CSS is server-safe (no browser APIs); JS registration runs in the browser <script>.
299
+ import '@adia-ai/web-components/css';
300
+ ---
301
+ <slot />
302
+ <script>
303
+ import '@adia-ai/web-components';
304
+ import '@adia-ai/web-modules/shell';
305
+ </script>
306
+ """,
307
+ },
308
+ }
309
+
310
+
311
+ def _ssr_files(name, framework):
312
+ spec = _SSR[framework]
313
+ title = name.strip() or _slug(name)
314
+ readme = _SSR_README.format(
315
+ title=title, framework=framework, path=spec["path"],
316
+ hook=spec["hook"], link=spec["link"], bind=spec["bind"])
317
+ return {spec["path"]: spec["body"], "README.md": readme}
318
+
319
+
320
+ _SSR_README = """# {title} — adia-ui integration ({framework})
321
+
322
+ Drop `{path}` into your {framework} app and mount it high (around the layout/root).
323
+
324
+ Integration checklist (the `host-wiring` skill + its ssr-integration reference own the depth):
325
+
326
+ 1. **Registration is client-only.** This provider defers the kit import into {hook}; never import
327
+ `@adia-ai/web-components` at server module top-level (it throws `HTMLElement is not defined`).
328
+ 2. **Routing stays the framework's.** Do NOT mount `<router-ui>` — exactly one route owner. Use {link}.
329
+ 3. **Data:** fetch on the server, pass as initial props, refresh on the client.
330
+ 4. **State:** cross-cutting state (sidebar, nav, optimistic UI) goes in cookies/session — the shell
331
+ re-mounts per navigation, so component-lifetime signals are lost.
332
+ 5. **Props:** set non-string props as properties ({bind}), not stringified attributes.
333
+ 6. **CSS:** import the kit CSS once (server-safe).
334
+ """
335
+
336
+
337
+ # ---- page & component templates --------------------------------------------
338
+
339
+ _PAGE_HTML = """<!doctype html>
340
+ <html lang="en" data-theme="auto">
341
+ <head>
342
+ <meta charset="utf-8" />
343
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
344
+ <title>{title}</title>
345
+ <!-- Foundation + barrel CSS, and registration: bare @adia-ai/* specifiers resolve
346
+ through Vite's module graph (or an importmap without a bundler) — never a static
347
+ /node_modules/... URL, which 404s silently under Vite's own SPA fallback (see
348
+ spa-architecture.md for the importmap / CDN options). -->
349
+ <script type="module">
350
+ import '@adia-ai/web-components';
351
+ import '@adia-ai/web-components/css';
352
+ </script>
353
+ </head>
354
+ <body>
355
+ <main id="page-root"><p>Loading…</p></main>
356
+ <script type="module">
357
+ // page-trio loader: fetch the fragment, inject it, then run setup() if a .contents.js exists.
358
+ const root = document.getElementById('page-root');
359
+ root.innerHTML = await (await fetch('./{slug}.contents.html')).text();
360
+ {js_import} </script>
361
+ </body>
362
+ </html>
363
+ """
364
+
365
+ _PAGE_CONTENTS = """<col-ui gap="4">
366
+ <text-ui variant="heading">{title}</text-ui>
367
+ <text-ui>Page scaffold. Compose the real content with /screen-composition.</text-ui>
368
+ </col-ui>
369
+ """
370
+
371
+ _PAGE_JS = """export default function setup(root) {
372
+ // Wire behavior here: events, property-API (e.g. el.columns = [...]), data fetch, streaming.
373
+ // Register any custom components this page uses via a side-effect import.
374
+ }
375
+ """
376
+
377
+ _COMPONENT_JS = """import {{ defineIfFree }} from '@adia-ai/web-components/core/register';
378
+ import {{ UIElement }} from '@adia-ai/web-components/core/element';
379
+
380
+ class {cls} extends UIElement {{
381
+ #booted = false;
382
+ connected() {{
383
+ if (this.#booted) return; // the callback re-fires whenever the element moves in the DOM
384
+ this.#booted = true;
385
+ this.innerHTML = `<col-ui gap="2"><text-ui>{tag}</text-ui></col-ui>`;
386
+ }}
387
+ }}
388
+ defineIfFree('{tag}', {cls});
389
+ export {{ {cls} }};
390
+ """
391
+
392
+
393
+ def _page_files(name, duo):
394
+ slug, title = _slug(name), (name.strip() or _slug(name))
395
+ js_import = "" if duo else f" (await import('./{slug}.contents.js')).default?.(root);\n"
396
+ files = {
397
+ f"{slug}.html": _PAGE_HTML.format(slug=slug, title=title, js_import=js_import),
398
+ f"{slug}.contents.html": _PAGE_CONTENTS.format(title=title),
399
+ }
400
+ if not duo:
401
+ files[f"{slug}.contents.js"] = _PAGE_JS
402
+ return files, slug
403
+
404
+
405
+ def _component_files(name):
406
+ tag = _tag(name)
407
+ cls = _cls(tag)
408
+ return {
409
+ f"components/{tag}/{tag}.js": _COMPONENT_JS.format(tag=tag, cls=cls),
410
+ f"components/{tag}/{tag}.css": _SPA_CSS.format(tag=tag),
411
+ }, tag
412
+
413
+
414
+ # ---- writer ----------------------------------------------------------------
415
+
416
+ def _write(root, files, force):
417
+ written, skipped = [], []
418
+ for rel, content in files.items():
419
+ dest = os.path.join(root, rel)
420
+ if os.path.exists(dest) and not force:
421
+ skipped.append(rel)
422
+ continue
423
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
424
+ with open(dest, "w", encoding="utf-8") as f:
425
+ f.write(content)
426
+ written.append(rel)
427
+ return written, skipped
428
+
429
+
430
+ def _scaffold(mode, name, framework, outdir, force):
431
+ files = _spa_files(name) if mode == "spa" else _ssr_files(name, framework)
432
+ root = os.path.join(outdir, _slug(name))
433
+ written, skipped = _write(root, files, force)
434
+ return root, written, skipped
435
+
436
+
437
+ def _inventory(app_root):
438
+ """Score an app dir against project-shapes.md's structure rubric.
439
+
440
+ Emits the inventory scorecard (gate · pass/fail · cited path) — the
441
+ mechanizable 4 of the rubric's 5 gates. Shape-match ('the layout
442
+ matches one of the three shapes') and duplicated-cross-surface-code
443
+ stay the model's judgment; the scorecard prints them as JUDGMENT rows
444
+ so no report silently omits them. Factory-audit Wave 2 (gh#259):
445
+ the rubric was the skill's done-gate with nothing scoring it.
446
+ """
447
+ rows = [] # (gate, status, evidence)
448
+
449
+ # Gate 1 — four-axis present (spec/ + plan/ + app/; skills/ optional)
450
+ missing = [d for d in ("spec", "plan", "app") if not os.path.isdir(os.path.join(app_root, d))]
451
+ rows.append(("four-axis present", "PASS" if not missing else "FAIL",
452
+ "spec/ plan/ app/ all present" if not missing else f"missing: {', '.join(missing)}/"))
453
+
454
+ # Gates 3+4 — walk surfaces under app/
455
+ duo_bad, trio_bad, loose_components = [], [], []
456
+ app_dir = os.path.join(app_root, "app")
457
+ for dirpath, dirnames, filenames in os.walk(app_dir):
458
+ dirnames[:] = [d for d in dirnames if d not in ("node_modules", "dist", ".git")]
459
+ for fn in filenames:
460
+ path = os.path.join(dirpath, fn)
461
+ rel = os.path.relpath(path, app_root)
462
+ if fn.endswith(".contents.js"):
463
+ # trio member: must export setup
464
+ try:
465
+ src = open(path, encoding="utf-8", errors="ignore").read()
466
+ except OSError:
467
+ src = ""
468
+ if "export" not in src or "setup" not in src:
469
+ trio_bad.append(rel + " (no exported setup)")
470
+ html = path[: -len(".contents.js")] + ".html"
471
+ if not os.path.exists(html):
472
+ duo_bad.append(rel + " (orphan .contents.js — dead-DUO smell)")
473
+ if fn.endswith(".js") and not fn.endswith((".contents.js", ".config.js", ".test.js")):
474
+ parent = os.path.basename(dirpath)
475
+ grandparent = os.path.basename(os.path.dirname(dirpath))
476
+ base = fn[:-3]
477
+ # component form: components/<tag>/<tag>.js
478
+ if grandparent == "components" and parent != base:
479
+ loose_components.append(rel + f" (dir '{parent}' ≠ tag '{base}')")
480
+ elif parent == "components":
481
+ loose_components.append(rel + " (bare file directly under components/)")
482
+ rows.append(("page form correct", "PASS" if not (duo_bad or trio_bad) else "FAIL",
483
+ "; ".join(duo_bad + trio_bad) or "every .contents.js exports setup, none orphaned"))
484
+ rows.append(("components foldered", "PASS" if not loose_components else "FAIL",
485
+ "; ".join(loose_components) or "every component is components/<tag>/<tag>.js"))
486
+
487
+ rows.append(("shape declared & matched", "JUDGMENT",
488
+ "compare the tree against project-shapes.md's three shape trees — not scriptable"))
489
+ rows.append(("no duplicated cross-surface code", "JUDGMENT",
490
+ "review app/shared/ vs per-surface copies — not scriptable"))
491
+
492
+ width = max(len(r[0]) for r in rows)
493
+ print(f"[inventory] {app_root}")
494
+ fails = 0
495
+ for gate, status, evidence in rows:
496
+ mark = {"PASS": "✓", "FAIL": "✗", "JUDGMENT": "◆"}[status]
497
+ if status == "FAIL":
498
+ fails += 1
499
+ print(f" {mark} {gate.ljust(width)} {status:<8} {evidence}")
500
+ print(f"[inventory] {fails} mechanized gate(s) failing; 2 judgment gates remain the model's."
501
+ if fails else "[inventory] mechanized gates clean; 2 judgment gates remain the model's.")
502
+ return 1 if fails else 0
503
+
504
+
505
+ # ---- REQ-04: template↔exports-map drift gate (packed-tarball resolution) --
506
+ #
507
+ # Resolves every @adia-ai/web-components / @adia-ai/web-modules bare
508
+ # specifier the templates emit against the PACKED, extracted artifact —
509
+ # not the repo tree (npm pack's `files` filtering can diverge from it) and
510
+ # not the public registry (gh#1120's acceptance needs the same-cut
511
+ # artifact; lockstep releases make same-cut resolution the binding check).
512
+ # `scripts/verify/exports-wildcard-resolution.mjs` (gh#296) is the existing
513
+ # precedent for this exact style of live-resolution check; this is its
514
+ # manual-walk equivalent in Python, scoped to what the scaffold emits.
515
+
516
+ _SPECIFIER_RE = re.compile(
517
+ r"""(?:\bimport\s*\(\s*|\bimport\s+|\bfrom\s+)['"](@adia-ai/(?:web-components|web-modules)(?:/[^'"]*)?)['"]"""
518
+ )
519
+
520
+
521
+ def _extract_specifiers(text):
522
+ """Every @adia-ai/web-components|web-modules bare-specifier import in a
523
+ generated file's source text (static import, side-effect import, or
524
+ dynamic import() — the three forms the ssr templates use)."""
525
+ return {m.group(1) for m in _SPECIFIER_RE.finditer(text)}
526
+
527
+
528
+ def _emitted_specifiers_by_mode():
529
+ """Every specifier each scaffold mode's templates emit, keyed by a
530
+ label for the FAIL line — spa, all four ssr frameworks, page, and
531
+ component (REQ-04's named blast radius)."""
532
+ by_mode = {}
533
+ by_mode["spa"] = set().union(*[_extract_specifiers(c) for c in _spa_files("Demo App").values()])
534
+ for fw in sorted(_SSR):
535
+ files = _ssr_files(f"demo-{fw}", fw)
536
+ by_mode[f"ssr/{fw}"] = set().union(*[_extract_specifiers(c) for c in files.values()])
537
+ page_files, _ = _page_files("Live View", False)
538
+ by_mode["page"] = set().union(*[_extract_specifiers(c) for c in page_files.values()]) if page_files else set()
539
+ component_files, _ = _component_files("data-badge")
540
+ by_mode["component"] = set().union(*[_extract_specifiers(c) for c in component_files.values()])
541
+ return by_mode
542
+
543
+
544
+ def _split_specifier(spec):
545
+ """'@adia-ai/web-components/core/register' -> ('@adia-ai/web-components', 'core/register').
546
+ Bare '@adia-ai/web-components' -> (pkg, '')."""
547
+ for pkg in ("@adia-ai/web-components", "@adia-ai/web-modules"):
548
+ if spec == pkg:
549
+ return pkg, ""
550
+ if spec.startswith(pkg + "/"):
551
+ return pkg, spec[len(pkg) + 1:]
552
+ return None, None
553
+
554
+
555
+ def _pick_export_target(target):
556
+ """Pick the 'import' condition (falling back to 'default') from an
557
+ exports-map value — either a bare string or a conditions dict."""
558
+ if isinstance(target, str):
559
+ return target
560
+ if isinstance(target, dict):
561
+ for cond in ("import", "default"):
562
+ v = target.get(cond)
563
+ if isinstance(v, str):
564
+ return v
565
+ return None
566
+
567
+
568
+ def _resolve_export(pkg_json, subpath):
569
+ """Manual walk of pkg_json's exports map (exact key, else the single-*
570
+ wildcard key with the longest matching prefix) + wildcard substitution.
571
+ Returns the resolved relative path (still '*'-free), or None if no key
572
+ in the map matches this subpath at all."""
573
+ exports = pkg_json.get("exports")
574
+ if not isinstance(exports, dict):
575
+ return None
576
+ key = "." if subpath == "" else "./" + subpath
577
+ if key in exports:
578
+ return _pick_export_target(exports[key])
579
+ best = None
580
+ for k, v in exports.items():
581
+ if k.count("*") != 1:
582
+ continue
583
+ prefix, _, suffix = k.partition("*")
584
+ if key.startswith(prefix) and key.endswith(suffix) and len(key) >= len(prefix) + len(suffix):
585
+ if best is None or len(prefix) > len(best[0]):
586
+ captured = key[len(prefix):len(key) - len(suffix)] if suffix else key[len(prefix):]
587
+ best = (prefix, captured, v)
588
+ if best is None:
589
+ return None
590
+ _, captured, target = best
591
+ rel = _pick_export_target(target)
592
+ return rel.replace("*", captured) if rel else None
593
+
594
+
595
+ def _pack_and_extract(pkg_dir, tmp):
596
+ """npm pack pkg_dir for real (no network — local tarballing of the
597
+ `files` allowlist) and extract the tarball, so resolution runs against
598
+ what actually SHIPS, not the repo tree."""
599
+ out = subprocess.run(
600
+ ["npm", "pack", "--silent", "--pack-destination", tmp],
601
+ cwd=pkg_dir, capture_output=True, text=True,
602
+ )
603
+ if out.returncode != 0:
604
+ raise RuntimeError(f"npm pack failed in {pkg_dir}: {(out.stderr or out.stdout).strip()}")
605
+ tgz_name = out.stdout.strip().splitlines()[-1]
606
+ extract_dir = os.path.join(tmp, "extracted-" + tgz_name)
607
+ os.makedirs(extract_dir, exist_ok=True)
608
+ with tarfile.open(os.path.join(tmp, tgz_name)) as tf:
609
+ tf.extractall(extract_dir) # noqa: S202 — trusted, just-packed local tarball
610
+ root = os.path.join(extract_dir, "package")
611
+ with open(os.path.join(root, "package.json"), encoding="utf-8") as f:
612
+ pkg_json = json.load(f)
613
+ return root, pkg_json, tgz_name
614
+
615
+
616
+ def _selftest_exports_resolution():
617
+ """REQ-04 — every specifier the templates emit must resolve, under the
618
+ PACKED artifact's real exports map, to a file that exists in the
619
+ extracted tarball. Packs BOTH @adia-ai/web-components and
620
+ @adia-ai/web-modules at this repo's current lockstep version."""
621
+ script_dir = os.path.dirname(os.path.abspath(__file__))
622
+ repo_root = os.path.abspath(os.path.join(script_dir, "..", "..", "..", ".."))
623
+ pkg_dirs = {
624
+ "@adia-ai/web-components": os.path.join(repo_root, "packages", "web-components"),
625
+ "@adia-ai/web-modules": os.path.join(repo_root, "packages", "web-modules"),
626
+ }
627
+ ok = True
628
+ by_mode = _emitted_specifiers_by_mode()
629
+ with tempfile.TemporaryDirectory() as tmp:
630
+ packed = {}
631
+ for pkg_name, pkg_dir in pkg_dirs.items():
632
+ try:
633
+ packed[pkg_name] = _pack_and_extract(pkg_dir, tmp)
634
+ except Exception as e:
635
+ print(f"selftest: FAIL — could not npm pack {pkg_name}: {e}", file=sys.stderr)
636
+ ok = False
637
+ for mode, specs in by_mode.items():
638
+ for spec in sorted(specs):
639
+ pkg_name, subpath = _split_specifier(spec)
640
+ if pkg_name is None or pkg_name not in packed:
641
+ continue
642
+ root, pkg_json, tgz_name = packed[pkg_name]
643
+ version = pkg_json.get("version", "?")
644
+ rel = _resolve_export(pkg_json, subpath)
645
+ if rel is None:
646
+ print(f"selftest: FAIL — {mode} emits '{spec}'; "
647
+ f"not resolvable in {pkg_name}-{version}.tgz exports", file=sys.stderr)
648
+ ok = False
649
+ continue
650
+ if not os.path.exists(os.path.join(root, rel.lstrip("./"))):
651
+ print(f"selftest: FAIL — {mode} emits '{spec}'; resolves to "
652
+ f"'{rel}' which does not exist in {pkg_name}-{version}.tgz", file=sys.stderr)
653
+ ok = False
654
+ return ok
655
+
656
+
657
+ def _selftest_hint_drift():
658
+ """REQ-05 — the /adia-scaffold command doc's argument-hint must equal
659
+ the script's OWN argparse mode choices (via _build_parser(), not a
660
+ third hardcoded copy), or the boot journey's first documented command
661
+ can silently name a mode the script rejects (gh#1121's repro:
662
+ `adia-scaffold app my-app` — 'app' was never a real mode)."""
663
+ _, sub = _build_parser()
664
+ choices = set(sub.choices.keys())
665
+ script_dir = os.path.dirname(os.path.abspath(__file__))
666
+ doc_path = os.path.join(script_dir, "..", "commands", "project-scaffolding.md")
667
+ try:
668
+ with open(doc_path, encoding="utf-8") as f:
669
+ text = f.read()
670
+ except OSError as e:
671
+ print(f"selftest: FAIL — cannot read {doc_path}: {e}", file=sys.stderr)
672
+ return False
673
+ m = re.search(r'argument-hint:\s*"(\[[^\]]*\])', text)
674
+ if not m:
675
+ print(f"selftest: FAIL — {doc_path} has no argument-hint mode-bracket", file=sys.stderr)
676
+ return False
677
+ hinted = set(m.group(1).strip("[]").split("|"))
678
+ if hinted != choices:
679
+ print(f"selftest: FAIL — argument-hint modes {sorted(hinted)} != "
680
+ f"argparse choices {sorted(choices)} ({doc_path})", file=sys.stderr)
681
+ return False
682
+ return True
683
+
684
+
685
+ def _selftest():
686
+ ok = True
687
+ with tempfile.TemporaryDirectory() as tmp:
688
+ _, w, _ = _scaffold("spa", "Demo App", None, tmp, False)
689
+ need = [f for f in w if f.endswith(("index.html", "demo-app.js", "demo-app.css"))]
690
+ missing = [n for n in ("index.html", "vite.config.js", "package.json") if not any(f.endswith(n) for f in w)]
691
+ if missing or len(w) < 8:
692
+ print(f"selftest: SPA scaffold incomplete (missing: {missing}, got {len(w)} files)", file=sys.stderr); ok = False
693
+ for fw in ("next", "nuxt", "sveltekit", "astro"):
694
+ _, w2, _ = _scaffold("ssr", f"demo-{fw}", fw, tmp, False)
695
+ if not any("README.md" == f for f in w2) or len(w2) != 2:
696
+ print(f"selftest: SSR/{fw} scaffold incomplete: {w2}", file=sys.stderr); ok = False
697
+ pf, _ = _page_files("Live View", False)
698
+ wpt, _ = _write(os.path.join(tmp, "pg"), pf, False)
699
+ if not (any(f.endswith("live-view.html") for f in wpt) and any(f.endswith("live-view.contents.js") for f in wpt)):
700
+ print("selftest: page-trio incomplete", file=sys.stderr); ok = False
701
+ pfd, _ = _page_files("Static Note", True)
702
+ wpd, _ = _write(os.path.join(tmp, "pgd"), pfd, False)
703
+ if any(f.endswith(".contents.js") for f in wpd) or len(wpd) != 2:
704
+ print("selftest: page-DUO should have no .contents.js", file=sys.stderr); ok = False
705
+ cf, _ = _component_files("data-badge")
706
+ wc, _ = _write(os.path.join(tmp, "cmp"), cf, False)
707
+ if not any(f.endswith(os.path.join("data-badge", "data-badge.js")) for f in wc):
708
+ print("selftest: component incomplete", file=sys.stderr); ok = False
709
+ # inventory: a fresh scaffold's mechanized gates must pass; a broken
710
+ # tree (bare component file, setup-less .contents.js) must fail.
711
+ root, _, _ = _scaffold("spa", "Inv App", None, os.path.join(tmp, "inv"), False)
712
+ import contextlib, io
713
+ buf = io.StringIO()
714
+ with contextlib.redirect_stdout(buf):
715
+ rc_good = _inventory(root)
716
+ if rc_good != 0:
717
+ print(f"selftest: inventory flagged a fresh scaffold:\n{buf.getvalue()}", file=sys.stderr); ok = False
718
+ bad = os.path.join(tmp, "invbad")
719
+ os.makedirs(os.path.join(bad, "app", "components"), exist_ok=True)
720
+ open(os.path.join(bad, "app", "components", "loose.js"), "w").write("// bare\n")
721
+ open(os.path.join(bad, "app", "broken.contents.js"), "w").write("// no setup here\n")
722
+ with contextlib.redirect_stdout(io.StringIO()):
723
+ rc_bad = _inventory(bad)
724
+ if rc_bad == 0:
725
+ print("selftest: inventory passed a broken tree", file=sys.stderr); ok = False
726
+ # REQ-05 — command-doc argument-hint must equal the real argparse modes.
727
+ if not _selftest_hint_drift():
728
+ ok = False
729
+ # REQ-04 — every emitted specifier must resolve against BOTH packed,
730
+ # extracted tarballs' real exports maps (gh#1120's blast radius).
731
+ if not _selftest_exports_resolution():
732
+ ok = False
733
+ print("selftest: PASS" if ok else "selftest: FAIL")
734
+ return 0 if ok else 1
735
+
736
+
737
+ def _build_parser():
738
+ """The one parser both main() and the selftest's hint-drift check
739
+ (REQ-05) use — `sub.choices` is the single source of the real mode
740
+ list, so the drift check can never itself drift from what main()
741
+ actually accepts."""
742
+ p = argparse.ArgumentParser(prog="adia-scaffold", add_help=True,
743
+ description="Lay the minimal bones of an adia-ui app.")
744
+ sub = p.add_subparsers(dest="mode", required=True)
745
+ for m in ("spa", "ssr"):
746
+ sp = sub.add_parser(m)
747
+ sp.add_argument("name")
748
+ sp.add_argument("-o", "--out", default=".")
749
+ sp.add_argument("--force", action="store_true")
750
+ if m == "ssr":
751
+ sp.add_argument("--framework", required=True,
752
+ choices=sorted(_SSR.keys()))
753
+ pg = sub.add_parser("page")
754
+ pg.add_argument("name")
755
+ pg.add_argument("-o", "--out", default=".")
756
+ pg.add_argument("--duo", action="store_true")
757
+ pg.add_argument("--force", action="store_true")
758
+ cm = sub.add_parser("component")
759
+ cm.add_argument("name")
760
+ cm.add_argument("-o", "--out", default=".")
761
+ cm.add_argument("--force", action="store_true")
762
+ inv = sub.add_parser("inventory", help="score an app dir against the structure rubric")
763
+ inv.add_argument("app_root", nargs="?", default=".")
764
+ sub.add_parser("selftest")
765
+ return p, sub
766
+
767
+
768
+ def main(argv):
769
+ p, _sub = _build_parser()
770
+ args = p.parse_args(argv)
771
+
772
+ if args.mode == "selftest":
773
+ return _selftest()
774
+
775
+ if args.mode == "inventory":
776
+ return _inventory(args.app_root)
777
+
778
+ if args.mode == "page":
779
+ files, _ = _page_files(args.name, args.duo)
780
+ root, label = args.out, ("PAGE/DUO" if args.duo else "PAGE")
781
+ written, skipped = _write(root, files, args.force)
782
+ elif args.mode == "component":
783
+ files, _ = _component_files(args.name)
784
+ root, label = args.out, "COMPONENT"
785
+ written, skipped = _write(root, files, args.force)
786
+ else:
787
+ framework = getattr(args, "framework", None)
788
+ root, written, skipped = _scaffold(args.mode, args.name, framework, args.out, args.force)
789
+ label = args.mode.upper() + (f"/{framework}" if framework else "")
790
+ print(f"adia-scaffold [{label}] → {root}")
791
+ for f in written:
792
+ print(f" + {f}")
793
+ for f in skipped:
794
+ print(f" · {f} (exists; --force to overwrite)")
795
+ if not written:
796
+ print(" (nothing written)")
797
+ return 0
798
+
799
+
800
+ if __name__ == "__main__":
801
+ sys.exit(main(sys.argv[1:]))