@elytracms/cli 0.0.85 → 0.0.87

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/cli",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.85",
4
+ "version": "0.0.87",
5
5
  "description": "The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "node": ">=22.18.0"
18
18
  },
19
19
  "dependencies": {
20
- "@elytracms/core": "^0.0.85"
20
+ "@elytracms/core": "^0.0.87"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { join } from 'node:path'
3
3
  import { stdout } from 'node:process'
4
- import { runDoctor } from './doctor.js'
4
+ import { resolveInstalledEngineVersion, runDoctor } from './doctor.js'
5
5
  import { runPreviews } from './previews.js'
6
6
  import { inspectProject, resolveStudioEnv } from './project.js'
7
7
  import { ensureAuthKeys } from './provision.js'
@@ -426,7 +426,17 @@ export async function runInit(project, { log: emit = log, fetchImpl, exec, provi
426
426
  emit('')
427
427
  emit('3. Verify —')
428
428
  const env = resolveStudioEnv(project.root)
429
- const code = await runDoctor({ env, fetchImpl, log: (l) => emit(` ${l}`) })
429
+ // EC-552: with the studio dir known, the verify step includes the version
430
+ // handshake — right after a provision is exactly when a stale-functions state
431
+ // should be loud. Resolution happens HERE (the entry), keeping runDoctor injected.
432
+ const verifyStudioDir = project.apps.studio ? join(project.root, 'studio') : undefined
433
+ const code = await runDoctor({
434
+ env,
435
+ fetchImpl,
436
+ log: (l) => emit(` ${l}`),
437
+ studioDir: verifyStudioDir,
438
+ installedEngineVersion: resolveInstalledEngineVersion(verifyStudioDir),
439
+ })
430
440
 
431
441
  // 4. Ready checklist.
432
442
  emit('')
@@ -470,6 +480,7 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
470
480
  return 1
471
481
  }
472
482
  const studioEnv = resolveStudioEnv(project.root, env)
483
+ const doctorStudioDir = project.apps.studio ? join(project.root, 'studio') : undefined
473
484
  return runDoctor({
474
485
  env: studioEnv,
475
486
  fetchImpl,
@@ -477,7 +488,10 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
477
488
  url: flagValue(argv, '--url'),
478
489
  // EC-399: where `convex env list` runs (name enumeration — presence only),
479
490
  // and whether the AI provider-key expectation applies.
480
- studioDir: project.apps.studio ? join(project.root, 'studio') : undefined,
491
+ studioDir: doctorStudioDir,
492
+ // EC-552: the installed engine version — resolved at the entry so runDoctor
493
+ // stays pure/injected; unresolvable (deps not installed) → honest `unknown`.
494
+ installedEngineVersion: resolveInstalledEngineVersion(doctorStudioDir),
481
495
  // EC-469: where the project's `cms/` route set is read from (code, not backend).
482
496
  root: project.root,
483
497
  aiRouteConfigured: project.hasAiRoute,
package/src/doctor.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
1
4
  import {
2
5
  REVALIDATE_ENV_ENDPOINT,
3
6
  REVALIDATE_ENV_SECRET,
@@ -5,6 +8,7 @@ import {
5
8
  checkDeploymentReady,
6
9
  expectedDeploymentEnv,
7
10
  probeAuthSigningKeys,
11
+ probeDeployedFunctionsVersion,
8
12
  probeRevalidateEndpoint,
9
13
  } from '@elytracms/core/export-sync/deployment-ready'
10
14
  import { executeWorkspaceConfig } from './config-harness.js'
@@ -68,16 +72,27 @@ export function resolveDoctorTarget(env = {}, { url } = {}) {
68
72
  // --- Probes (node) -----------------------------------------------------------
69
73
 
70
74
  /**
71
- * Reachability probe: any HTTP response means the deployment answered; a thrown fetch
72
- * (DNS/connection) means it did not. (The studio uses `adapter.status()`; the CLI has
73
- * no convex client, so a plain GET to the deployment's client URL is the node analogue.)
75
+ * Deployment probe (EC-542 + EC-552): ONE call to the public query API yields BOTH the
76
+ * reachability fact and the version-handshake probe. The studio's probe was always a
77
+ * real query (`adapter.status()` → `members:mine`); the CLI previously used a bare GET
78
+ * and called ANY HTTP response "answered" — measured false-positive: Convex's wildcard
79
+ * `*.convex.cloud` 404s (empty body) for deployments that DO NOT EXIST, so every
80
+ * nonexistent deployment passed. Reachability now means "answered AS a Convex
81
+ * deployment": a Convex envelope — success, missing-function, or any other Convex
82
+ * error — proves a deployment; a non-Convex answer (the empty 404) or a thrown fetch
83
+ * (DNS/connection) fails it, each with its own honest detail so "nothing there" and
84
+ * "there but not deployed" stay distinguishable (the latter is reachable=true + the
85
+ * `functions-version`/JWKS-404 checks telling the deploy story).
74
86
  */
75
- export async function probeReachable(url, fetchImpl = fetch) {
76
- try {
77
- const res = await fetchImpl(url, { method: 'GET' })
78
- return { available: true, detail: `HTTP ${res.status}` }
79
- } catch (error) {
80
- return { available: false, detail: error instanceof Error ? error.message : String(error) }
87
+ export async function probeDeployment(url, fetchImpl = fetch) {
88
+ const versionProbe = await probeDeployedFunctionsVersion(url, fetchImpl)
89
+ const answered =
90
+ versionProbe.status === 'answered' ||
91
+ versionProbe.status === 'missing' ||
92
+ versionProbe.answeredAsConvex === true
93
+ return {
94
+ reachable: answered ? { available: true } : { available: false, detail: versionProbe.detail },
95
+ versionProbe,
81
96
  }
82
97
  }
83
98
 
@@ -86,24 +101,54 @@ export async function probeReachable(url, fetchImpl = fetch) {
86
101
  * user facts are intentionally omitted (no auth session) → `unknown`; the app facts
87
102
  * (config/collections) are gathered separately via the consumer-Vite harness when it
88
103
  * is available (EC-495, {@link configFactsFromOutcome}).
104
+ *
105
+ * EC-542/EC-552: reachability and the version probe are ONE network call
106
+ * ({@link probeDeployment}); the `functions-version` fact is emitted only when the
107
+ * caller supplies `installedEngineVersion` (otherwise the probe outcome still decides
108
+ * reachability but the handshake stays an honest `unknown`).
89
109
  */
90
- export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
110
+ export async function gatherFacts(target, { fetchImpl = fetch, installedEngineVersion } = {}) {
91
111
  if (target.kind === 'playground') return { backend: { kind: 'playground' } }
92
112
  if (target.kind === 'unconfigured') return { backend: { kind: 'unconfigured', detail: target.detail } }
93
113
 
94
- const reachable = await probeReachable(target.url, fetchImpl)
114
+ const { reachable, versionProbe } = await probeDeployment(target.url, fetchImpl)
95
115
  const siteUrl = backendSiteUrl(target.url, target.siteUrl)
96
116
  const authKeys = await probeAuthSigningKeys(siteUrl, fetchImpl)
97
117
  return {
98
118
  backend: { kind: 'convex', url: target.url },
99
119
  reachable,
100
120
  authKeys,
121
+ ...(installedEngineVersion
122
+ ? { functionsVersion: { probe: versionProbe, expected: installedEngineVersion } }
123
+ : {}),
101
124
  // A configured URL means a deployment is wired; leave `provisioned` unset (→ ok).
102
125
  // session omitted → the user checks surface as `unknown`; config/collections are
103
126
  // merged in by the caller from the EC-495 harness outcome (or stay omitted).
104
127
  }
105
128
  }
106
129
 
130
+ /**
131
+ * Resolve the engine version of the `@elytracms/studio` package INSTALLED in the
132
+ * consumer's studio app (EC-552) — the `expected` half of the version handshake.
133
+ * Deliberately resolved through the `convex/delivery` export and its adjacent
134
+ * `package.json`: that is the very source file the consumer's `convex deploy`
135
+ * bundles, so the version read here is by construction the version a redeploy
136
+ * would stamp. (The consumer's own `package.json` only carries a RANGE.)
137
+ * `undefined` when the deps aren't installed → the fact is omitted → the check
138
+ * reports an honest `unknown`, matching the config checks' opportunistic contract.
139
+ */
140
+ export function resolveInstalledEngineVersion(studioDir) {
141
+ if (!studioDir) return undefined
142
+ try {
143
+ const req = createRequire(join(studioDir, 'package.json'))
144
+ const deliverySource = req.resolve('@elytracms/studio/convex/delivery')
145
+ const pkg = JSON.parse(readFileSync(join(dirname(deliverySource), '..', 'package.json'), 'utf8'))
146
+ return typeof pkg.version === 'string' ? pkg.version : undefined
147
+ } catch {
148
+ return undefined
149
+ }
150
+ }
151
+
107
152
  // --- Config facts via the consumer-Vite harness (EC-495) -------------------------------
108
153
 
109
154
  /**
@@ -389,6 +434,13 @@ export function formatReport(report) {
389
434
  * works-without-node_modules contract is untouched. A resolved config also hands the
390
435
  * route checks the same collections the studio actually mounts (superseding the
391
436
  * `cms/index.ts` re-export). `configOutcome` is injectable for tests.
437
+ *
438
+ * EC-552: when the caller supplies `installedEngineVersion` (the CLI entry resolves it
439
+ * from the consumer's installed `@elytracms/studio` via {@link
440
+ * resolveInstalledEngineVersion}), the deployed functions are probed for their stamped
441
+ * version and the `functions-version` check gets a real answer; otherwise the fact is
442
+ * omitted → an honest `unknown`. Resolution stays at the entry point so this function
443
+ * remains pure/injected like every other gatherer here.
392
444
  */
393
445
  export async function runDoctor({
394
446
  env = {},
@@ -402,6 +454,7 @@ export async function runDoctor({
402
454
  previewsArtifacts,
403
455
  routeFacts,
404
456
  configOutcome,
457
+ installedEngineVersion,
405
458
  } = {}) {
406
459
  const target = resolveDoctorTarget(env, { url })
407
460
 
@@ -417,7 +470,11 @@ export async function runDoctor({
417
470
  log('No backend configured.')
418
471
  }
419
472
 
420
- const facts = await gatherFacts(target, { fetchImpl })
473
+ // EC-552: the version handshake rides the same probe as reachability; it becomes a
474
+ // real check only when the caller supplied the installed engine version (the CLI
475
+ // entry resolves it via `resolveInstalledEngineVersion`; runDoctor itself never
476
+ // touches the filesystem for it, staying pure/injected like every gatherer here).
477
+ const facts = await gatherFacts(target, { fetchImpl, installedEngineVersion })
421
478
  if (target.kind === 'convex') {
422
479
  facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
423
480
  // EC-421: default-on signed no-op ping whenever the revalidate pair is present.
package/src/previews.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
- import { join } from 'node:path'
3
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
- import { pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } from './config-harness.js'
5
+ import { CONFIG_FILE, pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } from './config-harness.js'
6
6
 
7
7
  /**
8
8
  * `elytra previews` (EC-440) — build-time fixture-preview generation.
@@ -18,14 +18,19 @@ import { pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } fro
18
18
  * the blocks-panel tile);
19
19
  * - a previews manifest to `studio/elytra-previews.json` (importable next to
20
20
  * `studio.config.tsx`) carrying per entry: componentId, fixture, URL, and
21
- * an input hash (manifest prop/slot shape + fixture props) — no
22
- * timestamps, so unchanged inputs re-serialize to the same bytes.
21
+ * an input hash (manifest prop/slot shape + fixture props) — plus ONE
22
+ * manifest-level `renderDeps` list (EC-600): a content hash per project
23
+ * source file the config imports and per stylesheet in the theme's
24
+ * `@import` chain. No timestamps, so unchanged inputs re-serialize to the
25
+ * same bytes.
23
26
  *
24
- * Idempotence: an entry whose input hash matches the existing manifest AND
25
- * whose image file exists is skipped entirely (screenshots are not guaranteed
26
- * byte-stable, so "skip unchanged" is the mechanism, not re-render-and-
27
- * compare); the manifest is only written when its bytes change. A re-run with
28
- * unchanged inputs therefore rewrites nothing.
27
+ * Idempotence: an entry is skipped entirely when its input hash matches the
28
+ * existing manifest, its image file exists, AND the render dependencies are
29
+ * unchanged (screenshots are not guaranteed byte-stable, so "skip unchanged" is
30
+ * the mechanism, not re-render-and-compare). A changed render dependency
31
+ * re-renders every entry — nothing can say which pixels it touched. The
32
+ * manifest is only written when its bytes change. A re-run with unchanged
33
+ * inputs therefore rewrites nothing.
29
34
  *
30
35
  * Harness: the shared consumer-Vite boot (`config-harness.js`, EC-495 — one
31
36
  * mechanism for this command and `elytra doctor`'s config check) plus a
@@ -51,12 +56,17 @@ export const PREVIEWS_MANIFEST_FILE = 'elytra-previews.json'
51
56
  /** Root-relative URL prefix the manifest entries carry. */
52
57
  export const PREVIEWS_URL_PREFIX = '/elytra-previews/'
53
58
  /**
54
- * Manifest format version (mirrors `@elytracms/studio`'s PREVIEWS_MANIFEST_VERSION).
59
+ * Manifest format version — THE declaration (EC-514: the studio's mirror was
60
+ * removed; its reader is version-agnostic, and this CLI is the only writer and the
61
+ * only reader of the number). Bump on incompatible schema/hash changes.
55
62
  * `2` (EC-502): the entry hash no longer includes the engine version, so v1 hashes
56
63
  * are not comparable with v2 ones — a version mismatch is reported as itself
57
64
  * ("regenerate"), never as N bogus `stale` entries.
65
+ * `3` (EC-600): the manifest carries `renderDeps` (see {@link renderDepsOf}); a v2
66
+ * manifest has none, so its freshness cannot be judged — reported once as
67
+ * `manifest-version`, and one regeneration renders everything against the deps.
58
68
  */
59
- export const PREVIEWS_MANIFEST_VERSION = 2
69
+ export const PREVIEWS_MANIFEST_VERSION = 3
60
70
 
61
71
  /** Capture geometry: 1280×800 CSS pixels (a desktop render), shot at 0.5 → 640×400 (16/10). */
62
72
  export const CAPTURE = { width: 1280, height: 800, scale: 0.5 }
@@ -107,11 +117,22 @@ function sortValue(value) {
107
117
  }
108
118
 
109
119
  /**
110
- * The staleness input hash: sha-256 over the stable-serialized render inputs —
111
- * the manifest's prop/slot shape + the fixture's own props/slotChildren (from
112
- * the studio's `previewEntriesForConfig`). Implementation and theme drift are
113
- * deliberately NOT hashed (the ratified EC-440 split: those are caught by
114
- * regeneration runs, e.g. in the consumer's build).
120
+ * The PER-ENTRY staleness hash: sha-256 over the stable-serialized DECLARED
121
+ * inputs — the manifest's prop/slot shape + the fixture's own props/slotChildren
122
+ * (from the studio's `previewEntriesForConfig`). A change here is local to one
123
+ * entry: that entry re-renders and `--check` names exactly it.
124
+ *
125
+ * The component implementation and the theme are NOT in this hash — they have
126
+ * no per-entry identity (a config arrives evaluated; the theme is one stylesheet
127
+ * for everything). Until EC-600 this comment claimed regeneration caught drift
128
+ * there. It did not: regeneration rendered only `!fresh` jobs and `fresh` was
129
+ * decided by this hash alone, so a changed implementation or theme kept BOTH the
130
+ * check green and the images stale (measured on apps/demo 2026-10-09: an
131
+ * appended export in `hero.tsx` and a new `:root` rule in `theme.css` each left
132
+ * `--check` at exit 0; a fixture edit flipped it to `stale`). They are covered
133
+ * now by the manifest-level `renderDeps` ({@link renderDepsOf}): any change there
134
+ * re-renders everything and reports ONE `render-deps` problem — never N
135
+ * per-entry `stale` lines (the EC-502 lesson below).
115
136
  *
116
137
  * **EC-502 — the engine version was removed from this hash.** It made every
117
138
  * release invalidate every committed preview in every repo, so `elytra previews
@@ -133,11 +154,141 @@ export function previewEntryHash(hashInput) {
133
154
  return createHash('sha256').update(stableStringify({ input: hashInput }), 'utf8').digest('hex')
134
155
  }
135
156
 
157
+ // --- Render dependencies (EC-600) ------------------------------------------------
158
+
159
+ /**
160
+ * Root-relative POSIX path of an absolute file under `root` and not under a
161
+ * `node_modules` directory; `null` otherwise (virtual ids, installed packages,
162
+ * engine sources that resolve outside the project).
163
+ */
164
+ function projectPath(root, file) {
165
+ if (typeof file !== 'string' || !isAbsolute(file)) return null
166
+ const rel = relative(root, file)
167
+ if (rel.length === 0 || rel.startsWith('..') || isAbsolute(rel)) return null
168
+ const posix = rel.split(sep).join('/')
169
+ return posix.split('/').includes('node_modules') ? null : posix
170
+ }
171
+
172
+ /**
173
+ * The project source files a Vite module graph loaded: every module with a real
174
+ * file under `root` that is not under `node_modules` — exactly what
175
+ * `elytra.config.ts` transitively imports, wherever the consumer keeps it
176
+ * (components, their helpers, imported assets, `cms/`). No directory
177
+ * convention is assumed (AGENTS.md §4: capability, never structure). Virtual
178
+ * modules have no file; installed packages sit under node_modules (in this
179
+ * monorepo they resolve to workspace packages outside the project) — the
180
+ * engine category the manifest's `engine` field and the engine-drift warning
181
+ * already cover. Absolute paths, sorted, unique.
182
+ */
183
+ export function projectSourceFiles(moduleFiles, root) {
184
+ const out = new Set()
185
+ for (const file of moduleFiles) if (projectPath(root, file) !== null) out.add(file)
186
+ return [...out].sort()
187
+ }
188
+
189
+ const CSS_COMMENT = /\/\*[\s\S]*?\*\//g
190
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?(?:(['"])([^'"]+)\1|([^'")\s;]+))/g
191
+
192
+ /**
193
+ * The theme entry plus every stylesheet it reaches through RELATIVE `@import`s,
194
+ * recursively (each file once; an extension-less `./tokens` resolves to
195
+ * `./tokens.css` the way postcss-import does). Bare specifiers (`tailwindcss`,
196
+ * a package's CSS) are installed code and are not followed; a relative target
197
+ * that does not exist is skipped here — the importing file, whose text names
198
+ * it, is hashed regardless, and the render itself fails loudly on it. Absolute
199
+ * paths, in import order.
200
+ */
201
+ export function themeImportChain(entryPath, { readFile = readFileSync, exists = existsSync } = {}) {
202
+ const chain = []
203
+ const seen = new Set()
204
+ const visit = (file) => {
205
+ if (seen.has(file)) return
206
+ seen.add(file)
207
+ if (!exists(file)) return
208
+ chain.push(file)
209
+ const text = String(readFile(file)).replace(CSS_COMMENT, '')
210
+ for (const match of text.matchAll(CSS_IMPORT)) {
211
+ const spec = match[2] ?? match[3]
212
+ if (!spec || spec.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(spec)) continue
213
+ const target = join(dirname(file), spec)
214
+ if (exists(target)) visit(target)
215
+ else if (spec.startsWith('.') && exists(`${target}.css`)) visit(`${target}.css`)
216
+ }
217
+ }
218
+ visit(entryPath)
219
+ return chain
220
+ }
221
+
222
+ /**
223
+ * Content-hash the render dependencies — the manifest's `renderDeps`:
224
+ * `[{ path, hash }]`, root-relative POSIX paths, sha-256 of the bytes, sorted by
225
+ * path, deduped. Byte-deterministic for unchanged files.
226
+ */
227
+ export function renderDepsOf(root, files, { readFile = readFileSync } = {}) {
228
+ const byPath = new Map()
229
+ for (const file of files) {
230
+ const path = relative(root, file).split(sep).join('/')
231
+ if (byPath.has(path)) continue
232
+ byPath.set(path, createHash('sha256').update(readFile(file)).digest('hex'))
233
+ }
234
+ return [...byPath.entries()]
235
+ .map(([path, hash]) => ({ path, hash }))
236
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
237
+ }
238
+
239
+ /**
240
+ * The paths whose hash differs / are new / are gone between two `renderDeps`
241
+ * lists (each sorted). A missing list (a pre-EC-600 manifest) counts as empty,
242
+ * so every current dependency shows up as `added`.
243
+ */
244
+ export function diffRenderDeps(prior, current) {
245
+ const before = new Map((prior ?? []).map((d) => [d.path, d.hash]))
246
+ const after = new Map((current ?? []).map((d) => [d.path, d.hash]))
247
+ const changed = []
248
+ const added = []
249
+ const removed = []
250
+ for (const [path, hash] of after) {
251
+ if (!before.has(path)) added.push(path)
252
+ else if (before.get(path) !== hash) changed.push(path)
253
+ }
254
+ for (const path of before.keys()) if (!after.has(path)) removed.push(path)
255
+ return { changed: changed.sort(), added: added.sort(), removed: removed.sort() }
256
+ }
257
+
258
+ /** `true` when nothing changed, was added, or was removed. */
259
+ export function renderDepsEqual(prior, current) {
260
+ const { changed, added, removed } = diffRenderDeps(prior, current)
261
+ return changed.length + added.length + removed.length === 0
262
+ }
263
+
264
+ /** The changed/added/removed paths as one short list (`+new`, `-gone`), capped. */
265
+ function summarizeRenderDeps(diff, cap = 6) {
266
+ const paths = [...diff.changed, ...diff.added.map((x) => `+${x}`), ...diff.removed.map((x) => `-${x}`)]
267
+ const shown = paths.slice(0, cap).join(', ') + (paths.length > cap ? `, … +${paths.length - cap} more` : '')
268
+ return { count: paths.length, shown }
269
+ }
270
+
271
+ /**
272
+ * What the render-dependency coverage IS and IS NOT — printed on every run, so
273
+ * "fresh" is read as exactly the claim it makes: a visible caveat, not a
274
+ * comment (AGENTS.md §4).
275
+ */
276
+ export function describeRenderDeps({ sourceCount, themeCount, themeEntry }) {
277
+ return [
278
+ `Render dependencies: ${sourceCount} project source file(s) imported by ${CONFIG_FILE} ` +
279
+ `+ ${themeCount} theme stylesheet(s) (the @import chain of ${themeEntry}). ` +
280
+ 'A change in any of them re-renders every preview.',
281
+ ' Not seen: modules loaded only at render time (dynamic imports), assets referenced by URL, ' +
282
+ 'installed packages (the engine version is reported separately, as a warning).',
283
+ ]
284
+ }
285
+
136
286
  /** Serialize the previews manifest deterministically (sorted entry keys, LF, trailing newline). */
137
- export function serializePreviewsManifest({ engine, entries }) {
287
+ export function serializePreviewsManifest({ engine, entries, renderDeps = [] }) {
138
288
  const doc = {
139
289
  version: PREVIEWS_MANIFEST_VERSION,
140
290
  engine,
291
+ renderDeps: renderDeps.map((d) => ({ path: d.path, hash: d.hash })),
141
292
  entries: entries.map((e) => ({
142
293
  componentId: e.componentId,
143
294
  fixture: e.fixture,
@@ -165,16 +316,19 @@ const fileOfUrl = (url) => (typeof url === 'string' ? url.split('/').pop() : und
165
316
 
166
317
  /**
167
318
  * Decide which entries need rendering. `specs` carry a computed `hash`;
168
- * an entry is FRESH (skipped) when the existing manifest has the same hash for
169
- * it and the image file it names is present. Fresh entries keep their existing
170
- * URL verbatim (a prior PNG-fallback file stays a PNG until its inputs change).
319
+ * an entry is FRESH (skipped) when the render dependencies are unchanged
320
+ * (EC-600 — `renderDeps` against the manifest's; a changed dependency makes
321
+ * EVERY entry stale), the existing manifest has the same hash for it, and the
322
+ * image file it names is present. Fresh entries keep their existing URL
323
+ * verbatim (a prior PNG-fallback file stays a PNG until its inputs change).
171
324
  */
172
- export function planPreviewRun({ specs, existing, files }) {
325
+ export function planPreviewRun({ specs, existing, files, renderDeps }) {
173
326
  const priorByKey = new Map((existing?.entries ?? []).map((e) => [key(e), e]))
327
+ const depsFresh = renderDepsEqual(existing?.renderDeps, renderDeps)
174
328
  return specs.map((spec) => {
175
329
  const prior = priorByKey.get(key(spec))
176
330
  const priorFile = prior ? fileOfUrl(prior.url) : undefined
177
- const fresh = Boolean(prior && prior.hash === spec.hash && priorFile && files.includes(priorFile))
331
+ const fresh = Boolean(depsFresh && prior && prior.hash === spec.hash && priorFile && files.includes(priorFile))
178
332
  return fresh ? { ...spec, fresh: true, url: prior.url } : { ...spec, fresh: false }
179
333
  })
180
334
  }
@@ -186,9 +340,11 @@ export function planPreviewRun({ specs, existing, files }) {
186
340
  * Two channels, and the distinction is the whole point (EC-502):
187
341
  *
188
342
  * - **problems** fail the gate (exit 1). Each one means an artifact does not
189
- * match what the config declares: a fixture with no preview, a preview whose
343
+ * match what the code declares: a fixture with no preview, a preview whose
190
344
  * declared inputs changed, a manifest entry whose image is gone, an entry or
191
- * file nothing enumerates any more.
345
+ * file nothing enumerates any more — and, ONCE for the whole set, a render
346
+ * dependency (a config-imported source file or a theme stylesheet) whose
347
+ * content changed since the images were rendered (EC-600).
192
348
  * - **warnings** are reported and do NOT fail. Today that is exactly one:
193
349
  * `engine-drift` — these images were rendered by a different engine version.
194
350
  * True and worth saying, but it is implementation drift, the same category
@@ -201,7 +357,7 @@ export function planPreviewRun({ specs, existing, files }) {
201
357
  * `stale` line per entry. Say the real thing once, and skip the hash comparison
202
358
  * (entry-set and file checks still run — they are version-independent).
203
359
  */
204
- export function checkPreviews({ specs, manifest, files, engine }) {
360
+ export function checkPreviews({ specs, manifest, files, engine, renderDeps }) {
205
361
  const problems = []
206
362
  const warnings = []
207
363
  if (!manifest) {
@@ -215,6 +371,10 @@ export function checkPreviews({ specs, manifest, files, engine }) {
215
371
  const comparableHashes = version === PREVIEWS_MANIFEST_VERSION
216
372
  if (!comparableHashes) {
217
373
  problems.push({ kind: 'manifest-version', from: version, to: PREVIEWS_MANIFEST_VERSION })
374
+ } else {
375
+ // One problem for the whole set — which files, never N bogus per-entry `stale` rows.
376
+ const deps = diffRenderDeps(manifest.renderDeps, renderDeps)
377
+ if (!renderDepsEqual(manifest.renderDeps, renderDeps)) problems.push({ kind: 'render-deps', ...deps })
218
378
  }
219
379
  if (engine !== undefined && manifest.engine !== undefined && manifest.engine !== engine) {
220
380
  warnings.push({ kind: 'engine-drift', from: manifest.engine, to: engine })
@@ -263,6 +423,13 @@ export function formatCheckProblems(problems) {
263
423
  if (p.kind === 'manifest-version') {
264
424
  return ` ✗ manifest written in format v${p.from}, this CLI writes v${p.to} — hashes are not comparable; regenerate`
265
425
  }
426
+ if (p.kind === 'render-deps') {
427
+ const { count, shown } = summarizeRenderDeps(p)
428
+ return (
429
+ ` ✗ render deps ${count} render dependenc${count === 1 ? 'y' : 'ies'} changed since these previews ` +
430
+ `were rendered (${shown}) — every preview re-renders`
431
+ )
432
+ }
266
433
  return p.kind === 'orphan-file'
267
434
  ? ` ✗ ${label[p.kind]} ${p.file}`
268
435
  : ` ✗ ${label[p.kind]} ${p.componentId} · ${p.fixture}${p.file ? ` (${p.file})` : ''}`
@@ -507,7 +674,27 @@ async function startHarnessServer({ root, studioDir, port }) {
507
674
  port,
508
675
  plugins: [tailwind(), ...harnessPlugins({ configPath, themePath })],
509
676
  })
510
- return { server, url: `http://127.0.0.1:${port}${HARNESS_PATH}` }
677
+ return { server, url: `http://127.0.0.1:${port}${HARNESS_PATH}`, themePath }
678
+ }
679
+
680
+ /**
681
+ * The render dependencies of a booted harness (EC-600): the project source files
682
+ * the SSR module graph loaded while enumerating (what `elytra.config.ts`
683
+ * imports) plus the theme's `@import` chain, content-hashed. Call AFTER
684
+ * `ssrLoadModule` has run, or the graph is empty.
685
+ */
686
+ function collectRenderDeps(harness, root) {
687
+ const graph = harness.server.environments?.ssr?.moduleGraph ?? harness.server.moduleGraph
688
+ const sources = projectSourceFiles([...graph.idToModuleMap.values()].map((m) => m.file), root)
689
+ const theme = themeImportChain(harness.themePath)
690
+ return {
691
+ renderDeps: renderDepsOf(root, [...sources, ...theme]),
692
+ description: describeRenderDeps({
693
+ sourceCount: sources.length,
694
+ themeCount: theme.length,
695
+ themeEntry: relative(root, harness.themePath).split(sep).join('/'),
696
+ }),
697
+ }
511
698
  }
512
699
 
513
700
  const PLAYWRIGHT_INSTALL_HELP = [
@@ -634,7 +821,11 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
634
821
  fixture: entry.fixture,
635
822
  hash: previewEntryHash(entry.hashInput),
636
823
  }))
637
- log(`Enumerated ${specs.length} fixture preview(s) from elytra.config.ts.`)
824
+ log(`Enumerated ${specs.length} fixture preview(s) from ${CONFIG_FILE}.`)
825
+ // EC-600: what the config imported while enumerating + the theme chain — and,
826
+ // visibly, what that does NOT cover.
827
+ const { renderDeps, description } = collectRenderDeps(harness, root)
828
+ for (const line of description) log(line)
638
829
 
639
830
  const artifacts = gatherPreviewsArtifacts(studioDir)
640
831
  const existing = parsePreviewsManifest(artifacts.manifestText)
@@ -645,12 +836,15 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
645
836
  manifest: existing,
646
837
  files: artifacts.files,
647
838
  engine,
839
+ renderDeps,
648
840
  })
649
841
  // Warnings print on BOTH verdicts — a green run that still has something to
650
842
  // say says it, and a red run does not swallow it.
651
843
  for (const line of formatCheckWarnings(warnings)) log(line)
652
844
  if (ok) {
653
- log(`✓ ${specs.length} preview(s) up to date (hashes match, all files present).`)
845
+ log(
846
+ `✓ ${specs.length} preview(s) up to date (declared inputs and render dependencies unchanged, all files present).`,
847
+ )
654
848
  return 0
655
849
  }
656
850
  log(`✗ ${problems.length} problem(s):`)
@@ -659,7 +853,11 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
659
853
  return 1
660
854
  }
661
855
 
662
- const jobs = planPreviewRun({ specs, existing, files: artifacts.files })
856
+ if (existing && !renderDepsEqual(existing.renderDeps, renderDeps)) {
857
+ const { shown } = summarizeRenderDeps(diffRenderDeps(existing.renderDeps, renderDeps))
858
+ log(`Render dependencies changed (${shown}) — every preview re-renders.`)
859
+ }
860
+ const jobs = planPreviewRun({ specs, existing, files: artifacts.files, renderDeps })
663
861
  const toRender = jobs.filter((j) => !j.fresh)
664
862
  log(`${jobs.length - toRender.length} unchanged (skipped), ${toRender.length} to render.`)
665
863
 
@@ -715,7 +913,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
715
913
 
716
914
  // Manifest: every successful entry (fresh or rendered), enumeration order.
717
915
  const finalEntries = jobs.filter((j) => typeof j.url === 'string')
718
- const manifestText = serializePreviewsManifest({ engine, entries: finalEntries })
916
+ const manifestText = serializePreviewsManifest({ engine, entries: finalEntries, renderDeps })
719
917
  const manifestChanged = artifacts.manifestText !== manifestText
720
918
  if (manifestChanged) {
721
919
  mkdirSync(studioDir, { recursive: true })