mikser-io 9.19.0 → 9.20.0

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.
@@ -106,9 +106,16 @@ there is no manifest to check against.
106
106
  npx mikser --verify || echo "output folder has drifted"
107
107
  ```
108
108
 
109
- `Orphan` is the one to read carefully — it is normal for files that
110
- another plugin writes (assets, files, data), and a real signal for a page
111
- whose layout stopped producing it.
109
+ A destination is resolved against the output folder first and, when that
110
+ finds nothing, treated as a filesystem path — assets carry an absolute
111
+ destination built from `assetsFolder`, which may sit outside the output
112
+ folder entirely.
113
+
114
+ `Orphan` still needs reading with that in mind: only files under the
115
+ output folder are walked, and a file is an orphan when no snapshot claims
116
+ it. That is normal for anything written without a render snapshot (the
117
+ `files` and `data` plugins), and a real signal for a page whose layout
118
+ stopped producing it.
112
119
 
113
120
  ### The rest, briefly
114
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.19.0",
3
+ "version": "9.20.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -331,6 +331,11 @@ export function createSqliteDatabase({
331
331
  logger: logger ?? null,
332
332
  }
333
333
 
334
+ // Published so plugins can tell an everything-was-evaluated cycle
335
+ // from an incremental one without reaching into the db handle. Read
336
+ // through `isFullCycle()` in utils.js rather than directly.
337
+ runtime.options.firstRun = provisioningCtx.firstRun
338
+
334
339
  // Fire provisioning callbacks before schema apply, so they can
335
340
  // load runtime extensions (sqlite-vec's vec0, etc.) that the
336
341
  // schemas about to apply might reference, register custom
package/src/engine.js CHANGED
@@ -265,8 +265,15 @@ export async function setup(options) {
265
265
  for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
266
266
  for (const e of orphaned) logger.warn('Orphan: %s', e.path)
267
267
 
268
+ // Level picked from the verdict, because the level IS the marker
269
+ // in pino-pretty's messageFormat: notice renders 🟢, warn 🟡,
270
+ // error 🔴. A fixed `notice` prints a green tick next to the word
271
+ // FAIL, which reads as success at a glance even though the exit
272
+ // code is right.
268
273
  const verdict = errors > 0 ? 'FAIL' : (warnings > 0 ? 'WARN' : 'OK')
269
- logger.notice('Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned',
274
+ const report = errors > 0 ? logger.error : (warnings > 0 ? logger.warn : logger.notice)
275
+ report.call(logger,
276
+ 'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned',
270
277
  verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length)
271
278
  process.exit(errors > 0 ? 2 : (warnings > 0 ? 1 : 0))
272
279
  }
package/src/manifest.js CHANGED
@@ -66,7 +66,10 @@ import { useDatabase, registerSchema } from './database/index.js'
66
66
  // PK index, no separate id index needed. Parent index is for pagination
67
67
  // cleanup (drop all children of X). WITHOUT ROWID keeps the file
68
68
  // smaller.
69
- registerSchema('mikser_snapshots', `
69
+ // Exported so tests build against the real schema rather than a copy. A
70
+ // copy drifts the moment a column is added, and the failure is a bare
71
+ // SQLITE_ERROR that names nothing.
72
+ export const SNAPSHOTS_SCHEMA = `
70
73
  CREATE TABLE IF NOT EXISTS mikser_snapshots (
71
74
  id TEXT NOT NULL,
72
75
  destination TEXT NOT NULL,
@@ -78,7 +81,8 @@ registerSchema('mikser_snapshots', `
78
81
  PRIMARY KEY (id, destination)
79
82
  ) WITHOUT ROWID;
80
83
  CREATE INDEX IF NOT EXISTS idx_mikser_snapshots_parent ON mikser_snapshots(parent) WHERE parent IS NOT NULL;
81
- `)
84
+ `
85
+ registerSchema('mikser_snapshots', SNAPSHOTS_SCHEMA)
82
86
 
83
87
  // Module-level DB handle + prepared statements for the lifecycle
84
88
  // integration. Tests build their own via `createManifest(db)` —
@@ -186,9 +190,34 @@ function snapToRow(snap) {
186
190
  }
187
191
  }
188
192
 
