mikser-io 9.59.2 → 9.61.1

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/CLAUDE.md CHANGED
@@ -158,7 +158,12 @@ brevity.
158
158
  - `server.js` — Express bring-up: CLI flags (`--server`, `--cors`,
159
159
  `--no-cors`), trust-proxy, CORS (with extensible header arrays for
160
160
  plugins to push onto), late-binding static mount + listen.
161
- - `report.js` — the `--json` build report. `warnings` is a VIEW of
161
+ - `report.js` — the `--json` build report. `invalidated` says WHY the
162
+ build did work (`nothing` / `sources` / `config` / `version` / `clear`),
163
+ recorded where each is decided — `reportWipe` in database/index.js,
164
+ `reportChanged` in source.js as the complement of `reportGated`.
165
+ `evaluated` is what a subsystem looked at vs what exists
166
+ (`reportEvaluated`), generalised from assets' matchTally. `warnings` is a VIEW of
162
167
  `logger.warn`; `faults` is a view of `logger.error` **carrying a
163
168
  `code`** — a subsystem declaring it cannot work, deduped by that code,
164
169
  never cleared per cycle, and surfaced in `mikser_ping`. The log call is
@@ -190,6 +195,11 @@ brevity.
190
195
  over ids, not the matched paths, so a file appearing later still
191
196
  invalidates. Paths resolve against `options.workingFolder` — the
192
197
  render-time `runtime` is a per-render projection with no options on it.
198
+ The "this file has no entity" warning reads `options.sourceFolders` —
199
+ the set useSource records as it registers each collection — NOT a list
200
+ of folder names written in the plugin. A hardcoded list misses every
201
+ collection a project registers through `sources()`, which produced 63
202
+ false warnings per build on a real site.
193
203
  - `render.js` / `postprocess.js` — Piscina worker entry points AND the
194
204
  default-export functions the INLINE/SERIAL dispatcher calls directly.
195
205
  Each receives entity + options + config + state; the WORKER path also
@@ -205,7 +215,12 @@ brevity.
205
215
  `descriptor.options` and arrive as the `config` arg to
206
216
  `load`/`render`/`setup`/`postprocess`/`teardown`.
207
217
  - `config.js` — loads `mikser.config.js` at `onLoad` into
