@elytracms/cli 0.0.86 → 0.0.88

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.86",
4
+ "version": "0.0.88",
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.86"
20
+ "@elytracms/core": "^0.0.88"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/cli.js CHANGED
@@ -117,7 +117,9 @@ and are checked in the studio), and reports the generated-previews artifacts
117
117
  (manifest/files present; hash staleness lives in \`elytra previews --check\`).
118
118
  With studio/ deps installed, config validity is checked for real: doctor
119
119
  executes elytra.config.ts through your studio's own Vite (takes a few
120
- seconds); without deps it stays an honest "unknown" with the reason.
120
+ seconds); without deps it stays an honest "unknown" with the reason. With
121
+ the config resolved, it also reads the public delivery snapshot and warns
122
+ about published documents whose collection the config does not declare.
121
123
  Exit non-zero if any check fails. Read-only.
122
124
 
123
125
  Options:
package/src/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { createRequire } from 'node:module'
3
3
  import { dirname, join } from 'node:path'
4
+ import { orphanedCollections } from '@elytracms/core/cms-core'
4
5
  import {
5
6
  REVALIDATE_ENV_ENDPOINT,
6
7
  REVALIDATE_ENV_SECRET,
@@ -8,6 +9,7 @@ import {
8
9
  checkDeploymentReady,
9
10
  expectedDeploymentEnv,
10
11
  probeAuthSigningKeys,
12
+ probeDeliverySnapshot,
11
13
  probeDeployedFunctionsVersion,
12
14
  probeRevalidateEndpoint,
13
15
  } from '@elytracms/core/export-sync/deployment-ready'
@@ -201,7 +203,7 @@ export function configFactsFromOutcome(outcome) {
201
203
  issueCount: issues.length,
202
204
  ...(firstIssue ? { firstIssue: formatConfigIssue(firstIssue) } : {}),
203
205
  }
204
- const project = valid ? result.projects?.[0] : undefined
206
+ const project = resolvedProjectOf(outcome)
205
207
  const collections = project?.collections
206
208
  // EC-524: the `componentIds` fact was gathered here for EC-508's presentation
207
209
  // check, which resolved a collection's declared `presentation.wrapper` against
@@ -216,6 +218,60 @@ export function configFactsFromOutcome(outcome) {
216
218
  }
217
219
  }
218
220
 
221
+ /**
222
+ * The project a resolved config outcome describes — the first one, the same choice
223
+ * {@link configFactsFromOutcome} makes for `collections` — or `undefined` when the
224
+ * config did not resolve validly (or was never evaluated). Pure.
225
+ */
226
+ export function resolvedProjectOf(outcome) {
227
+ if (!outcome || outcome.kind !== 'resolved' || outcome.result?.ok !== true) return undefined
228
+ return outcome.result.projects?.[0]
229
+ }
230
+
231
+ // --- Published orphans (EC-606) -----------------------------------------------------
232
+ //
233
+ // A published document whose collection the config does not declare is still on the
234
+ // public delivery snapshot, but no route or query addresses it and the studio hides it.
235
+ // The doctor reads the snapshot (public, published-only — what every visitor receives)
236
+ // and runs core's ONE counter (`orphanedCollections`, the same one the studio's session
237
+ // hydration runs) against the resolved config's collection ids.
238
+
239
+ /**
240
+ * Gather the `publishedOrphans` fact for a convex target, or `undefined` for any other
241
+ * target (no deployment, no snapshot). Without a declared set — the config did not
242
+ * resolve here — the fact is an honest `unknown` carrying why, and no request is made.
243
+ * `project` is {@link resolvedProjectOf}'s answer; `configFacts` is what
244
+ * {@link configFactsFromOutcome} returned (for the reason text).
245
+ */
246
+ export async function gatherPublishedOrphans({
247
+ target,
248
+ project,
249
+ configFacts = {},
250
+ fetchImpl = fetch,
251
+ probe = probeDeliverySnapshot,
252
+ } = {}) {
253
+ if (target?.kind !== 'convex') return undefined
254
+ if (!project || !Array.isArray(project.collections)) {
255
+ const reason =
256
+ configFacts.config !== undefined && !configFacts.config.valid
257
+ ? 'fix the workspace config issues first (see config-valid).'
258
+ : (configFacts.configUnavailableDetail ?? 'elytra.config.ts was not evaluated here.')
259
+ return {
260
+ status: 'unknown',
261
+ detail: `Published documents were not checked against the declared collections — ${reason}`,
262
+ }
263
+ }
264
+ const snapshot = await probe(target.url, project.slug, fetchImpl)
265
+ if (snapshot.status !== 'answered') return { status: 'unknown', detail: snapshot.detail }
266
+ return {
267
+ status: 'counted',
268
+ orphans: orphanedCollections(
269
+ snapshot.collections.map((collection) => ({ collection, published: true })),
270
+ project.collections.map((collection) => collection.id),
271
+ ),
272
+ }
273
+ }
274
+
219
275
  // --- Deployment env-var presence (EC-399) --------------------------------------
220
276
  //
221
277
  // The Convex deployment's own env (`npx convex env set …`) lives in NO file a
@@ -441,6 +497,11 @@ export function formatReport(report) {
441
497
  * version and the `functions-version` check gets a real answer; otherwise the fact is
442
498
  * omitted → an honest `unknown`. Resolution stays at the entry point so this function
443
499
  * remains pure/injected like every other gatherer here.
500
+ *
501
+ * EC-606: against a convex target the `app` bucket also reports PUBLISHED ORPHANS —
502
+ * published documents whose collection the resolved config does not declare — read
503
+ * from the public delivery snapshot ({@link gatherPublishedOrphans}). A `warn` at most,
504
+ * so the exit code never changes; without a resolved config it is an honest `unknown`.
444
505
  */
445
506
  export async function runDoctor({
446
507
  env = {},
@@ -491,12 +552,23 @@ export async function runDoctor({
491
552
  // (announced by the harness itself — the boot costs seconds). Merged BEFORE the
492
553
  // contract composes the report so `config-valid`/`asset-collection` are real checks.
493
554
  let configFacts = {}
555
+ let outcome
494
556
  if (configOutcome !== undefined || root !== undefined) {
495
- const outcome = configOutcome ?? (await executeWorkspaceConfig({ root, studioDir, log }))
557
+ outcome = configOutcome ?? (await executeWorkspaceConfig({ root, studioDir, log }))
496
558
  configFacts = configFactsFromOutcome(outcome)
497
559
  Object.assign(facts, configFacts)
498
560
  }
499
561
 
562
+ // EC-606: published documents of undeclared collections — one more request on the
563
+ // same public query API, made only when the config resolved (it needs the declared set).
564
+ const publishedOrphans = await gatherPublishedOrphans({
565
+ target,
566
+ project: resolvedProjectOf(outcome),
567
+ configFacts,
568
+ fetchImpl,
569
+ })
570
+ if (publishedOrphans) facts.publishedOrphans = publishedOrphans
571
+
500
572
  const report = checkDeploymentReady(facts)
501
573
 
502
574
  // EC-440: the previews bucket (repo artifacts, backend-independent). Appended to
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
@@ -57,8 +62,11 @@ export const PREVIEWS_URL_PREFIX = '/elytra-previews/'
57
62
  * `2` (EC-502): the entry hash no longer includes the engine version, so v1 hashes
58
63
  * are not comparable with v2 ones — a version mismatch is reported as itself
59
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.
60
68
  */
61
- export const PREVIEWS_MANIFEST_VERSION = 2
69
+ export const PREVIEWS_MANIFEST_VERSION = 3
62
70
 
63
71
  /** Capture geometry: 1280×800 CSS pixels (a desktop render), shot at 0.5 → 640×400 (16/10). */
64
72
  export const CAPTURE = { width: 1280, height: 800, scale: 0.5 }
@@ -109,11 +117,22 @@ function sortValue(value) {
109
117
  }
110
118
 
111
119
  /**
112
- * The staleness input hash: sha-256 over the stable-serialized render inputs —
113
- * the manifest's prop/slot shape + the fixture's own props/slotChildren (from
114
- * the studio's `previewEntriesForConfig`). Implementation and theme drift are
115
- * deliberately NOT hashed (the ratified EC-440 split: those are caught by
116
- * 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).
117
136
  *
118
137
  * **EC-502 — the engine version was removed from this hash.** It made every
119
138
  * release invalidate every committed preview in every repo, so `elytra previews
@@ -135,11 +154,141 @@ export function previewEntryHash(hashInput) {
135
154
  return createHash('sha256').update(stableStringify({ input: hashInput }), 'utf8').digest('hex')
136
155
  }
137
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
+
138
286
  /** Serialize the previews manifest deterministically (sorted entry keys, LF, trailing newline). */
139
- export function serializePreviewsManifest({ engine, entries }) {
287
+ export function serializePreviewsManifest({ engine, entries, renderDeps = [] }) {
140
288
  const doc = {
141
289
  version: PREVIEWS_MANIFEST_VERSION,
142
290
  engine,
291
+ renderDeps: renderDeps.map((d) => ({ path: d.path, hash: d.hash })),
143
292
  entries: entries.map((e) => ({
144
293
  componentId: e.componentId,
145
294
  fixture: e.fixture,
@@ -167,16 +316,19 @@ const fileOfUrl = (url) => (typeof url === 'string' ? url.split('/').pop() : und
167
316
 
168
317
  /**
169
318
  * Decide which entries need rendering. `specs` carry a computed `hash`;
170
- * an entry is FRESH (skipped) when the existing manifest has the same hash for
171
- * it and the image file it names is present. Fresh entries keep their existing
172
- * 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).
173
324
  */
174
- export function planPreviewRun({ specs, existing, files }) {
325
+ export function planPreviewRun({ specs, existing, files, renderDeps }) {
175
326
  const priorByKey = new Map((existing?.entries ?? []).map((e) => [key(e), e]))
327
+ const depsFresh = renderDepsEqual(existing?.renderDeps, renderDeps)
176
328
  return specs.map((spec) => {
177
329
  const prior = priorByKey.get(key(spec))
178
330
  const priorFile = prior ? fileOfUrl(prior.url) : undefined
179
- 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))
180
332
  return fresh ? { ...spec, fresh: true, url: prior.url } : { ...spec, fresh: false }
181
333
  })
182
334
  }
@@ -188,9 +340,11 @@ export function planPreviewRun({ specs, existing, files }) {
188
340
  * Two channels, and the distinction is the whole point (EC-502):
189
341
  *
190
342
  * - **problems** fail the gate (exit 1). Each one means an artifact does not
191
- * 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
192
344
  * declared inputs changed, a manifest entry whose image is gone, an entry or
193
- * 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).
194
348
  * - **warnings** are reported and do NOT fail. Today that is exactly one:
195
349
  * `engine-drift` — these images were rendered by a different engine version.
196
350
  * True and worth saying, but it is implementation drift, the same category
@@ -203,7 +357,7 @@ export function planPreviewRun({ specs, existing, files }) {
203
357
  * `stale` line per entry. Say the real thing once, and skip the hash comparison
204
358
  * (entry-set and file checks still run — they are version-independent).
205
359
  */
206
- export function checkPreviews({ specs, manifest, files, engine }) {
360
+ export function checkPreviews({ specs, manifest, files, engine, renderDeps }) {
207
361
  const problems = []
208
362
  const warnings = []
209
363
  if (!manifest) {
@@ -217,6 +371,10 @@ export function checkPreviews({ specs, manifest, files, engine }) {
217
371
  const comparableHashes = version === PREVIEWS_MANIFEST_VERSION
218
372
  if (!comparableHashes) {
219
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 })
220
378
  }
221
379
  if (engine !== undefined && manifest.engine !== undefined && manifest.engine !== engine) {
222
380
  warnings.push({ kind: 'engine-drift', from: manifest.engine, to: engine })
@@ -265,6 +423,13 @@ export function formatCheckProblems(problems) {
265
423
  if (p.kind === 'manifest-version') {
266
424
  return ` ✗ manifest written in format v${p.from}, this CLI writes v${p.to} — hashes are not comparable; regenerate`
267
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
+ }
268
433
  return p.kind === 'orphan-file'
269
434
  ? ` ✗ ${label[p.kind]} ${p.file}`
270
435
  : ` ✗ ${label[p.kind]} ${p.componentId} · ${p.fixture}${p.file ? ` (${p.file})` : ''}`
@@ -509,7 +674,27 @@ async function startHarnessServer({ root, studioDir, port }) {
509
674
  port,
510
675
  plugins: [tailwind(), ...harnessPlugins({ configPath, themePath })],
511
676
  })
512
- 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
+ }
513
698
  }
514
699
 
515
700
  const PLAYWRIGHT_INSTALL_HELP = [
@@ -636,7 +821,11 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
636
821
  fixture: entry.fixture,
637
822
  hash: previewEntryHash(entry.hashInput),
638
823
  }))
639
- 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)
640
829
 
641
830
  const artifacts = gatherPreviewsArtifacts(studioDir)
642
831
  const existing = parsePreviewsManifest(artifacts.manifestText)
@@ -647,12 +836,15 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
647
836
  manifest: existing,
648
837
  files: artifacts.files,
649
838
  engine,
839
+ renderDeps,
650
840
  })
651
841
  // Warnings print on BOTH verdicts — a green run that still has something to
652
842
  // say says it, and a red run does not swallow it.
653
843
  for (const line of formatCheckWarnings(warnings)) log(line)
654
844
  if (ok) {
655
- 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
+ )
656
848
  return 0
657
849
  }
658
850
  log(`✗ ${problems.length} problem(s):`)
@@ -661,7 +853,11 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
661
853
  return 1
662
854
  }
663
855
 
664
- 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 })
665
861
  const toRender = jobs.filter((j) => !j.fresh)
666
862
  log(`${jobs.length - toRender.length} unchanged (skipped), ${toRender.length} to render.`)
667
863
 
@@ -717,7 +913,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
717
913
 
718
914
  // Manifest: every successful entry (fresh or rendered), enumeration order.
719
915
  const finalEntries = jobs.filter((j) => typeof j.url === 'string')
720
- const manifestText = serializePreviewsManifest({ engine, entries: finalEntries })
916
+ const manifestText = serializePreviewsManifest({ engine, entries: finalEntries, renderDeps })
721
917
  const manifestChanged = artifacts.manifestText !== manifestText
722
918
  if (manifestChanged) {
723
919
  mkdirSync(studioDir, { recursive: true })