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 +17 -2
- package/docs/diagnostics.md +30 -0
- package/package.json +1 -1
- package/src/catalog.js +27 -0
- package/src/config.js +114 -16
- package/src/database/index.js +13 -3
- package/src/plugins/assets.js +20 -0
- package/src/plugins/render/file.js +17 -6
- package/src/report.js +99 -2
- package/src/source.js +17 -1
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. `
|
|
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`.
|
|
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
|
package/docs/diagnostics.md
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
package/src/database/index.js
CHANGED
|
@@ -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).
|
|
335
|
-
'
|
|
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
|
package/src/plugins/assets.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
78
|
-
+ 'and changing it will NOT rebuild the pages that read it.
|
|
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 = {
|
|
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 ??= {
|
|
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,
|