208
- `runtime.config`. v9 holds only engine-level keys (`server`,
218
+ `runtime.config`. The cache-invalidating stamp covers the config's whole
219
+ local module graph, captured via `module.registerHooks` during the import
220
+ (Node 22.15+; older runtimes fall back to the entry file and warn). Scoped
221
+ to the config's own directory — `node_modules` alone is not enough of a
222
+ filter, because a workspace symlinks its siblings outside it. Coverage is
223
+ published at `runtime.options.configCoverage` and in the build report. v9 holds only engine-level keys (`server`,
209
224
  `logging`, `catalog` if tuned) plus the `plugins` array — all
210
225
  plugin options moved to the factory call site (ADR-0010).
211
226
  - `plugins.js` — dispatches v9 plugin entries at `onLoad`. Each
@@ -26,6 +26,9 @@ engine source, the entry point is missing and belongs on this page.
26
26
  | I am an agent reading CLI output, not speaking MCP | [`--tools` / `--tool`](#the-two-agent-workflows) |
27
27
  | Did my schema validate anything at all? | [`schemas.names()`](#schemasnames--schemaslookup) |
28
28
  | A tool answered emptily — is it broken, or is there nothing to find? | [`faults`](#faults) |
29
+ | I edited the build and nothing rebuilt | `--json` → `config.files` |
30
+ | Why did this build do any work at all? | `--json` → `invalidated` |
31
+ | Did my new pattern get a chance to match? | `--json` → `evaluated` |
29
32
 
30
33
  ## Command line
31
34
 
@@ -150,6 +153,33 @@ The buckets, and the distinction between them is the point:
150
153
  | `warnings` | everything that went through `logger.warn` this cycle, with its `code` |
151
154
  | `faults` | subsystems that reported they **cannot work** — see [Faults](#faults) |
152
155
 
156
+ `invalidated` says why the build did anything, which the counts never did —
157
+ `0 rendered` reads the same whether nothing needed doing or something did and
158
+ the engine failed to notice:
159
+
160
+ | cause | means |
161
+ | --- | --- |
162
+ | `nothing` | the engine looked and there was no work. A finding, not an absence — check `summary.gated` to see how much it looked at |
163
+ | `sources` | these files changed, named in `changed` (capped, with `truncated` when there were more) |
164
+ | `config` | the config or a module it imports moved, so the cache was wiped |
165
+ | `version` | the engine version moved, with `from` and `to` |
166
+ | `clear` | you passed `--clear` |
167
+
168
+ A wipe outranks changed sources: once the cache goes, every file is a changed
169
+ file and listing them all is noise.
170
+
171
+ `evaluated` says what each subsystem actually looked at, against what exists —
172
+ `{ assets: { evaluated: 0, of: 397 } }`. A new pattern that never had the
173
+ chance to match anything otherwise looks exactly like a run with nothing to do.
174
+
175
+ Each report also carries `config`: the files the config stamp spans, and
176
+ whether that coverage is `complete`. The stamp is what makes a config edit
177
+ invalidate the cache, and it covers the config's whole local module graph —
178
+ not just the entry file. So if you keep the build in `config/pipeline.js` and
179
+ import it from both a dev and a prod config, editing the pipeline invalidates,
180
+ which is the case that matters. `config.files` is there so *"I edited the build
181
+ and nothing rebuilt"* is answerable by reading rather than by experiment.
182
+
153
183
  Each report also carries `cycleId`, `startedAt` and `finishedAt`. Under
154
184
  `--watch` two consecutive reports are otherwise indistinguishable, so
155
185
  "is this my edit's cycle or the one before it" has no answer without the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.59.2",
3
+ "version": "9.61.1",
4
4
  "description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/catalog.js CHANGED
@@ -536,6 +536,33 @@ export async function findEntities(query) {
536
536
  return shim.all().filter(m)
537
537
  }
538
538
 
539
+ // How many entities match, without materializing any of them.
540
+ //
541
+ // findEntities parses the JSON body of every row it returns, which is the
542
+ // wrong price for a number. A COUNT(*) with the same pushed-down WHERE is one
543
+ // query and no parsing — the difference between asking "how big is the
544
+ // catalog" costing nothing and costing a full scan.
545
+ //
546
+ // Returns null when the query cannot be answered in SQL alone. A residual
547
+ // JS-side clause would require fetching the rows to test them, which is
548
+ // exactly what this exists to avoid, and guessing a number would be worse than
549
+ // admitting there isn't one.
550
+ export function countEntities(query) {
551
+ if (!db?.isOpen) {
552
+ const shim = mapStub()
553
+ if (!shim) return 0
554
+ if (!query) return shim.all().length
555
+ const m = typeof query === 'function' ? query : sift(query)
556
+ return shim.all().filter(m).length
557
+ }
558
+ if (!query) return stmtCount.get().c
559
+
560
+ const t = siftToSql(query)
561
+ // A residual matcher means part of the filter never reached SQL.
562
+ if (residualMatcher(query, t.jsFilter)) return null
563
+ return db.prepare(`SELECT COUNT(*) AS c FROM mikser_entities ${t.sql}`).get(...t.params).c
564
+ }
565
+
539
566
  // Streaming variant of findEntities. Same query shape, same sift→SQL
540
567
  // translation, but yields entities chunk-by-chunk so peak memory is
541
568
  // O(chunk × entity) instead of O(corpus × entity).
package/src/config.js CHANGED
@@ -1,10 +1,74 @@
1
1
  import runtime from './runtime.js'
2
2
  import { useLogger } from './engine.js'
3
3
  import { onLoad } from './lifecycle.js'
4
- import { checksum } from './utils.js'
4
+ import { checksum, checksumOf } from './utils.js'
5
5
  import path from 'node:path'
6
+ import nodeModule from 'node:module'
7
+ import { fileURLToPath } from 'node:url'
6
8
  import { existsSync } from 'node:fs'
7
9
 
10
+ // Every local module the config actually pulls in, recorded as it loads.
11
+ //
12
+ // The stamp used to be the entry file's bytes alone, and that is inverted
13
+ // against significance the moment a project has more than one config — which
14
+ // is as soon as it has a dev one and a prod one. Both import the module that
15
+ // decides how the site is built; neither IS that module. So a comment in the
16
+ // thin wrapper wiped the catalog, and rewriting the pipeline that processes
17
+ // every asset changed nothing and rebuilt nothing, on a green build.
18
+ //
19
+ // Node's own loader hook rather than parsing import statements. The resolver
20
+ // already knows the answer exactly, including transitive imports and dynamic
21
+ // ones that actually ran, and a regex over source is the kind of thing that
22
+ // silently misses a case — which here means silently not invalidating, the
23
+ // exact failure being fixed.
24
+ //
25
+ // Scoped to files under the config's own directory. `node_modules` is not
26
+ // enough of a filter on its own: a workspace symlinks its siblings, so
27
+ // `mikser-io` itself resolves to a real path outside node_modules and the
28
+ // engine's entire source tree would land in the stamp.
29
+ function captureConfigGraph(root) {
30
+ const files = new Set()
31
+ let capturing = false
32
+
33
+ // Node 22.15+. Older runtimes keep the previous behaviour rather than a
34
+ // worse guess, and `configCoverage` says which one is in force.
35
+ if (typeof nodeModule.registerHooks !== 'function') {
36
+ return { files, supported: false, start() {}, stop() {} }
37
+ }
38
+ nodeModule.registerHooks({
39
+ load(url, context, next) {
40
+ if (capturing && url.startsWith('file:')) {
41
+ const file = fileURLToPath(url)
42
+ if (!file.includes(`${path.sep}node_modules${path.sep}`)
43
+ && !path.relative(root, file).startsWith('..')) {
44
+ files.add(file)
45
+ }
46
+ }
47
+ return next(url, context)
48
+ },
49
+ })
50
+ return {
51
+ files,
52
+ supported: true,
53
+ start() { capturing = true },
54
+ stop() { capturing = false },
55
+ }
56
+ }
57
+
58
+ // One stamp over the whole set, path-qualified and order-independent.
59
+ //
60
+ // Path as well as content, so moving a module between two files with the same
61
+ // bytes still counts as a change.
62
+ async function stampGraph(files) {
63
+ const parts = []
64
+ for (const file of [...files].sort()) {
65
+ try {
66
+ parts.push(`${file}:${await checksum(file)}`)
67
+ } catch { /* vanished between load and stat — the next cycle sees it */ }
68
+ }
69
+ return parts.length ? checksumOf(parts.join('\n')) : null
70
+ }
71
+
8
72
  onLoad(async () => {
9
73
  const logger = useLogger()
10
74
  const configFile = path.resolve(runtime.options.config)
@@ -19,16 +83,10 @@ onLoad(async () => {
19
83
  // invalidation, so the only symptom was output that did not match the
20
84
  // config, with nothing saying so.
21
85
  //
22
- // The file's bytes only. A config that imports other modules will not
23
- // notice a change in those, which is a real limit worth knowing rather
24
- // than a reason to hash the whole module graph.
25
- try {
26
- runtime.options.configChecksum = await checksum(configFile)
27
- } catch {
28
- // No config file is a legitimate state (defaults all the way down);
29
- // absent stamp means "nothing to compare", not "changed".
30
- runtime.options.configChecksum = null
31
- }
86
+ // Computed AFTER the import, from what the import actually loaded see
87
+ // captureConfigGraph. Before it, there is nothing to hash but the entry
88
+ // file, which is the bug.
89
+ const graph = captureConfigGraph(path.dirname(configFile))
32
90
 
33
91
  // Absence is decided by looking for the file, NOT by catching
34
92
  // ERR_MODULE_NOT_FOUND from the import.
@@ -48,14 +106,54 @@ onLoad(async () => {
48
106
  logger.debug('No config file at %s — using defaults', configFile)
49
107
  } else {
50
108
  // No catch: any failure loading a config that EXISTS is fatal.
51
- const config = await import(configFile)
52
- if (typeof config.default == 'function') {
53
- runtime.config = await config.default(runtime)
54
- } else if (typeof config.default == 'object') {
55
- runtime.config = config.default
109
+ graph.start()
110
+ try {
111
+ const config = await import(configFile)
112
+ if (typeof config.default == 'function') {
113
+ runtime.config = await config.default(runtime)
114
+ } else if (typeof config.default == 'object') {
115
+ runtime.config = config.default
116
+ }
117
+ } finally {
118
+ graph.stop()
119
+ }
120
+ }
121
+
122
+ // The stamp, and what it covers.
123
+ //
124
+ // Coverage is published because "I edited the build and nothing rebuilt"
125
+ // was only answerable by experiment. It is the difference between a limit
126
+ // that is documented and one that is visible at the moment it bites.
127
+ //
128
+ // Absent stamp means "nothing to compare", not "changed" — no config file
129
+ // is a legitimate state, defaults all the way down.
130
+ const covered = [...graph.files]
131
+ runtime.options.configCoverage = {
132
+ files: covered.sort(),
133
+ // False on a runtime without loader hooks, where the stamp is the
134
+ // entry file alone and a change to anything it imports is invisible.
135
+ complete: graph.supported,
136
+ }
137
+ if (covered.length) {
138
+ runtime.options.configChecksum = await stampGraph(covered)
139
+ logger.debug('Config checksum spans %d file(s): %s', covered.length, covered.join(', '))
140
+ } else {
141
+ try {
142
+ runtime.options.configChecksum = existsSync(configFile) ? await checksum(configFile) : null
143
+ } catch {
144
+ runtime.options.configChecksum = null
56
145
  }
57
146
  }
58
147
 
148
+ // Said once, at the only moment it can be acted on. A project whose build
149
+ // lives in a module the stamp cannot reach gets a rebuild it did not ask
150
+ // for rather than silence it cannot diagnose.
151
+ if (existsSync(configFile) && !graph.supported) {
152
+ logger.warn({ code: 'config-coverage-partial' },
153
+ 'This Node build has no module loader hooks, so the config stamp covers %s alone. Editing a module '
154
+ + 'it imports will NOT invalidate anything — run with --force after such a change.', configFile)
155
+ }
156
+
59
157
  // Nothing else is loaded. There is deliberately no `config/<plugin>
60
158
  // .config.js` channel: plugin options arrive as factory arguments
61
159
  // (ADR-0010), and an entry in `plugins` is a factory call result — a
@@ -47,6 +47,7 @@ import { mkdirSync, unlinkSync, existsSync, readFileSync, writeFileSync } from '
47
47
  import Database from 'better-sqlite3'
48
48
  import runtime from '../runtime.js'
49
49
  import { isReportOnlyRun } from '../tools.js'
50
+ import { reportWipe } from '../report.js'
50
51
  import { onLoaded } from '../lifecycle.js'
51
52
  import packageInfo from '../../package.json' with { type: 'json' }
52
53
 
@@ -330,9 +331,9 @@ export function createSqliteDatabase({
330
331
  configChanged ? 'config' : 'schema version')
331
332
  } else if (configChanged && !(recorded && recorded !== version)) {
332
333
  logger?.warn(
333
- 'Config changed since the last run. Wiping the cache and rebuilding from sources ' +
334
- '(files are the source of truth — no source data is affected). Note this tracks the ' +
335
- 'bytes of %s only: a change in a module it imports is not seen.',
334
+ 'Config changed since the last run. Wiping the cache and rebuilding from sources '
335
+ + '(files are the source of truth — no source data is affected). The stamp covers %s and '
336
+ + 'every local module it imports; `--json` reports exactly which files under `config.files`.',
336
337
  runtime.options.config,
337
338
  )
338
339
  }
@@ -355,6 +356,15 @@ export function createSqliteDatabase({
355
356
  } else if (forceWipe) {
356
357
  logger?.info('Clearing the cache and rebuilding from sources.')
357
358
  }
359
+ // Recorded where the decision is made. The report otherwise shows
360
+ // a cold build and no reason for it, and "everything rebuilt" is
361
+ // the same output whether the version moved, the config moved, or
362
+ // someone passed --clear.
363
+ reportWipe(
364
+ recorded && recorded !== version ? 'version' : forceWipe ? 'clear' : 'config',
365
+ recorded && recorded !== version ? { from: recorded, to: version } : {},
366
+ )
367
+
358
368
  // Unlink, rather than dropping table by table.
359
369
  //
360
370
  // The wipe used to have to know which tables to keep, because
@@ -1,3 +1,5 @@
1
+ import { reportEvaluated } from '../report.js'
2
+ import { countEntities } from '../catalog.js'
1
3
  import path from 'node:path'
2
4
  import { mkdir, writeFile, unlink, rm, readFile, symlink, } from 'fs/promises'
3
5
  import { existsSync } from 'node:fs'
@@ -482,6 +484,24 @@ export function assets(options = {}) {
482
484
 
483
485
  reportUnmatchedPresets(logger)
484
486
 
487
+ // How much of the catalog this run actually looked at.
488
+ //
489
+ // The warning above only fires on a full cycle, because on an
490
+ // incremental one a healthy preset legitimately matches nothing. That
491
+ // is correct and it leaves the reverse question unanswered: a NEW
492
+ // pattern that never had the chance to match anything looks exactly
493
+ // like a run with nothing to do. `evaluated 4 of 397` answers it
494
+ // without needing a warning to decide whether to fire.
495
+ try {
496
+ // COUNT(*), not a fetch: the denominator is a number, and paying a
497
+ // full scan and a JSON.parse per row to produce it would make the
498
+ // diagnostic cost more than the thing it diagnoses.
499
+ reportEvaluated('assets', {
500
+ evaluated: matchTally.evaluated,
501
+ of: countEntities({ collection: { $ne: collection } }),
502
+ })
503
+ } catch { /* a count is not worth failing a build over */ }
504
+
485
505
  let revisions = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
486
506
  for (let revision of revisions) {
487
507
  const [preset] = revision.split(path.sep)
@@ -67,16 +67,27 @@ function entityIdFor(workingFolder, resolved) {
67
67
  // nothing to track" read identically from a template. Said once per path.
68
68
  const warnedOutside = new Set()
69
69
  function warnIfUntrackable(options, resolved, logger) {
70
- const folders = ['documentsFolder', 'filesFolder', 'assetsFolder', 'resourcesFolder', 'dataFolder']
71
- .map(key => options?.[key]).filter(Boolean)
72
- if (!folders.length) return // nothing configured to compare against
70
+ // Every folder whose files become entities, as recorded by useSource when
71
+ // it registered them — NOT a list written here.
72
+ //
73
+ // The first version of this hardcoded five content folders, and a project
74
+ // registering its own collections through sources() has more than five. On
75
+ // lmed that meant 63 warnings per build, one for every stylesheet and
76
+ // script, all of them tracked correctly and every one of them saying the
77
+ // opposite. Which is worse than not warning: 63 spurious lines a build
78
+ // teaches you to filter the channel, and the filtered-out line is the real
79
+ // one.
80
+ const folders = Object.values(options?.sourceFolders ?? {})
81
+ // Nothing registered yet means nothing can be concluded. Silence is the
82
+ // only honest answer — the previous shape guessed instead.
83
+ if (!folders.length) return
73
84
  if (folders.some(folder => !path.relative(folder, resolved).startsWith('..'))) return
74
85
  if (warnedOutside.has(resolved)) return
75
86
  warnedOutside.add(resolved)
76
87
  logger?.warn?.({ code: 'untracked-file-read' },
77
- 'A template read %s, which is outside every content folder — so it has no entity, nothing watches it, '
78
- + 'and changing it will NOT rebuild the pages that read it. Move it under a content folder if that '
79
- + 'matters, or pass { track: false } to say the staleness is intended.', resolved)
88
+ 'A template read %s, which is outside every folder mikser takes entities from — so it has no entity, '
89
+ + 'nothing watches it, and changing it will NOT rebuild the pages that read it. Register the folder '
90
+ + 'with sources() if that matters, or pass { track: false } to say the staleness is intended.', resolved)
80
91
  }
81
92
 
82
93
  export function load({ runtime, options, track, logger }) {
package/src/report.js CHANGED
@@ -109,8 +109,12 @@ export function resetReport() {
109
109
  const previous = runtime.state.cycle
110
110
  if (previous && !previous.finishedAt) finishCycle()
111
111
  runtime.state.cycle = { id: nextCycleId(), startedAt: Date.now(), finishedAt: null }
112
- runtime.state.report = { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
112
+ runtime.state.report = {
113
+ rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0, evaluated: {},
114
+ }
113
115
  runtime.state.renderErrors = []
116
+ // Per cycle, unlike the wipe: what changed is a fact about THIS build.
117
+ runtime.state.changed = { ids: [], count: 0 }
114
118
  }
115
119
 
116
120
  // End of a cycle: stamp it, file it, and wake anyone waiting on it.
@@ -136,10 +140,70 @@ function store() {
136
140
  // Without this the build everyone looks at first reports cycleId: null.
137
141
  runtime.state ??= {}
138
142
  runtime.state.cycle ??= { id: 1, startedAt: Date.now(), finishedAt: null }
139
- runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
143
+ runtime.state.report ??= {
144
+ rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0, evaluated: {},
145
+ }
146
+ runtime.state.report.evaluated ??= {}
140
147
  return runtime.state.report
141
148
  }
142
149
 
150
+ // Why this cycle did any work at all.
151
+ //
152
+ // The counts say what happened; they never said what STARTED it. "0 rendered"
153
+ // is the same line whether nothing needed doing, or something needed doing and
154
+ // the engine could not tell — which is the difference between a build you can
155
+ // trust and one you have to reproduce by hand.
156
+ //
157
+ // Two halves, because there are two ways work begins. A WIPE is process-level:
158
+ // the version moved, the config moved, `--clear` was passed, and everything is
159
+ // rebuilt from source. Otherwise it is the sources that changed since last
160
+ // time, which is per cycle.
161
+ //
162
+ // `nothing` is a first-class answer, not an absence. It means the engine
163
+ // looked and there was genuinely no work — which is the one thing an operator
164
+ // most wants distinguished from a build that silently did not notice.
165
+
166
+ // Set once, by whoever decided to wipe. Not gated on reportWanted: it happens
167
+ // at database open, which may be before a reader has asked for a report, and
168
+ // it is one small object.
169
+ export function reportWipe(cause, detail = {}) {
170
+ runtime.state ??= {}
171
+ runtime.state.wipe = { cause, ...detail }
172
+ }
173
+
174
+ // One source whose bytes moved. The complement of reportGated: between them
175
+ // every file the engine looked at is accounted for.
176
+ export function reportChanged(id) {
177
+ if (!reportWanted() || !id) return
178
+ const store = changedStore()
179
+ store.count++
180
+ // Capped. On a cold build this is the whole corpus, and the cause already
181
+ // says so — the list is for the incremental case, where naming the three
182
+ // files that moved is the entire answer.
183
+ if (store.ids.length < CHANGED_LIMIT) store.ids.push(id)
184
+ }
185
+
186
+ const CHANGED_LIMIT = 50
187
+
188
+ function changedStore() {
189
+ runtime.state ??= {}
190
+ runtime.state.changed ??= { ids: [], count: 0 }
191
+ return runtime.state.changed
192
+ }
193
+
194
+ // What a subsystem looked at, against what it could have looked at.
195
+ //
196
+ // Generalised from the assets plugin, which already warns when a configured
197
+ // preset matched none of the entities a full cycle evaluated. That reasoning —
198
+ // an incremental cycle only re-evaluates what changed, so matching nothing can
199
+ // be perfectly healthy — is not specific to presets, and neither is the
200
+ // question it answers. "assets evaluated 4 of 397" is the line that tells you
201
+ // instantly that a new pattern never had the chance to match anything.
202
+ export function reportEvaluated(scope, { evaluated, of } = {}) {
203
+ if (!reportWanted() || !scope) return
204
+ store().evaluated[scope] = { evaluated: evaluated ?? 0, ...(Number.isFinite(of) ? { of } : {}) }
205
+ }
206
+
143
207
  // An entity whose SOURCE did not change is gated at import and never becomes
144
208
  // a render task at all — so it appears in neither `rendered` nor `skipped`,
145
209
  // and the two lists would not reconcile with the corpus size without saying
@@ -309,6 +373,25 @@ export function renderErrorCount() {
309
373
  return errorStore().length
310
374
  }
311
375
 
376
+ // The cause, and enough detail to act on it.
377
+ //
378
+ // A wipe outranks changed sources: when the cache went, everything is a
379
+ // changed source and saying so is noise. `nothing` is returned rather than
380
+ // omitted, because an absent field reads as "not recorded" and this is a
381
+ // finding.
382
+ function invalidation() {
383
+ const wipe = runtime.state?.wipe
384
+ if (wipe) return wipe
385
+ const { ids, count } = changedStore()
386
+ if (!count) return { cause: 'nothing' }
387
+ return {
388
+ cause: 'sources',
389
+ changed: ids,
390
+ count,
391
+ ...(count > ids.length ? { truncated: count - ids.length } : {}),
392
+ }
393
+ }
394
+
312
395
  export function buildReport() {
313
396
  const report = store()
314
397
  const cycle = runtime.state?.cycle
@@ -325,6 +408,18 @@ export function buildReport() {
325
408
  // failed build, whatever the other counts say.
326
409
  errors: errorStore(),
327
410
  warnings: report.warnings,
411
+ // Why this build did any work — see reportWipe / reportChanged.
412
+ invalidated: invalidation(),
413
+ // What each subsystem looked at, against what it could have.
414
+ ...(Object.keys(report.evaluated ?? {}).length ? { evaluated: report.evaluated } : {}),
415
+ // Which files the config stamp spans.
416
+ //
417
+ // "I edited the build and nothing rebuilt" was only answerable by
418
+ // experiment: the stamp covered the entry file, real projects put the
419
+ // build in a module it imports, and nothing said which. Published
420
+ // rather than documented, because a limit you can see at the moment it
421
+ // bites is a different thing from one written down elsewhere.
422
+ ...(runtime.options?.configCoverage ? { config: runtime.options.configCoverage } : {}),
328
423
  // Named conditions reported at error level: a subsystem saying it
329
424
  // cannot work, as opposed to `errors`, which is a render that threw.
330
425
  // Carried whole rather than filtered to this cycle — a fault raised at
@@ -345,6 +440,8 @@ export function buildReport() {
345
440
  gated: report.gated,
346
441
  warnings: report.warnings.length,
347
442
  faults: faults().length,
443
+ // Sources whose bytes moved this cycle. The complement of `gated`.
444
+ changed: changedStore().count,
348
445
  },
349
446
  }
350
447
  }
package/src/source.js CHANGED
@@ -44,7 +44,7 @@ import pMap from 'p-map'
44
44
  import runtime from './runtime.js'
45
45
  import { ACTION } from './constants.js'
46
46
  import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils.js'
47
- import { reportGated } from './report.js'
47
+ import { reportGated, reportChanged } from './report.js'
48
48
  import { findById, findEntities, checksumsByCollection } from './catalog.js'
49
49
  import { useDatabase } from './database/index.js'
50
50
 
@@ -328,6 +328,18 @@ export function useSource(core, options) {
328
328
  ? folder
329
329
  : path.join(runtime.options.workingFolder, folder)
330
330
  runtime.options[`${collection}Folder`] = absFolder
331
+ // The authoritative set of folders whose files become entities.
332
+ //
333
+ // `<collection>Folder` above is the per-collection accessor and reads
334
+ // like one; this is the LIST, which is a different question and the
335
+ // one anything asking "could a file here be tracked?" needs. Deriving
336
+ // it by scanning options for a `*Folder` suffix would sweep up
337
+ // workingFolder, runtimeFolder and outputFolder, and hand-listing the
338
+ // content ones misses every collection a project registers itself —
339
+ // which is exactly how the file helpers came to warn 63 times a build
340
+ // about files they were tracking correctly.
341
+ runtime.options.sourceFolders ??= {}
342
+ runtime.options.sourceFolders[collection] = absFolder
331
343
  logger.debug('%s folder: %s', cap, absFolder)
332
344
 
333
345
  await mkdir(absFolder, { recursive: true })
@@ -456,6 +468,10 @@ export function useSource(core, options) {
456
468
  reportGated()
457
469
  return
458
470
  }
471
+ // Past the gate means the bytes are new or different — the complement
472
+ // of reportGated, so between them every file looked at is accounted
473
+ // for and "why did this build do anything" has an answer.
474
+ reportChanged(id)
459
475
 
460
476
  const base = {
461
477
  id,