189
- async function hashOutputFile(destination) {
193
+ // Where a `destination` actually is on disk.
194
+ //
195
+ // Two shapes are in circulation and both are legitimate. A page's is
196
+ // output-relative with a leading slash (`/bg/index.html`); an asset's is a
197
+ // real filesystem path, because assets.js builds it from
198
+ // `runtime.options.assetsFolder`, which resolves inside the WORKING folder
199
+ // and can sit outside outputFolder entirely.
200
+ //
201
+ // `path.isAbsolute` cannot separate them — on POSIX `/bg/index.html` is
202
+ // absolute too — so resolve by EXISTENCE, output-relative first since that
203
+ // is the dominant shape. A page destination treated as a filesystem path
204
+ // would otherwise be looked for at the root of the disk.
205
+ //
206
+ // Shared, because the two callers needing it drifted into two separate
207
+ // bugs: verify() reported every asset missing while printing its real
208
+ // path, and hashOutputFile silently recorded no outputHash for one, which
209
+ // left 78% of a real project's snapshots presence-checked only.
210
+ export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
190
211
  if (!destination) return undefined
191
- const filePath = path.join(runtime.options.outputFolder, destination)
212
+ const joined = path.join(outputFolder ?? '', destination)
213
+ if (existsSync(joined)) return joined
214
+ if (path.isAbsolute(destination) && existsSync(destination)) return destination
215
+ return joined
216
+ }
217
+
218
+ async function hashOutputFile(destination) {
219
+ const filePath = resolveOutputPath(destination)
220
+ if (!filePath) return undefined
192
221
  try {
193
222
  const buf = await readFile(filePath)
194
223
  return sha1(buf)
@@ -548,8 +577,17 @@ export function createManifest(db) {
548
577
  for (const row of stmtSelectAll.iterate()) {
549
578
  const snap = rowToSnap(row)
550
579
  if (!snap.destination) continue
551
- claimed.add(snap.destination.replace(/^\/+/, ''))
552
- const filePath = path.join(outputFolder, snap.destination)
580
+ const filePath = resolveOutputPath(snap.destination, outputFolder)
581
+ // Orphan detection compares against a globby walk of
582
+ // outputFolder, so `claimed` has to hold exactly the relative
583
+ // form that walk produces. A destination resolving outside
584
+ // outputFolder can never appear in it and is not claimable;
585
+ // one inside it must be claimed by its relative path, not by
586
+ // the raw string with its leading slashes stripped.
587
+ const relative = path.relative(outputFolder, filePath)
588
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
589
+ claimed.add(relative)
590
+ }
553
591
  if (!existsSync(filePath)) {
554
592
  missing.push({ id: snap.id, destination: snap.destination })
555
593
  continue
@@ -6,6 +6,7 @@ import { globby } from 'globby'
6
6
  import _ from 'lodash'
7
7
  import map from 'p-map'
8
8
  import { reportWarning } from '../report.js'
9
+ import { isFullCycle } from '../utils.js'
9
10
 
10
11
  // Normalize a `options.presets[name]` value to a consistent
11
12
  // { matches, options } shape so callers don't have to inspect which form
@@ -120,6 +121,14 @@ export function assets(options = {}) {
120
121
  if (matchTally.reported) return
121
122
  const configured = Object.keys(options.presets || {})
122
123
  if (!configured.length || !matchTally.evaluated) return
124
+ // Only when the cycle evaluated everything. On an incremental run the
125
+ // evaluated set is whatever changed, so a healthy preset matches
126
+ // nothing in a run of two and the warning fires on every build —
127
+ // which trains the reader to filter it, and the filtered-out line is
128
+ // the real one. The message already told the reader to use --force to
129
+ // check the whole catalog; that condition gates it now instead of
130
+ // annotating it.
131
+ if (!isFullCycle(runtime)) return
123
132
  matchTally.reported = true
124
133
 
125
134
  for (const preset of configured) {
@@ -129,8 +138,7 @@ export function assets(options = {}) {
129
138
  logger.warn(
130
139
  'Assets preset %j matched none of the %d entities evaluated (patterns: %s). ' +
131
140
  'Patterns run against entity.id, which files({ outputFolder }) does NOT prefix — ' +
132
- 'the prefix appears on name and meta.url only. On an incremental run unchanged ' +
133
- 'entities are not re-evaluated; use --force to check the whole catalog.',
141
+ 'the prefix appears on name and meta.url only.',
134
142
  preset, matchTally.evaluated, matches.join(', '))
135
143
  }
136
144
  }
package/src/utils.js CHANGED
@@ -990,6 +990,29 @@ export async function writeOutput(file, bytes) {
990
990
  return true
991
991
  }
992
992
 
993
+ // Did this cycle evaluate the whole corpus, or only what changed?
994
+ //
995
+ // The distinction is what makes a "declared X matched nothing" warning
996
+ // worth printing. On an incremental cycle only changed entities are
997
+ // re-evaluated, so a pattern or preset legitimately matches nothing in a
998
+ // run of two — printing it there means the warning fires on every healthy
999
+ // build, and a warning that always fires is one people filter out, taking
1000
+ // the real instance with it.
1001
+ //
1002
+ // True for: --force, a first-run or wiped database, and a cache
1003
+ // invalidation. Those are exactly the cases where the import gate is
1004
+ // bypassed and every entity is presented for evaluation.
1005
+ // Takes the runtime rather than reading the singleton, so a plugin passes
1006
+ // the one it was injected with — which is the singleton in a real build and
1007
+ // the harness's stand-in under test.
1008
+ export function isFullCycle(rt = runtime) {
1009
+ return !!(
1010
+ rt?.options?.force
1011
+ || rt?.options?.firstRun
1012
+ || rt?.catalog?.cacheInvalidated
1013
+ )
1014
+ }
1015
+
993
1016
  // ── operating-system and file-manager litter ────────────────────────────
994
1017
  //
995
1018
  // Exposing a source folder over a network filesystem (mikser-io-webdav) or