uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,1982 @@
1
+ /**
2
+ * uniweb doctor - Diagnose project configuration issues
3
+ */
4
+
5
+ import {
6
+ existsSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ writeFileSync,
10
+ mkdirSync,
11
+ rmSync
12
+ } from 'node:fs'
13
+ import { join, resolve, basename, dirname, relative } from 'node:path'
14
+ import yaml from 'js-yaml'
15
+ import {
16
+ resolveFoundationSrcPath,
17
+ classifyPackage,
18
+ isExtensionPackage as buildIsExtensionPackage
19
+ } from '@uniweb/build'
20
+ import { loadDeployYml, AGENTS_KEYS } from '@uniweb/build/site'
21
+ import { listAdapters } from '@uniweb/build/hosts'
22
+ import { RUNTIME_KEYS } from '@uniweb/build/uwx'
23
+ import { getCliVersion } from '../versions.js'
24
+ import { readAgentsVersion } from '../utils/agents-stamp.js'
25
+ import { writeJsonPreservingStyle } from '../utils/json-file.js'
26
+ import { surveyWorkspaceDeps } from '../utils/dep-survey.js'
27
+ import { discoverFoundations, discoverSites } from '../utils/discover.js'
28
+ import { checkSiteInstall } from '../utils/install-integrity.js'
29
+ import { findWorkspaceRoot } from '../utils/workspace.js'
30
+ import { DATA_DIR } from '@uniweb/core/data-paths'
31
+ import { FAMILIES, resolveFamily } from '@uniweb/schemas/families'
32
+ import { suggestFamily } from '@uniweb/schemas/family-aliases'
33
+
34
+ /**
35
+ * Parse the `--fix [<issue-id>]` flag.
36
+ *
37
+ * Returns:
38
+ * null — no auto-fix requested.
39
+ * 'all' — fix every auto-fixable issue.
40
+ * '<issue-id>' — fix only issues with this id.
41
+ */
42
+ function parseFixFlag(args) {
43
+ const idx = args.indexOf('--fix')
44
+ if (idx === -1) return null
45
+ const next = args[idx + 1]
46
+ if (!next || next.startsWith('--')) return 'all'
47
+ return next
48
+ }
49
+
50
+ // ANSI colors
51
+ const colors = {
52
+ reset: '\x1b[0m',
53
+ bright: '\x1b[1m',
54
+ dim: '\x1b[2m',
55
+ red: '\x1b[31m',
56
+ green: '\x1b[32m',
57
+ yellow: '\x1b[33m',
58
+ blue: '\x1b[36m'
59
+ }
60
+
61
+ const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
62
+ const warn = (msg) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`)
63
+ const error = (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`)
64
+ const info = (msg) => console.log(`${colors.blue}→${colors.reset} ${msg}`)
65
+ const log = console.log
66
+
67
+ /**
68
+ * Check if a directory is a site
69
+ */
70
+ function isSite(dir) {
71
+ // Use the canonical classifier; also accept legacy `site.yaml`
72
+ // (the classifier only recognizes `.yml` and `.document.yml`).
73
+ return classifyPackage(dir) === 'site' || existsSync(join(dir, 'site.yaml'))
74
+ }
75
+
76
+ /**
77
+ * Check if a directory is a foundation
78
+ */
79
+ function isFoundation(dir) {
80
+ return classifyPackage(dir) === 'foundation'
81
+ }
82
+
83
+ /**
84
+ * Load a foundation's built schema.json.
85
+ *
86
+ * ⛔ THE PATH IS `dist/meta/schema.json`, and it read `dist/schema.json` from
87
+ * 2026-02-06 until 2026-09-15 — a file the build has never written. So this
88
+ * returned `null` every time, and its one caller (the extension `vars`/layouts
89
+ * warning, which `framework/CLAUDE.md` gotcha #18 says doctor performs) could
90
+ * not fire at all.
91
+ *
92
+ * ⭐ THE CLASS IS WORTH MORE THAN THE BUG: a wrong PATH returns empty, it does
93
+ * not throw. `existsSync` said no, the function returned `null`, every caller
94
+ * used `?.` and skipped — and a check that never fires looks exactly like a
95
+ * codebase with nothing to report.
96
+ */
97
+ function loadSchemaJson(dir) {
98
+ const schemaPath = join(dir, 'dist', 'meta', 'schema.json')
99
+ if (!existsSync(schemaPath)) return null
100
+ try {
101
+ return JSON.parse(readFileSync(schemaPath, 'utf8'))
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Check if a foundation is an extension (via schema.json or foundation.js)
109
+ */
110
+ function isExtensionPackage(dir) {
111
+ return buildIsExtensionPackage(dir)
112
+ }
113
+
114
+ /**
115
+ * Load package.json from a directory
116
+ */
117
+ function loadPackageJson(dir) {
118
+ const pkgPath = join(dir, 'package.json')
119
+ if (!existsSync(pkgPath)) return null
120
+ try {
121
+ return JSON.parse(readFileSync(pkgPath, 'utf8'))
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Load site.yml from a directory
129
+ */
130
+ function loadSiteYml(dir) {
131
+ const ymlPath = join(dir, 'site.yml')
132
+ const yamlPath = join(dir, 'site.yaml')
133
+ const configPath = existsSync(ymlPath)
134
+ ? ymlPath
135
+ : existsSync(yamlPath)
136
+ ? yamlPath
137
+ : null
138
+ if (!configPath) return null
139
+ try {
140
+ return yaml.load(readFileSync(configPath, 'utf8'))
141
+ } catch {
142
+ return null
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Diagnose the compiled-collection output directory.
148
+ *
149
+ * `public/<DATA_DIR>/` holds what the build compiles from `records/`, and
150
+ * nothing else — `records/` is the only supported way to provide structured
151
+ * data. Two consequences, both checked here:
152
+ *
153
+ * 1. **The mapping is a bijection.** Every entry should be backed by a
154
+ * declared collection. An entry that isn't is stale — most often a
155
+ * collection removed from `site.yml`, whose compiled records stay on disk
156
+ * and keep being deployed. The build reconciles *within* a collection it
157
+ * still knows about; it cannot reconcile one it has never heard of, which
158
+ * is exactly the case that needs a declaration to compare against.
159
+ *
160
+ * 2. **It should not be committed.** It is generated into the source tree
161
+ * rather than `dist/`, so without an ignore rule it looks permanent, gets
162
+ * committed, and then looks like something you may edit. That is how
163
+ * hand-authored files ended up there in the first place.
164
+ */
165
+ /**
166
+ * Whether any component source under `dir` matches `pattern`.
167
+ *
168
+ * Only the directories a foundation puts components in, and never
169
+ * `node_modules` — this answers "did the developer write this", so a match
170
+ * inside a dependency is the wrong answer.
171
+ */
172
+ /**
173
+ * Does this foundation render kit's <Prose> or <Article> — under its own name or an alias?
174
+ *
175
+ * The JSX tag alone missed `import { Article as ArticleBody } from '@uniweb/kit'`, which
176
+ * renders `<ArticleBody>`: two official templates shipped that way with no prose styles
177
+ * until 2026-09-29. So the import counts too. (The foundation build checks the same thing
178
+ * from its module graph, which no alias can hide from.)
179
+ *
180
+ * @param {string} dir - the foundation's source directory
181
+ * @returns {boolean}
182
+ */
183
+ export function rendersKitProse(dir) {
184
+ return (
185
+ sourceMatches(dir, /<(Prose|Article)\b/) ||
186
+ sourceMatches(dir, /import\s*\{[^}]*\b(Prose|Article)\b[^}]*\}\s*from\s*['"]@uniweb\/kit['"]/)
187
+ )
188
+ }
189
+
190
+ function sourceMatches(dir, pattern) {
191
+ let found = false
192
+ forEachSourceFile(dir, (text) => {
193
+ // `pattern` may carry /g, whose lastIndex persists across .test() calls —
194
+ // reset so a second file is not silently skipped.
195
+ pattern.lastIndex = 0
196
+ if (pattern.test(text)) found = true
197
+ })
198
+ return found
199
+ }
200
+
201
+ /**
202
+ * Read every component source file under `dir`, handing each one's text to `fn`.
203
+ *
204
+ * Same roots and same exclusions as `sourceMatches` (which is built on this):
205
+ * only where a foundation puts components, never `node_modules`, because the
206
+ * question is always "did the developer write this".
207
+ */
208
+ function forEachSourceFile(dir, fn) {
209
+ const roots = ['sections', 'layouts', 'components', 'utils']
210
+ const walk = (d, depth = 0) => {
211
+ if (depth > 6) return
212
+ let entries
213
+ try {
214
+ entries = readdirSync(d, { withFileTypes: true })
215
+ } catch {
216
+ return
217
+ }
218
+ for (const entry of entries) {
219
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
220
+ const p = join(d, entry.name)
221
+ if (entry.isDirectory()) {
222
+ walk(p, depth + 1)
223
+ } else if (/\.(jsx?|tsx?)$/.test(entry.name)) {
224
+ try {
225
+ fn(readFileSync(p, 'utf8'), relative(dir, p))
226
+ } catch {
227
+ // unreadable file — not this check's problem to report
228
+ }
229
+ }
230
+ }
231
+ }
232
+ for (const r of roots) walk(join(dir, r))
233
+ }
234
+
235
+ // A fenced data block tagged `form`, in any of the serialization formats the
236
+ // parser accepts for one. Matched on the info string only — the body is the
237
+ // author's business.
238
+ //
239
+ // No whitespace is allowed around the colon, because the parser allows none:
240
+ // `processCodeInfo` in @uniweb/content-reader does a bare `info.split(':')` and
241
+ // trims neither half, so ```` ```yaml: form ```` yields the tag `" form"` and
242
+ // lands at `content.data[" form"]`. Matching it here would warn about a block
243
+ // that is not a form.
244
+ const FORM_BLOCK = /^\s*`{3,}\s*(?:yaml|yml|json):form\b/m
245
+
246
+ /**
247
+ * Find the content files declaring a form, so we can tell whether having no
248
+ * submission destination matters for this site.
249
+ *
250
+ * Scans the page tree and layout only. A form is authored where it renders, and
251
+ * widening this to collections would trade a slower check for cases that do not
252
+ * occur.
253
+ */
254
+ export function findFormContent(sitePath, siteYml) {
255
+ const roots = [
256
+ siteYml?.paths?.pages ? join(sitePath, siteYml.paths.pages) : join(sitePath, 'pages'),
257
+ join(sitePath, 'layout')
258
+ ]
259
+ const found = []
260
+
261
+ const walk = (dir) => {
262
+ let entries
263
+ try {
264
+ entries = readdirSync(dir, { withFileTypes: true })
265
+ } catch {
266
+ return // absent or unreadable — not this check's problem to report
267
+ }
268
+ for (const entry of entries) {
269
+ const p = join(dir, entry.name)
270
+ if (entry.isDirectory()) {
271
+ walk(p)
272
+ } else if (entry.name.endsWith('.md')) {
273
+ try {
274
+ if (FORM_BLOCK.test(readFileSync(p, 'utf8'))) found.push(relative(sitePath, p))
275
+ } catch {
276
+ // unreadable file — skip
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ for (const root of roots) walk(root)
283
+ return found
284
+ }
285
+
286
+ /**
287
+ * A misspelled key in `agents:` is silent everywhere else, forever.
288
+ *
289
+ * The block reaches a backend as **opaque JSON** — measured 2026-08-13, and it
290
+ * is the right design: the vocabulary is the framework's, and a backend that
291
+ * validated it would be inventing our language. The cost is that nothing
292
+ * downstream can object. A host receives `expectedOrgins`, stores it, forwards
293
+ * it, and enforces nothing; the author sees a configured site and gets no
294
+ * check. **This is the only lane that can catch it.**
295
+ *
296
+ * ⛔ Severity is deliberate. It is a `warning` for an unknown key generally,
297
+ * but the failure it prevents is worst for the keys that RESTRICT something —
298
+ * an author who writes `expectedOrgins` believes they asked for a check they
299
+ * did not get. A key that silently does nothing is the recurring wart of this
300
+ * block: `exclude`'s glob spelling excludes nothing, and a carried key nobody
301
+ * reads enforces nothing.
302
+ *
303
+ * `agents: false` turns the capability off in one word and is not an object —
304
+ * flagging its absence of keys would be nonsense.
305
+ */
306
+ export function checkAgentsBlock({ siteName, siteYml, issues }) {
307
+ const agents = siteYml?.agents
308
+ if (agents === undefined || agents === false) return
309
+ if (!agents || typeof agents !== 'object' || Array.isArray(agents)) {
310
+ const id = 'agents-not-an-object'
311
+ issues.push({
312
+ id,
313
+ type: 'warning',
314
+ site: siteName,
315
+ message: `site.yml: \`agents:\` should be a map of options, or \`false\` to turn it off`
316
+ })
317
+ warn(`[${id}] ${siteName}: \`agents:\` is neither a map of options nor \`false\`.`)
318
+ return
319
+ }
320
+
321
+ const known = new Set(AGENTS_KEYS)
322
+ const unknown = Object.keys(agents).filter((k) => !known.has(k))
323
+ if (unknown.length === 0) return
324
+
325
+ const id = 'agents-unknown-key'
326
+ issues.push({
327
+ id,
328
+ type: 'warning',
329
+ site: siteName,
330
+ message: `site.yml: \`agents:\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
331
+ })
332
+ warn(
333
+ `[${id}] ${siteName}: \`agents:\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
334
+ .map((k) => `'${k}'`)
335
+ .join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
336
+ )
337
+ for (const key of unknown) {
338
+ const near = nearestKnownKey(key, known)
339
+ if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
340
+ }
341
+ log(
342
+ ` ${colors.dim}Nothing downstream will reject it: the block is forwarded to the host as opaque data,${colors.reset}`
343
+ )
344
+ log(
345
+ ` ${colors.dim}so an unrecognized key is carried and never acted on. Known: ${AGENTS_KEYS.join(', ')}.${colors.reset}`
346
+ )
347
+ }
348
+
349
+ /**
350
+ * The closest known key within a small edit distance, or null.
351
+ *
352
+ * Suggestions are capped at distance 3 rather than "closest wins": on a
353
+ * six-word vocabulary an uncapped nearest-match confidently proposes `index`
354
+ * for `origins`, and a wrong suggestion is worse than none — it sends the
355
+ * author to change a line that was not their mistake.
356
+ */
357
+ function nearestKnownKey(input, known) {
358
+ let best = null
359
+ let bestScore = Infinity
360
+ for (const candidate of known) {
361
+ const score = editDistance(input.toLowerCase(), candidate.toLowerCase())
362
+ if (score < bestScore) {
363
+ bestScore = score
364
+ best = candidate
365
+ }
366
+ }
367
+ return bestScore <= 3 ? best : null
368
+ }
369
+
370
+ /**
371
+ * The options `services.tracking` has a reader for — the build's list, which also
372
+ * decides what a push sends the host as its settings (`RUNTIME_KEYS`, `@uniweb/build`).
373
+ * ⛔ *Until 2026-10-06 doctor kept a copy of its own, which had drifted: it lacked
374
+ * `emit` and `flushIntervalMs`, which `wireTracker` reads.*
375
+ */
376
+ const TRACKING_KEYS = RUNTIME_KEYS.tracking
377
+
378
+ /**
379
+ * Spellings that read as an ATTEMPT to require consent but do not require it.
380
+ *
381
+ * `consentRequired` is `options.consent === 'required'` — an exact match — so
382
+ * every one of these silently means *no gate*. That is the one field in this
383
+ * block where a silent no-op has a consequence beyond a missing metric.
384
+ *
385
+ * ⚖️ Deliberately not "anything that is not `required`": `consent: none` is a
386
+ * documented, useful value — it is how a site overrides a gate its **host**
387
+ * declared, under the per-key tier fill. Flagging that would flag correct code.
388
+ */
389
+ const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true', 'yes', 'on', '1'])
390
+
391
+ /**
392
+ * `services.tracking` — flag the keys and values that are carried and never acted on.
393
+ *
394
+ * A key the site's tracking does not read goes to the host as a setting, and the
395
+ * rest is resolved at render, so nothing downstream rejects a mistake in it. Every error here therefore fails
396
+ * the same way: **silently, at a visitor's browser, as an absence** — which is
397
+ * also exactly what a site that configured nothing looks like. There is no
398
+ * symptom to notice and nothing to grep for.
399
+ *
400
+ * ⭐ That is the whole argument for checking it at `doctor` time: it is the only
401
+ * moment in the chain where a person who can fix it is looking at it.
402
+ *
403
+ * An address, `true` or `false` carries no options — there is nothing in it to be
404
+ * wrong.
405
+ */
406
+ /**
407
+ * `uniweb doctor` — is `package.json` behind what the build derived?
408
+ *
409
+ * A host offers services (`config.services.<name>`); a foundation has to render
410
+ * something against one, or an operator who provisioned it gets nothing and no
411
+ * error. `uniweb.supports` states which ones a foundation honours, and it rides
412
+ * to the registry as `info.supports` (`build/src/uwx/registry-package.js`).
413
+ *
414
+ * ## ⭐ THE BUILD ALREADY ANSWERED THIS — so read its answer, do not re-derive
415
+ *
416
+ * `@uniweb/build` derives the set from the post-tree-shake module graph and
417
+ * writes it to `dist/meta/schema.json`'s `_self.supports`
418
+ * (`build/src/foundation/derive-supports.js`). That value is what publishes,
419
+ * with or without this check, so the foundation is already correct on the wire.
420
+ *
421
+ * ⛔ **What was here before was a regex over `sections/`, `layouts/` and
422
+ * `components/`, and it did not work.** It knew four spellings and
423
+ * `useFormSubmit()` — the official submit gate — was not one, so it was silent
424
+ * on `templates/services`, the template written to demonstrate the feature. Its
425
+ * test could not find that, because the fixture was a source string written to
426
+ * match the regex. ⇒ **Do not reintroduce a second, weaker derivation here.**
427
+ * There is one, it runs in the build, and this reads it.
428
+ *
429
+ * ## So what is left for `doctor` to say
430
+ *
431
+ * Only that the FILE disagrees with the artifact. Nothing is broken when it
432
+ * does — but `uniweb.supports` is the one place a human can add a service the
433
+ * graph cannot see (a computed name), so a developer who never sees the derived
434
+ * set never learns the key exists, and never adds the one thing only they know.
435
+ *
436
+ * `--fix` writes the derived names in, where they land in a diff the developer
437
+ * reads. ⛔ The build never does this itself: rewriting source on every build is
438
+ * a surprise, and the artifact needs no help.
439
+ *
440
+ * Silent when the foundation has not been built — nothing has been measured, so
441
+ * there is nothing to compare and a guess would be worse than quiet.
442
+ */
443
+ export async function checkFoundationSupports({
444
+ foundationName,
445
+ folderName,
446
+ foundationDir,
447
+ pkg,
448
+ issues,
449
+ shouldFix,
450
+ fixed,
451
+ }) {
452
+ const schemaPath = join(foundationDir, 'dist', 'meta', 'schema.json')
453
+ if (!existsSync(schemaPath)) return
454
+
455
+ let derived
456
+ try {
457
+ derived = JSON.parse(readFileSync(schemaPath, 'utf8'))?._self?.supports
458
+ } catch {
459
+ return // unreadable or malformed build output — not this check's to report
460
+ }
461
+ // Absent means the build derived nothing and saw a computed name, so it
462
+ // reported UNKNOWN. There is no gap to name.
463
+ if (!Array.isArray(derived) || derived.length === 0) return
464
+
465
+ const declared = pkg?.uniweb?.supports
466
+ const hasDeclaration = Array.isArray(declared)
467
+ const missing = derived.filter((name) => !hasDeclaration || !declared.includes(name)).sort()
468
+ if (missing.length === 0) return
469
+
470
+ const id = 'foundation-supports-incomplete'
471
+ const list = missing.join(', ')
472
+ const merged = [...new Set([...(hasDeclaration ? declared : []), ...derived])].sort()
473
+
474
+ if (shouldFix?.(id)) {
475
+ const pkgPath = join(foundationDir, 'package.json')
476
+ try {
477
+ const src = readFileSync(pkgPath, 'utf8')
478
+ const parsed = JSON.parse(src)
479
+ parsed.uniweb = { ...(parsed.uniweb || {}), supports: merged }
480
+ writeJsonPreservingStyle(pkgPath, parsed, src)
481
+ fixed?.(`${folderName}/package.json now declares uniweb.supports: ${merged.join(', ')}`)
482
+ return
483
+ } catch (e) {
484
+ // Fall through to the warning — a failed write must not read as fixed.
485
+ warn(`[${id}] could not write ${folderName}/package.json: ${e.message}`)
486
+ }
487
+ }
488
+
489
+ issues.push({
490
+ id,
491
+ type: 'warning',
492
+ foundation: foundationName,
493
+ message: hasDeclaration
494
+ ? `${foundationName} builds against ${list} but does not declare ${missing.length === 1 ? 'it' : 'them'} in uniweb.supports`
495
+ : `${foundationName} builds against ${list} but declares no uniweb.supports`,
496
+ details: { derived, declared: hasDeclaration ? declared : null },
497
+ fixable: true,
498
+ })
499
+
500
+ warn(`[${id}] ${foundationName} builds against ${list}, not in ${folderName}/package.json`)
501
+ log(` The build already publishes ${colors.dim}${derived.join(', ')}${colors.reset}, so nothing is missing on the wire.`)
502
+ log(` Declaring it matters when a service is named by a computed value — the`)
503
+ log(` build cannot see those, and yours is the only place they can be added.`)
504
+ log(` ${colors.dim}uniweb doctor --fix ${id}${colors.reset}`)
505
+ }
506
+
507
+ /**
508
+ * Every section type's standard family — what it resolved to, and HOW.
509
+ *
510
+ * ⭐ THIS IS WHERE THE TAXONOMY IS ACTUALLY LEARNED. Nobody reads a word list in
511
+ * advance; they read the warning that names their own component and proposes the
512
+ * edit. A foundation developer sees their own picker here before an author does.
513
+ *
514
+ * ⛔ AN UNRECOGNIZED NAME IS NOT AN ERROR, and this check must never imply it is.
515
+ * A foundation with its own vocabulary is a supported choice: the section falls
516
+ * back to a generic illustration and nothing breaks. What is reported is the
517
+ * OPPORTUNITY — and only where there is a concrete suggestion to act on.
518
+ *
519
+ * ⚠️ READS THE BUILT `schema.json`, so it says nothing before a build. That is
520
+ * deliberate: schema.json is what a picker actually receives, and inferring from
521
+ * unbuilt source would report on a state no consumer sees.
522
+ */
523
+ export function checkSectionFamilies({
524
+ foundationName,
525
+ folderName,
526
+ foundationDir,
527
+ issues,
528
+ shouldFixExplicitly,
529
+ fixed,
530
+ }) {
531
+ const schema = loadSchemaJson(foundationDir)
532
+ if (!schema) return
533
+
534
+ const familyIds = FAMILIES.map((f) => f.id)
535
+ const sections = Object.entries(schema)
536
+ .filter(([key, value]) => !key.startsWith('_') && key !== 'dataSchemas' && value && typeof value === 'object')
537
+ .map(([name, entry]) => ({ name, entry, ...resolveFamily({ name, family: entry.family }) }))
538
+
539
+ if (sections.length === 0) return
540
+
541
+ const declared = sections.filter((s) => s.source === 'declared')
542
+ const byName = sections.filter((s) => s.source === 'name')
543
+ const misses = sections.filter((s) => !s.id)
544
+
545
+ info(
546
+ `${folderName}: ${sections.length - misses.length}/${sections.length} sections have a standard family ` +
547
+ `${colors.dim}(${declared.length} declared, ${byName.length} from the name)${colors.reset}`
548
+ )
549
+
550
+ // ── a `family:` that names nothing we know ────────────────────────────────
551
+ const bogus = misses.filter((s) => s.unknown)
552
+ for (const s of bogus) {
553
+ const id = 'section-family-unknown'
554
+ const near = suggestFamily(s.unknown, familyIds)
555
+ issues.push({
556
+ id,
557
+ type: 'warning',
558
+ foundation: foundationName,
559
+ message: `${s.name} declares family '${s.unknown}', which is not a standard family`,
560
+ })
561
+ warn(`[${id}] ${folderName}/${s.name}: family '${s.unknown}' is not a standard family`)
562
+ if (near.id) log(` Did you mean ${colors.blue}${near.id}${colors.reset}?`)
563
+ log(` ${colors.dim}An unrecognized value is legal — it falls back to a generic illustration.${colors.reset}`)
564
+ log(` ${colors.dim}uniweb families${colors.reset}`)
565
+ }
566
+
567
+ // ── a name we do not know, WITH something to suggest ──────────────────────
568
+ const undeclared = misses.filter((s) => !s.unknown)
569
+ const proposals = undeclared
570
+ .map((s) => ({ ...s, suggestion: suggestFamily(s.name, familyIds) }))
571
+ .filter((s) => s.suggestion.id || s.suggestion.ambiguous)
572
+ if (proposals.length === 0) return
573
+
574
+ const id = 'section-family-unrecognized'
575
+ const writable = proposals.filter((p) => p.suggestion.fixable && p.suggestion.id)
576
+
577
+ // ⛔ EXPLICIT ID ONLY — never a bare `--fix`. Every id below is a GUESS from a
578
+ // table that exists here, rather than in the resolver, precisely so a person
579
+ // approves it. A bare `--fix` writing them would put the silent-wrong-guess
580
+ // failure back, one directory deeper.
581
+ if (shouldFixExplicitly?.(id) && writable.length > 0) {
582
+ const srcDir = resolveFoundationSrcPath(foundationDir)
583
+ for (const p of writable) {
584
+ const metaPath = join(srcDir, p.entry.path || '', 'meta.js')
585
+ const written = writeFamilyIntoMeta(metaPath, p.suggestion.id)
586
+ if (written) fixed?.(`${folderName}/${p.entry.path}/meta.js now declares family: '${p.suggestion.id}'`)
587
+ else warn(`[${id}] could not write ${p.name}'s meta.js — add \`family: '${p.suggestion.id}'\` by hand`)
588
+ }
589
+ return
590
+ }
591
+
592
+ issues.push({
593
+ id,
594
+ type: 'info',
595
+ foundation: foundationName,
596
+ message: `${proposals.length} section(s) in ${foundationName} could declare a standard family`,
597
+ details: proposals.map((p) => ({ name: p.name, suggested: p.suggestion.id, via: p.suggestion.via })),
598
+ fixable: writable.length > 0,
599
+ })
600
+
601
+ warn(`[${id}] ${folderName}: ${proposals.length} section(s) have no standard family`)
602
+ const width = Math.max(...proposals.map((p) => p.name.length))
603
+ for (const p of proposals) {
604
+ const { id: hit, via, ambiguous } = p.suggestion
605
+ if (ambiguous) {
606
+ log(` ${p.name.padEnd(width)} ${colors.dim}ambiguous — ${ambiguous}${colors.reset}`)
607
+ } else {
608
+ const how = via === 'near' ? 'possible typo' : via === 'suffix' ? 'name + shape suffix' : 'known alias'
609
+ log(` ${p.name.padEnd(width)} → ${colors.blue}${hit}${colors.reset} ${colors.dim}(${how})${colors.reset}`)
610
+ }
611
+ }
612
+ log(` ${colors.dim}Nothing is broken — an unrecognized name falls back to a generic illustration.${colors.reset}`)
613
+ if (writable.length > 0) {
614
+ log(` ${colors.dim}uniweb doctor --fix ${id}${colors.reset} ${colors.dim}— writes ${writable.length} \`family:\` line(s). Read the diff: these are guesses.${colors.reset}`)
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Insert `family: '<id>'` as the first key of a meta.js default export.
620
+ *
621
+ * ⛔ REFUSES ANYTHING IT DOES NOT RECOGNIZE. A meta.js is hand-written source,
622
+ * and a half-understood rewrite of someone's file is far worse than telling them
623
+ * to add one line. A `false` return is reported as "could not write", never as
624
+ * fixed.
625
+ */
626
+ function writeFamilyIntoMeta(metaPath, familyId) {
627
+ if (!existsSync(metaPath)) return false
628
+ try {
629
+ const src = readFileSync(metaPath, 'utf8')
630
+ if (/^\s*family\s*:/m.test(src)) return false // already has one — never overwrite
631
+ const m = src.match(/export\s+default\s*\{[ \t]*\r?\n/)
632
+ if (!m) return false
633
+ const at = m.index + m[0].length
634
+ const indent = src.slice(at).match(/^([ \t]+)/)?.[1] ?? ' '
635
+ writeFileSync(metaPath, `${src.slice(0, at)}${indent}family: '${familyId}',\n${src.slice(at)}`)
636
+ return true
637
+ } catch {
638
+ return false
639
+ }
640
+ }
641
+
642
+ /**
643
+ * `category:` and `purpose:` were retired when `family:` landed.
644
+ *
645
+ * ⚖️ INFO, NOT A WARNING. Both are inert — nothing in the build, the runtime or
646
+ * the wire has ever read either — so a foundation carrying one is not broken and
647
+ * does not need to hurry. It is worth saying once, because a developer who sees
648
+ * `category:` in a neighbour's meta.js will reasonably copy it.
649
+ */
650
+ export function checkRetiredMetaKeys({ foundationName, folderName, foundationDir, issues }) {
651
+ const schema = loadSchemaJson(foundationDir)
652
+ if (!schema) return
653
+ const stale = Object.entries(schema)
654
+ .filter(([key, v]) => !key.startsWith('_') && key !== 'dataSchemas' && v && typeof v === 'object')
655
+ .filter(([, v]) => v.category !== undefined || v.purpose !== undefined)
656
+ if (stale.length === 0) return
657
+
658
+ const id = 'section-retired-meta-key'
659
+ const keys = [...new Set(stale.flatMap(([, v]) => [
660
+ v.category !== undefined ? 'category' : null,
661
+ v.purpose !== undefined ? 'purpose' : null,
662
+ ].filter(Boolean)))]
663
+ issues.push({
664
+ id,
665
+ type: 'info',
666
+ foundation: foundationName,
667
+ message: `${stale.length} section(s) still declare ${keys.join(' / ')}`,
668
+ })
669
+ info(
670
+ `[${id}] ${folderName}: ${stale.length} section(s) still declare ` +
671
+ `${colors.dim}${keys.join(' / ')}${colors.reset} — retired, and read by nothing`
672
+ )
673
+ log(` ${colors.dim}\`family:\` replaced them. Removing them changes no behaviour.${colors.reset}`)
674
+ }
675
+
676
+ /**
677
+ * The gates kit exposes for a host service, and the field that IS the gate.
678
+ *
679
+ * Each hook returns a boolean saying whether the site actually has the service.
680
+ * A component that ignores it draws a control the visitor cannot use — a search
681
+ * box that searches nothing, a form whose answers have nowhere to go.
682
+ */
683
+ const SERVICE_GATES = [
684
+ { hook: 'useFormSubmit', gate: 'canSubmit', alt: 'isSubmitEnabled', draws: 'a form' },
685
+ { hook: 'useSearch', gate: 'isEnabled', alt: 'isSearchEnabled', draws: 'a search control' },
686
+ { hook: 'useSearchIndex', gate: 'isEnabled', alt: 'isSearchEnabled', draws: 'a search control' },
687
+ // Returns everything `useSearch` does, `isEnabled` included, so it gates the
688
+ // same way. It was missing from this list until the hook was renamed out of
689
+ // `useSearchWithIntent` — the kind of gap a list of names grows quietly.
690
+ { hook: 'useSearchPrefetch', gate: 'isEnabled', alt: 'isSearchEnabled', draws: 'a search control' },
691
+ ]
692
+
693
+ /**
694
+ * `uniweb doctor` — a control drawn for a service the site may not have.
695
+ *
696
+ * `resolveService` answers with an address or nothing, and nothing is a normal
697
+ * state: the operator did not provision that service. ⭐ **The rule is that a
698
+ * control for a service the site does not have must not be drawn** — no error,
699
+ * no apology, no explanatory string (there is deliberately none to show, since
700
+ * any wording would be ours to invent, in one language, for a visitor with no
701
+ * stake in it). The hooks hand you a boolean for exactly this, and a component
702
+ * that never reads it renders something permanently dead.
703
+ *
704
+ * ## ⚖️ WHY A REGEX IS ACCEPTABLE HERE, HAVING JUST BEEN REJECTED NEXT DOOR
705
+ *
706
+ * `checkFoundationSupports` above no longer scans source, because deriving
707
+ * *which services a foundation reaches* requires following calls through
708
+ * `@uniweb/kit` into `@uniweb/core`, and a missed match there silently costs a
709
+ * capability on the wire. This question is a different shape:
710
+ *
711
+ * - it is **local to one file** — the hook call and its gate are in the same
712
+ * component, in the developer's own source, with no indirection to follow;
713
+ * - the gate names are **fixed by kit's API**, not inferred — with one caveat
714
+ * that used to be a live false negative: `isEnabled` is a hook's returned
715
+ * field AND, until 2026-09-10, an `@uniweb/api` export. A component holding
716
+ * both read as gated. `@uniweb/kit`'s `is<Service>Enabled()` predicates are
717
+ * unique per subject, so `alt` below is matched instead and the collision
718
+ * cannot recur;
719
+ * - it only ever **warns**, and changes no artifact. A miss costs a missing
720
+ * warning, which is the safe direction.
721
+ *
722
+ * ⛔ Its limits, stated rather than discovered: a component that gates in its
723
+ * parent reads as ungated here, and one that destructures the gate without
724
+ * using it reads as gated. Both are warnings about a real smell either way, and
725
+ * neither can produce a wrong artifact.
726
+ */
727
+ /**
728
+ * Source with its comments removed.
729
+ *
730
+ * ⚠️ MEASURED, NOT PRECAUTIONARY. The first cut of this check searched the raw
731
+ * text, and passed on `templates/services` with the gate deliberately deleted
732
+ * from the code — because the component's JSDoc still said `canSubmit`. Prose
733
+ * about a gate is not a gate.
734
+ *
735
+ * ⛔ Whole-line and block comments only, deliberately. A line that begins with
736
+ * `//` or `*` is unambiguously a comment, so stripping it can never remove real
737
+ * code; hunting trailing comments would mean deciding whether a `//` sits
738
+ * inside a string, and getting that wrong deletes code and invents a warning.
739
+ * The residue is that a gate named ONLY in a trailing comment still reads as
740
+ * used, which is a missed warning — the safe direction.
741
+ */
742
+ function stripComments(src) {
743
+ return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
744
+ }
745
+
746
+ export function checkUngatedServiceControls({ foundationName, folderName, srcDir, issues }) {
747
+ const found = []
748
+
749
+ forEachSourceFile(srcDir, (raw, file) => {
750
+ const text = stripComments(raw)
751
+ for (const { hook, gate, alt, draws } of SERVICE_GATES) {
752
+ // A call, not merely an import: re-exporting a hook is not drawing with it.
753
+ if (!new RegExp(`\\b${hook}\\s*\\(`).test(text)) continue
754
+ // Either the hook's own returned field, or kit's standalone predicate for
755
+ // the same service — both are the gate, and a component that reads one
756
+ // does not need the other.
757
+ if (new RegExp(`\\b${gate}\\b`).test(text)) continue
758
+ if (alt && new RegExp(`\\b${alt}\\s*\\(`).test(text)) continue
759
+ found.push({ file, hook, gate, alt, draws })
760
+ }
761
+ })
762
+
763
+ if (found.length === 0) return
764
+
765
+ const id = 'ungated-service-control'
766
+ for (const f of found) {
767
+ issues.push({
768
+ id,
769
+ type: 'warning',
770
+ foundation: foundationName,
771
+ message: `${foundationName}/${f.file} calls ${f.hook}() without reading ${f.gate}`,
772
+ details: f,
773
+ })
774
+ warn(`[${id}] ${folderName}/${f.file} calls ${f.hook}() and never reads ${f.gate}`)
775
+ log(` It draws ${f.draws} even on a site with no such service, where the`)
776
+ log(` control cannot work. Gate on it instead:`)
777
+ log(` ${colors.dim}const { ${f.gate} } = ${f.hook}(…)${colors.reset}`)
778
+ log(` ${colors.dim}if (!${f.gate}) return null${colors.reset}`)
779
+ if (f.alt) log(` ${colors.dim}…or ${f.alt}() from @uniweb/kit, which needs no hook.${colors.reset}`)
780
+ }
781
+ }
782
+
783
+ export function checkTrackingBlock({ siteName, siteYml, issues }) {
784
+ const tracking = siteYml?.services?.tracking
785
+ if (tracking === undefined || tracking === null || typeof tracking === 'boolean') return
786
+ if (typeof tracking === 'string') return
787
+
788
+ if (typeof tracking !== 'object' || Array.isArray(tracking)) {
789
+ const id = 'tracking-not-an-object'
790
+ issues.push({
791
+ id,
792
+ type: 'warning',
793
+ site: siteName,
794
+ message: `site.yml: \`services.tracking\` should be true, false, an address, or a map of options`
795
+ })
796
+ warn(`[${id}] ${siteName}: \`services.tracking\` is neither true, false, an address nor a map of options.`)
797
+ return
798
+ }
799
+
800
+ const known = new Set(TRACKING_KEYS)
801
+ const unknown = Object.keys(tracking).filter((k) => !known.has(k))
802
+ if (unknown.length > 0) {
803
+ const id = 'tracking-unknown-key'
804
+ issues.push({
805
+ id,
806
+ type: 'warning',
807
+ site: siteName,
808
+ message: `site.yml: \`services.tracking\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
809
+ })
810
+ warn(
811
+ `[${id}] ${siteName}: \`services.tracking\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
812
+ .map((k) => `'${k}'`)
813
+ .join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
814
+ )
815
+ for (const key of unknown) {
816
+ const near = nearestKnownKey(key, known)
817
+ if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
818
+ }
819
+ log(
820
+ ` ${colors.dim}Not an option the site's tracking reads — sent to your host as a setting. Known: ${TRACKING_KEYS.join(', ')}.${colors.reset}`
821
+ )
822
+ }
823
+
824
+ // `consent` is exact-matched against 'required'. A near miss is not a typo
825
+ // with a cosmetic cost — it is a consent gate the author believes they asked
826
+ // for and did not get.
827
+ if ('consent' in tracking) {
828
+ const value = tracking.consent
829
+ const spelling = value === null || value === undefined ? '' : String(value).trim().toLowerCase()
830
+
831
+ // ⛔ The gate test is against the RAW value, because the runtime's is:
832
+ // `options.consent === 'required'`, exact and case-sensitive. Lowercasing
833
+ // before this comparison made `Required` look correct to the checker — the
834
+ // check stopped testing the thing it was written to catch. The normalized
835
+ // spelling is only for deciding whether a wrong value reads as an ATTEMPT.
836
+ const gateIsOn = value === 'required'
837
+ const looksIntended =
838
+ spelling === '' || spelling === 'required' || CONSENT_NEAR_MISSES.has(spelling)
839
+
840
+ if (!gateIsOn && looksIntended) {
841
+ const id = 'tracking-consent-not-required'
842
+ const shown = spelling === '' ? 'an empty value' : `\`${String(value)}\``
843
+ issues.push({
844
+ id,
845
+ type: 'warning',
846
+ site: siteName,
847
+ message: `site.yml: \`services.tracking.consent\` is ${shown} — only the exact value \`required\` turns the gate on`
848
+ })
849
+ warn(`[${id}] ${siteName}: \`services.tracking.consent\` is ${shown}; the gate is OFF.`)
850
+ log(
851
+ ` ${colors.dim}Only \`consent: required\` holds events until a visitor answers. Anything else${colors.reset}`
852
+ )
853
+ log(
854
+ ` ${colors.dim}sends immediately — including a blank value, which YAML reads as null.${colors.reset}`
855
+ )
856
+ }
857
+ }
858
+
859
+ // An entry that yields no URL is dropped by the loader without a sound.
860
+ const declared = tracking.scripts
861
+ if (declared !== undefined) {
862
+ const list = Array.isArray(declared) ? declared : [declared]
863
+ const bad = list.filter(
864
+ (e) => !(typeof e === 'string' ? e.trim() : typeof e?.src === 'string' && e.src.trim())
865
+ )
866
+ if (bad.length > 0) {
867
+ const id = 'tracking-script-without-url'
868
+ issues.push({
869
+ id,
870
+ type: 'warning',
871
+ site: siteName,
872
+ message: `site.yml: \`services.tracking.scripts\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
873
+ })
874
+ warn(
875
+ `[${id}] ${siteName}: ${bad.length} \`services.tracking.scripts\` ${bad.length === 1 ? 'entry has' : 'entries have'} no URL and will not load.`
876
+ )
877
+ log(
878
+ ` ${colors.dim}An entry is a URL, or an object with a \`src\`. Anything else is dropped silently.${colors.reset}`
879
+ )
880
+ }
881
+ }
882
+ }
883
+
884
+ /** Levenshtein distance, iterative two-row form. */
885
+ function editDistance(a, b) {
886
+ if (a === b) return 0
887
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
888
+ for (let i = 1; i <= a.length; i++) {
889
+ const row = [i]
890
+ for (let j = 1; j <= b.length; j++) {
891
+ row[j] = Math.min(
892
+ prev[j] + 1,
893
+ row[j - 1] + 1,
894
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
895
+ )
896
+ }
897
+ prev = row
898
+ }
899
+ return prev[b.length]
900
+ }
901
+
902
+ /**
903
+ * A site whose content declares a form needs somewhere to send it.
904
+ *
905
+ * Two things can supply that: `services.submit` in site.yml — asking the host for
906
+ * form handling, or naming the site's own — or the host at serve time. Doctor can
907
+ * only see the first — so it warns only when *nothing* could plausibly supply one:
908
+ * no entry, and no deploy target that would put a host in the picture. On a site
909
+ * bound to a host, an absent entry can be the correct configuration, and warning
910
+ * there would nag exactly the people who got it right.
911
+ *
912
+ * The consequence of being wrong in the other direction is what justifies the
913
+ * check at all: a form with no destination is not drawn, which is easy to ship
914
+ * without noticing.
915
+ */
916
+ export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issues }) {
917
+ if (siteYml?.services?.submit) return
918
+
919
+ const forms = findFormContent(sitePath, siteYml)
920
+ if (forms.length === 0) return
921
+
922
+ // A configured deploy target means a host is in the picture and may supply a
923
+ // destination we cannot see from here.
924
+ let deployYml = null
925
+ try {
926
+ deployYml = await loadDeployYml(sitePath)
927
+ } catch {
928
+ // malformed deploy.yml is reported by its own check; treat as absent here
929
+ }
930
+ if (deployYml?.targets && Object.keys(deployYml.targets).length > 0) return
931
+
932
+ const id = 'form-without-submit-target'
933
+ const n = forms.length
934
+ issues.push({
935
+ id,
936
+ type: 'warning',
937
+ site: siteName,
938
+ message: `${n} content file${n === 1 ? '' : 's'} declare${n === 1 ? 's' : ''} a form, but no submission destination is configured`
939
+ })
940
+ warn(`[${id}] ${n} content file${n === 1 ? '' : 's'} declare${n === 1 ? 's' : ''} a form with nowhere to send it`)
941
+ for (const f of forms.slice(0, 5)) log(` • ${f}`)
942
+ if (n > 5) log(` ${colors.dim}…and ${n - 5} more${colors.reset}`)
943
+ log(
944
+ ` Ask your host for form handling — ${colors.green}services: { submit: true }${colors.reset} — or name your own: ${colors.green}services: { submit: https://… }${colors.reset}.`
945
+ )
946
+ log(
947
+ ` ${colors.dim}A host that handles submissions supplies one itself — this check is skipped once a deploy target is configured.${colors.reset}`
948
+ )
949
+ }
950
+
951
+ /** The bare map in `queries.yml`, or `{}` when there is none. */
952
+ function readQueriesYml(sitePath) {
953
+ try {
954
+ const doc = yaml.load(readFileSync(join(sitePath, 'queries.yml'), 'utf8'))
955
+ return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {}
956
+ } catch {
957
+ return {}
958
+ }
959
+ }
960
+
961
+ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix, fixed }) {
962
+ const dataDir = join(sitePath, 'public', DATA_DIR)
963
+ // ⚠️ THE QUERIES, not the pool. `public/<DATA_DIR>/x.json` is a query's
964
+ // MATERIALIZATION — one file per named query — so the bijection is with the
965
+ // declared queries, never with the schema folders under `records/`.
966
+ //
967
+ // ⛔ BOTH HOMES, or this reports every compiled file as an orphan. A site that
968
+ // keeps its queries in `queries.yml` has none in `site.yml`, and reading one
969
+ // file would turn the whole check into a false positive — the loudest possible
970
+ // failure for a check whose job is to find stale output.
971
+ //
972
+ // Read directly rather than through `resolveQueriesConfig`: this pass is
973
+ // synchronous, and it needs the NAMES rather than resolved declarations.
974
+ const declared = new Set([
975
+ ...Object.keys(siteYml.queries || {}),
976
+ ...Object.keys(readQueriesYml(sitePath))
977
+ ])
978
+
979
+ if (existsSync(dataDir)) {
980
+ // A query `x` owns `x.json` (its list) and `x/` (its records' own files).
981
+ // Anything else is unaccounted for.
982
+ const orphans = readdirSync(dataDir, { withFileTypes: true })
983
+ .filter((entry) => {
984
+ const name = entry.isDirectory()
985
+ ? entry.name
986
+ : entry.name.replace(/\.json$/i, '')
987
+ return !declared.has(name)
988
+ })
989
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
990
+
991
+ if (orphans.length > 0) {
992
+ const id = 'orphaned-query-output'
993
+ issues.push({
994
+ id,
995
+ type: 'warning',
996
+ site: siteName,
997
+ message: `${orphans.length} entr${orphans.length === 1 ? 'y' : 'ies'} in public/${DATA_DIR}/ with no declared query`
998
+ })
999
+ warn(`[${id}] Stale output in public/${DATA_DIR}/: ${orphans.join(', ')}`)
1000
+ log(
1001
+ ` No query produces ${orphans.length === 1 ? 'it' : 'these'}. ` +
1002
+ `${orphans.length === 1 ? 'It is' : 'They are'} still served and deployed.`
1003
+ )
1004
+ if (shouldFix(id)) {
1005
+ for (const entry of orphans) {
1006
+ rmSync(join(dataDir, entry.replace(/\/$/, '')), { recursive: true, force: true })
1007
+ }
1008
+ fixed(`removed ${orphans.length} stale entr${orphans.length === 1 ? 'y' : 'ies'} from public/${DATA_DIR}/`)
1009
+ } else {
1010
+ log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to remove ${orphans.length === 1 ? 'it' : 'them'}.${colors.reset}`)
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ // The ignore rule. Checked whether or not the directory exists yet — the
1016
+ // point is to have it in place *before* the first build writes there.
1017
+ const gitignorePath = join(sitePath, '.gitignore')
1018
+ const rule = `public/${DATA_DIR}/`
1019
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : null
1020
+ const covered =
1021
+ existing !== null &&
1022
+ existing
1023
+ .split('\n')
1024
+ .map((line) => line.trim().replace(/\/$/, ''))
1025
+ .includes(rule.replace(/\/$/, ''))
1026
+
1027
+ if (!covered) {
1028
+ const id = 'generated-data-not-ignored'
1029
+ issues.push({
1030
+ id,
1031
+ type: 'warning',
1032
+ site: siteName,
1033
+ message: `public/${DATA_DIR}/ is generated but not gitignored`
1034
+ })
1035
+ warn(`[${id}] public/${DATA_DIR}/ is build output but not in .gitignore`)
1036
+ log(` Committing it means a diff on every build, and it invites editing what the build overwrites.`)
1037
+ if (shouldFix(id)) {
1038
+ const body = existing === null ? '' : existing.replace(/\n*$/, '\n')
1039
+ writeFileSync(
1040
+ gitignorePath,
1041
+ `${body}\n# Compiled query results — generated from records/ + queries.yml\n${rule}\n`
1042
+ )
1043
+ fixed(`added ${rule} to ${gitignorePath}`)
1044
+ if (existsSync(dataDir)) {
1045
+ log(` ${colors.dim}Already-committed files need \`git rm -r --cached public/${DATA_DIR}\`.${colors.reset}`)
1046
+ }
1047
+ } else {
1048
+ log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to add it.${colors.reset}`)
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * Main doctor command
1055
+ */
1056
+ export async function doctor(args = []) {
1057
+ log('')
1058
+ log(`${colors.blue}${colors.bright}Uniweb Doctor${colors.reset}`)
1059
+ log(`${colors.dim}Checking project configuration...${colors.reset}`)
1060
+
1061
+ // Auto-fix mode. `--fix` alone fixes every recoverable issue;
1062
+ // `--fix <issue-id>` targets one. Per-issue fix logic lives next to
1063
+ // each diagnostic below so the rewrite happens with full context.
1064
+ const fixFlag = parseFixFlag(args)
1065
+ const shouldFix = (id) => fixFlag === 'all' || fixFlag === id
1066
+ // ⛔ SOME FIXES MUST NOT RIDE A BARE `--fix`. A fix derived from a GUESS — the
1067
+ // family aliases, which exist in the CLI precisely so a human approves each
1068
+ // one — becomes a silent write the moment `--fix` alone applies it, which is
1069
+ // the failure the guess was moved here to avoid. Those checks ask for the id.
1070
+ const shouldFixExplicitly = (id) => fixFlag === id
1071
+ const fixed = (msg) =>
1072
+ console.log(` ${colors.green}↳ Fixed:${colors.reset} ${msg}`)
1073
+
1074
+ const projectDir = resolve(process.cwd())
1075
+
1076
+ // Find workspace root via the canonical primitive (recognizes
1077
+ // pnpm-workspace.yaml or package.json::workspaces).
1078
+ const workspaceDir = findWorkspaceRoot(projectDir)
1079
+
1080
+ if (!workspaceDir) {
1081
+ error('Not in a Uniweb workspace')
1082
+ log(
1083
+ `${colors.dim}Run this command from your project root or a site/foundation directory.${colors.reset}`
1084
+ )
1085
+ process.exit(1)
1086
+ }
1087
+
1088
+ log('')
1089
+ info(`Workspace: ${workspaceDir}`)
1090
+
1091
+ // Workspace manifest sync — keep `pnpm-workspace.yaml::packages` and
1092
+ // `package.json::workspaces` aligned. The CLI writes both on every
1093
+ // mutation (see addWorkspaceGlob in utils/config.js), but a user who
1094
+ // manually edits one can introduce drift. Drift breaks projects that
1095
+ // switch package managers (pnpm-workspace.yaml is pnpm-only;
1096
+ // package.json::workspaces is what npm and yarn read).
1097
+ const issues = []
1098
+ const ymlPath = join(workspaceDir, 'pnpm-workspace.yaml')
1099
+ if (existsSync(ymlPath)) {
1100
+ let ymlPackages = []
1101
+ try {
1102
+ ymlPackages = yaml.load(readFileSync(ymlPath, 'utf8'))?.packages || []
1103
+ } catch {
1104
+ // Malformed yaml — flag separately
1105
+ issues.push({
1106
+ id: 'workspace-yaml-malformed',
1107
+ type: 'error',
1108
+ message: 'pnpm-workspace.yaml is malformed and could not be parsed.'
1109
+ })
1110
+ error('pnpm-workspace.yaml is malformed and could not be parsed.')
1111
+ }
1112
+ const rootPkg = loadPackageJson(workspaceDir)
1113
+ const pkgWorkspaces = Array.isArray(rootPkg?.workspaces)
1114
+ ? rootPkg.workspaces
1115
+ : []
1116
+ const ymlSet = new Set(ymlPackages)
1117
+ const pkgSet = new Set(pkgWorkspaces)
1118
+ const onlyInYml = [...ymlSet].filter((g) => !pkgSet.has(g))
1119
+ const onlyInPkg = [...pkgSet].filter((g) => !ymlSet.has(g))
1120
+ if (onlyInYml.length || onlyInPkg.length) {
1121
+ const issue = {
1122
+ id: 'workspace-manifests-out-of-sync',
1123
+ type: 'warn',
1124
+ message: `pnpm-workspace.yaml and package.json::workspaces declare different package globs.`,
1125
+ details: { onlyInYml, onlyInPkg }
1126
+ }
1127
+ issues.push(issue)
1128
+ warn(
1129
+ `[workspace-manifests-out-of-sync] pnpm-workspace.yaml and package.json::workspaces are out of sync:`
1130
+ )
1131
+ if (onlyInYml.length)
1132
+ log(` only in pnpm-workspace.yaml: ${onlyInYml.join(', ')}`)
1133
+ if (onlyInPkg.length)
1134
+ log(` only in package.json::workspaces: ${onlyInPkg.join(', ')}`)
1135
+ if (shouldFix('workspace-manifests-out-of-sync')) {
1136
+ // Canonical resolution: write the union to both manifests.
1137
+ const union = Array.from(
1138
+ new Set([...ymlPackages, ...pkgWorkspaces])
1139
+ ).sort()
1140
+ writeFileSync(
1141
+ ymlPath,
1142
+ yaml.dump({ packages: union }, { flowLevel: -1, quotingType: '"' })
1143
+ )
1144
+ const rootPkgPath = join(workspaceDir, 'package.json')
1145
+ const rootPkgSrc = readFileSync(rootPkgPath, 'utf-8')
1146
+ const rootPkg = JSON.parse(rootPkgSrc)
1147
+ rootPkg.workspaces = union
1148
+ writeJsonPreservingStyle(rootPkgPath, rootPkg, rootPkgSrc)
1149
+ issue.fixed = true
1150
+ fixed(`wrote union [${union.join(', ')}] to both manifests`)
1151
+ } else {
1152
+ log(
1153
+ ` ${colors.dim}Pick one set of globs and copy it to the other manifest. The two should always match — pnpm reads pnpm-workspace.yaml, npm/yarn read package.json::workspaces.${colors.reset}`
1154
+ )
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ // Discover foundations + sites via the canonical workspace globs.
1160
+ // Doctor used to walk fixed paths (`foundation/`, `foundations/*`) which
1161
+ // missed the default-path `src/` shape that Thread D made canonical.
1162
+ // Using the same primitives every other command uses keeps doctor in
1163
+ // step with whatever layout the workspace has.
1164
+ const discovered = await discoverFoundations(workspaceDir)
1165
+ const foundations = []
1166
+ const extensions = []
1167
+ for (const f of discovered) {
1168
+ const fullPath = join(workspaceDir, f.path)
1169
+ const folderName = basename(f.path)
1170
+ const entry = { path: fullPath, name: f.name, folderName }
1171
+ if (buildIsExtensionPackage(fullPath)) {
1172
+ extensions.push(entry)
1173
+ } else {
1174
+ foundations.push(entry)
1175
+ }
1176
+ }
1177
+
1178
+ if (foundations.length === 0) {
1179
+ warn('No foundations found')
1180
+ } else {
1181
+ success(`Found ${foundations.length} foundation(s):`)
1182
+ for (const f of foundations) {
1183
+ const nameMismatch =
1184
+ f.name !== f.folderName
1185
+ ? ` ${colors.dim}(folder: ${f.folderName}/)${colors.reset}`
1186
+ : ''
1187
+ log(` • ${f.name}${nameMismatch}`)
1188
+ }
1189
+ }
1190
+
1191
+ // Prose without the tokens — a silent, total loss of styling.
1192
+ //
1193
+ // `@uniweb/kit/prose-tokens.css` brings Tailwind Typography with it and then
1194
+ // points it at the site's theme.yml. A foundation that writes `prose` (or
1195
+ // renders <Prose>/<Article>, which emit it) and does NOT import that file
1196
+ // gets the class and no rules behind it: correct markup, zero styling, and
1197
+ // nothing reported anywhere. Measured 2026-07-30 on a foundation missing only
1198
+ // the plugin — every paragraph `margin-top: 0px`, no warning from Vite,
1199
+ // Tailwind or the browser.
1200
+ //
1201
+ // kit closes this for anyone who imports the file; this closes it for anyone
1202
+ // who does not, which is the case kit cannot reach.
1203
+ for (const f of foundations) {
1204
+ // `f.path` is already absolute (built above with join(workspaceDir, …)).
1205
+ const stylesPath = join(f.path, 'styles.css')
1206
+ if (!existsSync(stylesPath)) continue
1207
+
1208
+ const styles = readFileSync(stylesPath, 'utf8')
1209
+ if (styles.includes('@uniweb/kit/prose-tokens.css')) continue
1210
+
1211
+ // Does anything here actually ask for prose? Either the class in the
1212
+ // stylesheet, or kit's components, which render it.
1213
+ const usesProseComponent = rendersKitProse(f.path)
1214
+ const usesProseClass = /(^|[\s"'`])prose(\s|["'`]|$)/m.test(styles)
1215
+
1216
+ if (!usesProseClass && !usesProseComponent) continue
1217
+
1218
+ const id = 'prose-without-tokens'
1219
+ const reason = usesProseComponent
1220
+ ? 'renders <Prose> or <Article>'
1221
+ : 'uses the `prose` class'
1222
+ issues.push({
1223
+ id,
1224
+ type: 'warning',
1225
+ foundation: f.name,
1226
+ message: `${f.name} ${reason} but does not import prose-tokens.css`
1227
+ })
1228
+ warn(`[${id}] ${f.name} ${reason}, with no prose styling behind it`)
1229
+ log(
1230
+ ` Tailwind Typography supplies the rules and it is not loaded, so the ` +
1231
+ `markup is correct and completely unstyled — silently.`
1232
+ )
1233
+ log(` Add to ${f.folderName}/styles.css:`)
1234
+ log(` ${colors.dim}@import "@uniweb/kit/prose-tokens.css";${colors.reset}`)
1235
+ }
1236
+
1237
+ // ⛔ SAY WHEN A CHECK DID NOT RUN. Every foundation check below reads the
1238
+ // BUILT `dist/meta/schema.json` — what a consumer actually receives — and
1239
+ // returns silently when there is none. In a workspace with a site, "Foundation
1240
+ // not built yet" turns up later under that site; in a FOUNDATION-ONLY
1241
+ // workspace, which is the normal shape when a foundation is the product, it
1242
+ // never does. The report then looks clean because nothing ran.
1243
+ //
1244
+ // That is the same class as the `dist/schema.json` path bug (a check that
1245
+ // never fires is indistinguishable from a codebase with nothing to report),
1246
+ // so it is named here rather than left to be rediscovered.
1247
+ for (const f of foundations) {
1248
+ if (!loadSchemaJson(f.path)) {
1249
+ info(
1250
+ `${f.folderName}: not built, so nothing was checked here ` +
1251
+ `${colors.dim}(services, section families, retired keys)${colors.reset}`
1252
+ )
1253
+ log(` ${colors.dim}Run \`uniweb build\` in ${f.folderName}, then doctor again.${colors.reset}`)
1254
+ }
1255
+ }
1256
+
1257
+ // ── uniweb.supports — the services this foundation says it is built against ──
1258
+ for (const f of foundations) {
1259
+ await checkFoundationSupports({
1260
+ foundationName: f.name,
1261
+ folderName: f.folderName,
1262
+ foundationDir: f.path,
1263
+ pkg: loadPackageJson(f.path),
1264
+ issues,
1265
+ shouldFix,
1266
+ fixed
1267
+ })
1268
+ checkUngatedServiceControls({
1269
+ foundationName: f.name,
1270
+ folderName: f.folderName,
1271
+ srcDir: resolveFoundationSrcPath(f.path),
1272
+ issues
1273
+ })
1274
+ checkSectionFamilies({
1275
+ foundationName: f.name,
1276
+ folderName: f.folderName,
1277
+ foundationDir: f.path,
1278
+ issues,
1279
+ shouldFixExplicitly,
1280
+ fixed
1281
+ })
1282
+ checkRetiredMetaKeys({
1283
+ foundationName: f.name,
1284
+ folderName: f.folderName,
1285
+ foundationDir: f.path,
1286
+ issues
1287
+ })
1288
+ }
1289
+
1290
+ // Extensions carry `uniweb.supports` too — same key, same field, and a site's
1291
+ // answer is the union across its primary foundation and its extensions. The
1292
+ // regex-era check looked at foundations only, so an extension that drew a
1293
+ // search box was never asked about one.
1294
+ for (const e of extensions) {
1295
+ await checkFoundationSupports({
1296
+ foundationName: e.name,
1297
+ folderName: e.folderName,
1298
+ foundationDir: e.path,
1299
+ pkg: loadPackageJson(e.path),
1300
+ issues,
1301
+ shouldFix,
1302
+ fixed
1303
+ })
1304
+ checkUngatedServiceControls({
1305
+ foundationName: e.name,
1306
+ folderName: e.folderName,
1307
+ srcDir: resolveFoundationSrcPath(e.path),
1308
+ issues
1309
+ })
1310
+ // An extension contributes section types by URL and lands in the same
1311
+ // picker, so its sections need a family for exactly the same reason.
1312
+ checkSectionFamilies({
1313
+ foundationName: e.name,
1314
+ folderName: e.folderName,
1315
+ foundationDir: e.path,
1316
+ issues,
1317
+ shouldFixExplicitly,
1318
+ fixed
1319
+ })
1320
+ checkRetiredMetaKeys({
1321
+ foundationName: e.name,
1322
+ folderName: e.folderName,
1323
+ foundationDir: e.path,
1324
+ issues
1325
+ })
1326
+ }
1327
+
1328
+ if (extensions.length > 0) {
1329
+ log('')
1330
+ success(`Found ${extensions.length} extension(s):`)
1331
+ for (const e of extensions) {
1332
+ const nameMismatch =
1333
+ e.name !== e.folderName
1334
+ ? ` ${colors.dim}(folder: ${e.folderName}/)${colors.reset}`
1335
+ : ''
1336
+ log(` • ${e.name}${nameMismatch}`)
1337
+ }
1338
+ }
1339
+
1340
+ // Discover sites via the canonical workspace globs (same rationale as
1341
+ // foundations above: respects whatever layout the user chose).
1342
+ const sites = (await discoverSites(workspaceDir)).map((s) => ({
1343
+ path: join(workspaceDir, s.path),
1344
+ name: s.name
1345
+ }))
1346
+
1347
+ if (sites.length === 0) {
1348
+ warn('No sites found')
1349
+ } else {
1350
+ log('')
1351
+ success(`Found ${sites.length} site(s):`)
1352
+ for (const s of sites) {
1353
+ log(` • ${s.name}`)
1354
+ }
1355
+ }
1356
+
1357
+ // Check each site (issues array was declared earlier alongside the
1358
+ // workspace-manifest-sync check).
1359
+ for (const site of sites) {
1360
+ const siteName = site.name
1361
+ const sitePath = site.path
1362
+ const siteYml = loadSiteYml(sitePath)
1363
+ const sitePkg = loadPackageJson(sitePath)
1364
+
1365
+ log('')
1366
+ info(`Checking site: ${siteName}`)
1367
+
1368
+ if (!siteYml) {
1369
+ issues.push({
1370
+ type: 'error',
1371
+ site: siteName,
1372
+ message: 'Missing site.yml'
1373
+ })
1374
+ error('Missing site.yml')
1375
+ continue
1376
+ }
1377
+
1378
+ if (!sitePkg) {
1379
+ issues.push({
1380
+ type: 'error',
1381
+ site: siteName,
1382
+ message: 'Missing package.json'
1383
+ })
1384
+ error('Missing package.json')
1385
+ continue
1386
+ }
1387
+
1388
+ // The agent index ships by default, but it can only carry absolute links
1389
+ // when the site declares where it lives. Root-relative links still work
1390
+ // for an agent that arrived via the index, so this warns rather than
1391
+ // suppressing the artifact the way sitemap.xml does — a silently absent
1392
+ // index is the exact failure the projections exist to prevent.
1393
+ const agentsConfig = siteYml.agents
1394
+ const indexEnabled = agentsConfig !== false && agentsConfig?.index !== false
1395
+ if (indexEnabled && !siteYml.seo?.baseUrl) {
1396
+ const issue = {
1397
+ id: 'agents-index-relative-links',
1398
+ type: 'warning',
1399
+ site: siteName,
1400
+ message:
1401
+ 'llms.txt will use root-relative links because seo.baseUrl is unset'
1402
+ }
1403
+ issues.push(issue)
1404
+ warn(`[agents-index-relative-links] llms.txt links will be root-relative`)
1405
+ log(
1406
+ ` Set ${colors.green}seo.baseUrl${colors.reset} in site.yml so agents get absolute URLs`
1407
+ )
1408
+ log(
1409
+ ` (this also turns on sitemap.xml and robots.txt, which are skipped without it)`
1410
+ )
1411
+ }
1412
+
1413
+ // `public/<DATA_DIR>/` is the build's output directory and nothing else —
1414
+ // `records/` is the only supported way to provide structured data. That makes the mapping a bijection: every entry there should be backed
1415
+ // by a declared QUERY, so anything else is stale, and identifiable.
1416
+ //
1417
+ // It matters because the directory is written into the source tree rather
1418
+ // than dist/, so what lands there persists and gets deployed. A collection
1419
+ // removed from site.yml leaves its compiled records behind — visible to
1420
+ // anyone who knows the URL, listed by nothing.
1421
+ // Forms need a destination, and having none is only visible on the page.
1422
+ await checkFormSubmitTarget({ sitePath, siteName, siteYml, issues })
1423
+ checkAgentsBlock({ siteName, siteYml, issues })
1424
+ checkTrackingBlock({ siteName, siteYml, issues })
1425
+
1426
+ checkGeneratedDataDir({
1427
+ sitePath,
1428
+ siteName,
1429
+ siteYml,
1430
+ issues,
1431
+ shouldFix,
1432
+ fixed
1433
+ })
1434
+
1435
+ const foundationName = siteYml.foundation
1436
+ if (!foundationName) {
1437
+ warn('No foundation specified in site.yml (using runtime loading?)')
1438
+ continue
1439
+ }
1440
+
1441
+ // Check if foundation name matches a known foundation
1442
+ const matchingFoundation = foundations.find(
1443
+ (f) => f.name === foundationName
1444
+ )
1445
+
1446
+ if (!matchingFoundation) {
1447
+ // Check if it might match a folder name instead
1448
+ const folderMatch = foundations.find(
1449
+ (f) => f.folderName === foundationName
1450
+ )
1451
+ if (folderMatch) {
1452
+ const issue = {
1453
+ id: 'foundation-name-mismatch',
1454
+ type: 'error',
1455
+ site: siteName,
1456
+ message: `Foundation mismatch: site.yml uses folder name "${foundationName}" instead of package name "${folderMatch.name}"`
1457
+ }
1458
+ issues.push(issue)
1459
+ error(`[foundation-name-mismatch] Foundation mismatch:`)
1460
+ log(
1461
+ ` site.yml says: ${colors.yellow}foundation: ${foundationName}${colors.reset}`
1462
+ )
1463
+ log(
1464
+ ` This matches the folder name, but the package name is: ${colors.green}${folderMatch.name}${colors.reset}`
1465
+ )
1466
+ if (shouldFix('foundation-name-mismatch')) {
1467
+ const siteYmlPath = join(sitePath, 'site.yml')
1468
+ const updated = { ...siteYml, foundation: folderMatch.name }
1469
+ writeFileSync(
1470
+ siteYmlPath,
1471
+ yaml.dump(updated, { flowLevel: -1, quotingType: "'" })
1472
+ )
1473
+ issue.fixed = true
1474
+ fixed(
1475
+ `${relative(workspaceDir, siteYmlPath)} now references "${folderMatch.name}"`
1476
+ )
1477
+ } else {
1478
+ log('')
1479
+ log(` ${colors.dim}To fix, update site.yml:${colors.reset}`)
1480
+ log(` foundation: ${folderMatch.name}`)
1481
+ }
1482
+ continue
1483
+ }
1484
+
1485
+ issues.push({
1486
+ type: 'error',
1487
+ site: siteName,
1488
+ message: `Foundation "${foundationName}" not found`
1489
+ })
1490
+ error(`Foundation "${foundationName}" not found`)
1491
+ log('')
1492
+ log(` ${colors.dim}Available foundations:${colors.reset}`)
1493
+ for (const f of foundations) {
1494
+ log(` • ${f.name} (${f.folderName}/)`)
1495
+ }
1496
+ continue
1497
+ }
1498
+
1499
+ success(`Foundation reference: ${foundationName}`)
1500
+
1501
+ // Check package.json dependency
1502
+ const deps = { ...sitePkg.dependencies, ...sitePkg.devDependencies }
1503
+ const depValue = deps[foundationName]
1504
+
1505
+ if (!depValue) {
1506
+ const issue = {
1507
+ id: 'missing-foundation-dep',
1508
+ type: 'error',
1509
+ site: siteName,
1510
+ message: `Missing dependency "${foundationName}" in package.json`
1511
+ }
1512
+ issues.push(issue)
1513
+ error(
1514
+ `[missing-foundation-dep] Missing dependency "${foundationName}" in package.json`
1515
+ )
1516
+ const expectedPath = `file:${relative(sitePath, matchingFoundation.path)}`
1517
+ if (shouldFix('missing-foundation-dep')) {
1518
+ const sitePkgPath = join(sitePath, 'package.json')
1519
+ const updatedPkg = { ...sitePkg }
1520
+ updatedPkg.dependencies = {
1521
+ ...(updatedPkg.dependencies || {}),
1522
+ [foundationName]: expectedPath
1523
+ }
1524
+ writeJsonPreservingStyle(sitePkgPath, updatedPkg)
1525
+ issue.fixed = true
1526
+ fixed(
1527
+ `added "${foundationName}": "${expectedPath}" to ${relative(workspaceDir, sitePkgPath)}`
1528
+ )
1529
+ } else {
1530
+ log('')
1531
+ log(
1532
+ ` ${colors.dim}Add to site's package.json dependencies:${colors.reset}`
1533
+ )
1534
+ log(` "${foundationName}": "${expectedPath}"`)
1535
+ }
1536
+ continue
1537
+ }
1538
+
1539
+ if (depValue.startsWith('file:')) {
1540
+ const depPath = join(sitePath, depValue.slice(5))
1541
+ if (!existsSync(depPath)) {
1542
+ const issue = {
1543
+ id: 'stale-file-path',
1544
+ type: 'error',
1545
+ site: siteName,
1546
+ message: `Dependency path doesn't exist: ${depValue}`
1547
+ }
1548
+ issues.push(issue)
1549
+ error(`[stale-file-path] Dependency path doesn't exist: ${depValue}`)
1550
+ const expectedPath = `file:${relative(sitePath, matchingFoundation.path)}`
1551
+ if (shouldFix('stale-file-path')) {
1552
+ const sitePkgPath = join(sitePath, 'package.json')
1553
+ const updatedPkg = { ...sitePkg }
1554
+ updatedPkg.dependencies = {
1555
+ ...(updatedPkg.dependencies || {}),
1556
+ [foundationName]: expectedPath
1557
+ }
1558
+ writeJsonPreservingStyle(sitePkgPath, updatedPkg)
1559
+ issue.fixed = true
1560
+ fixed(
1561
+ `updated "${foundationName}" to "${expectedPath}" in ${relative(workspaceDir, sitePkgPath)}`
1562
+ )
1563
+ } else {
1564
+ log('')
1565
+ log(` ${colors.dim}Update site's package.json:${colors.reset}`)
1566
+ log(` "${foundationName}": "${expectedPath}"`)
1567
+ }
1568
+ continue
1569
+ }
1570
+ success(`Dependency path: ${depValue}`)
1571
+ } else {
1572
+ success(`Dependency: ${depValue} (npm package)`)
1573
+ }
1574
+
1575
+ // Is what is installed what is declared? A `file:` dependency can be
1576
+ // satisfied by a link or by a copy, and only the link stays true. The copy
1577
+ // is invisible to a build — which reads the workspace source directly — so
1578
+ // it surfaces as a dev server serving stale code, or nothing at all.
1579
+ for (const finding of checkSiteInstall(
1580
+ { name: siteName, path: sitePath },
1581
+ matchingFoundation,
1582
+ workspaceDir
1583
+ )) {
1584
+ issues.push({
1585
+ id: finding.id,
1586
+ type: finding.severity,
1587
+ site: siteName,
1588
+ message: finding.message
1589
+ })
1590
+ error(`[${finding.id}] ${finding.message}`)
1591
+ log(`${colors.dim}${finding.detail}${colors.reset}`)
1592
+ log('')
1593
+ log(` ${colors.dim}To fix: ${finding.remedy}${colors.reset}`)
1594
+ }
1595
+
1596
+ // Check if foundation is built
1597
+ const foundationDist = join(matchingFoundation.path, 'dist', 'entry.js')
1598
+ if (!existsSync(foundationDist)) {
1599
+ issues.push({
1600
+ type: 'warn',
1601
+ site: siteName,
1602
+ message: `Foundation not built: ${matchingFoundation.name}`
1603
+ })
1604
+ warn(`Foundation not built yet`)
1605
+ log(` ${colors.dim}Run: uniweb build${colors.reset}`)
1606
+ } else {
1607
+ success(`Foundation built: dist/entry.js exists`)
1608
+ }
1609
+ }
1610
+
1611
+ // Check extensions
1612
+ for (const ext of extensions) {
1613
+ log('')
1614
+ info(`Checking extension: ${ext.name}`)
1615
+
1616
+ // An extension is a package that declares `extension: true` — that is how it
1617
+ // was told apart from the foundations above, wherever its folder is. (A check
1618
+ // here that it declares it could never fail, and was removed 2026-10-08.)
1619
+ success(`Extension identity: extension: true`)
1620
+
1621
+ // Check for vars or layouts
1622
+ const schema = loadSchemaJson(ext.path)
1623
+
1624
+ if (schema?._self?.vars && Object.keys(schema._self.vars).length > 0) {
1625
+ issues.push({
1626
+ type: 'warn',
1627
+ message: `Extension "${ext.name}" declares theme variables (vars). Extensions don't define theme variables.`
1628
+ })
1629
+ warn(`Extension declares vars — these won't take effect`)
1630
+ }
1631
+
1632
+ if (schema?._layouts && Object.keys(schema._layouts).length > 0) {
1633
+ issues.push({
1634
+ type: 'warn',
1635
+ message: `Extension "${ext.name}" provides layouts. Extensions don't provide layouts.`
1636
+ })
1637
+ warn(`Extension provides layouts — these won't take effect`)
1638
+ }
1639
+
1640
+ // Check if built
1641
+ const extensionDist = join(ext.path, 'dist', 'entry.js')
1642
+ if (!existsSync(extensionDist)) {
1643
+ issues.push({
1644
+ type: 'warn',
1645
+ message: `Extension not built: ${ext.name}`
1646
+ })
1647
+ warn(`Extension not built yet`)
1648
+ log(` ${colors.dim}Run: uniweb build${colors.reset}`)
1649
+ } else {
1650
+ success(`Extension built: dist/entry.js exists`)
1651
+ }
1652
+ }
1653
+
1654
+ // Check if any foundation with extension: true is wired as a primary foundation
1655
+ for (const f of foundations) {
1656
+ if (isExtensionPackage(f.path)) {
1657
+ const sitesUsingAsPrimary = sites.filter((s) => {
1658
+ const siteYml = loadSiteYml(s.path)
1659
+ return siteYml?.foundation === f.name
1660
+ })
1661
+ for (const site of sitesUsingAsPrimary) {
1662
+ issues.push({
1663
+ type: 'warn',
1664
+ site: site.name,
1665
+ message: `Foundation "${f.name}" declares extension: true but is wired as the primary foundation. It should be in extensions: instead.`
1666
+ })
1667
+ warn(
1668
+ `"${f.name}" is an extension but used as primary foundation in site "${site.name}"`
1669
+ )
1670
+ log(
1671
+ ` ${colors.dim}Move it to extensions: in site.yml instead of foundation:${colors.reset}`
1672
+ )
1673
+ }
1674
+ }
1675
+ }
1676
+
1677
+ // Validate extension URLs in site.yml
1678
+ for (const site of sites) {
1679
+ const siteYml = loadSiteYml(site.path)
1680
+ if (!siteYml?.extensions || !Array.isArray(siteYml.extensions)) continue
1681
+
1682
+ for (const extUrl of siteYml.extensions) {
1683
+ if (typeof extUrl !== 'string') continue
1684
+
1685
+ // Skip remote URLs — can't validate those
1686
+ if (extUrl.startsWith('http://') || extUrl.startsWith('https://'))
1687
+ continue
1688
+
1689
+ // Local extension URL: check if it maps to a known extension
1690
+ // URLs like /effects/foundation.js or /extensions/effects/foundation.js
1691
+ const urlParts = extUrl.replace(/^\//, '').split('/')
1692
+ const extName =
1693
+ urlParts.length >= 2 ? urlParts[urlParts.length - 2] : urlParts[0]
1694
+
1695
+ // Check if a matching extension exists and is built
1696
+ const matchingExt = extensions.find(
1697
+ (e) => e.folderName === extName || e.name === extName
1698
+ )
1699
+ if (
1700
+ matchingExt &&
1701
+ !existsSync(join(matchingExt.path, 'dist', 'entry.js'))
1702
+ ) {
1703
+ issues.push({
1704
+ type: 'warn',
1705
+ site: site.name,
1706
+ message: `Extension "${extName}" referenced in site.yml is not built`
1707
+ })
1708
+ }
1709
+ }
1710
+ }
1711
+
1712
+ // ─── Deploy configuration ────────────────────────────────────
1713
+ // Sanity-check deploy.yml shape and surface common drift between
1714
+ // deploy.yml, site.yml, and the artifacts the CLI generates.
1715
+ // Failures here would otherwise only surface at deploy / first GHA
1716
+ // run, where the feedback loop is slower.
1717
+ log('')
1718
+ info('Checking deploy configuration...')
1719
+ const knownAdapters = new Set(listAdapters())
1720
+ for (const site of sites) {
1721
+ const sitePath = site.path
1722
+ const siteName = site.name
1723
+ const deployYmlPath = join(sitePath, 'deploy.yml')
1724
+ if (!existsSync(deployYmlPath)) continue
1725
+
1726
+ let deployYml
1727
+ try {
1728
+ deployYml = await loadDeployYml(sitePath)
1729
+ } catch (err) {
1730
+ issues.push({
1731
+ id: 'deploy-yml-malformed',
1732
+ type: 'error',
1733
+ site: siteName,
1734
+ message: `deploy.yml is malformed: ${err.message}`
1735
+ })
1736
+ error(
1737
+ `[deploy-yml-malformed] ${relative(workspaceDir, deployYmlPath)}: ${err.message}`
1738
+ )
1739
+ continue
1740
+ }
1741
+ if (!deployYml) continue
1742
+
1743
+ const targets = deployYml.targets || {}
1744
+ const targetNames = Object.keys(targets)
1745
+
1746
+ // default: must reference a real target.
1747
+ if (deployYml.default && !targets[deployYml.default]) {
1748
+ issues.push({
1749
+ id: 'deploy-yml-default-unknown-target',
1750
+ type: 'error',
1751
+ site: siteName,
1752
+ message: `deploy.yml: default '${deployYml.default}' is not in targets (known: ${targetNames.join(', ') || '(none)'})`
1753
+ })
1754
+ error(
1755
+ `[deploy-yml-default-unknown-target] ${siteName}: default '${deployYml.default}' is not declared under \`targets:\`.`
1756
+ )
1757
+ log(
1758
+ ` ${colors.dim}Add it to targets: or change default: to one of: ${targetNames.join(', ') || '(none)'}.${colors.reset}`
1759
+ )
1760
+ }
1761
+
1762
+ // Each target must have a host, and host should be a known adapter.
1763
+ for (const [name, cfg] of Object.entries(targets)) {
1764
+ if (!cfg || typeof cfg !== 'object') continue
1765
+ if (!cfg.host) {
1766
+ issues.push({
1767
+ id: 'deploy-yml-target-missing-host',
1768
+ type: 'error',
1769
+ site: siteName,
1770
+ message: `deploy.yml: targets.${name} is missing \`host\``
1771
+ })
1772
+ error(
1773
+ `[deploy-yml-target-missing-host] ${siteName}: targets.${name} has no \`host\` field.`
1774
+ )
1775
+ continue
1776
+ }
1777
+ // 'uniweb' is the platform default and isn't in the static-host
1778
+ // adapter registry; treat it as known.
1779
+ if (cfg.host !== 'uniweb' && !knownAdapters.has(cfg.host)) {
1780
+ issues.push({
1781
+ id: 'deploy-yml-unknown-host',
1782
+ type: 'warn',
1783
+ site: siteName,
1784
+ message: `deploy.yml: targets.${name}.host '${cfg.host}' is not a known adapter`
1785
+ })
1786
+ warn(
1787
+ `[deploy-yml-unknown-host] ${siteName}: targets.${name}.host: '${cfg.host}' is not a known built-in adapter.`
1788
+ )
1789
+ log(
1790
+ ` ${colors.dim}Known: ${[...knownAdapters].sort().join(', ')}, plus 'uniweb'.${colors.reset}`
1791
+ )
1792
+ }
1793
+ }
1794
+
1795
+ // github-pages-specific: if a target has `domain`, `site/public/CNAME`
1796
+ // should exist with that value. Vite copies public/ into dist/, and
1797
+ // GH Pages reads dist/CNAME on each deploy to keep the custom domain
1798
+ // configured. A drift here means a deploy will silently lose the
1799
+ // custom domain.
1800
+ const cnamePath = join(sitePath, 'public', 'CNAME')
1801
+ const ghTarget = Object.entries(targets).find(
1802
+ ([, c]) => c?.host === 'github-pages'
1803
+ )
1804
+ if (ghTarget) {
1805
+ const [ghName, ghCfg] = ghTarget
1806
+ if (ghCfg.domain) {
1807
+ const expected = String(ghCfg.domain).trim()
1808
+ let actual = null
1809
+ if (existsSync(cnamePath)) {
1810
+ try {
1811
+ actual = readFileSync(cnamePath, 'utf8').trim()
1812
+ } catch {
1813
+ actual = null
1814
+ }
1815
+ }
1816
+ if (actual !== expected) {
1817
+ const issue = {
1818
+ id: 'github-pages-cname-mismatch',
1819
+ type: 'warn',
1820
+ site: siteName,
1821
+ message:
1822
+ actual === null
1823
+ ? `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} is missing`
1824
+ : `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} contains '${actual}'`
1825
+ }
1826
+ issues.push(issue)
1827
+ warn(`[github-pages-cname-mismatch] ${siteName}: ${issue.message}`)
1828
+ if (shouldFix('github-pages-cname-mismatch')) {
1829
+ const dir = dirname(cnamePath)
1830
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
1831
+ writeFileSync(cnamePath, expected + '\n')
1832
+ issue.fixed = true
1833
+ fixed(`wrote ${relative(workspaceDir, cnamePath)} = ${expected}`)
1834
+ } else {
1835
+ log(
1836
+ ` ${colors.dim}Re-run \`uniweb add ci --domain ${expected} --force\` (or pass --fix to write CNAME from deploy.yml).${colors.reset}`
1837
+ )
1838
+ }
1839
+ }
1840
+ } else if (existsSync(cnamePath)) {
1841
+ // CNAME exists but the github-pages target has no `domain` set —
1842
+ // either deploy.yml was edited and lost the domain, or someone
1843
+ // hand-authored CNAME without setting the target.
1844
+ let actual = ''
1845
+ try {
1846
+ actual = readFileSync(cnamePath, 'utf8').trim()
1847
+ } catch {
1848
+ // ignore
1849
+ }
1850
+ issues.push({
1851
+ id: 'github-pages-cname-orphan',
1852
+ type: 'warn',
1853
+ site: siteName,
1854
+ message: `${relative(workspaceDir, cnamePath)} exists ('${actual}') but deploy.yml's github-pages target has no \`domain\``
1855
+ })
1856
+ warn(
1857
+ `[github-pages-cname-orphan] ${siteName}: ${relative(workspaceDir, cnamePath)} exists ('${actual}') but the github-pages target has no \`domain\` set.`
1858
+ )
1859
+ log(
1860
+ ` ${colors.dim}Either add \`domain: ${actual}\` to targets.${ghName}, or delete the CNAME if you no longer want a custom domain.${colors.reset}`
1861
+ )
1862
+ }
1863
+ }
1864
+
1865
+ // site.yml::base + a generated GH Pages workflow → the workflow
1866
+ // sets UNIWEB_BASE at build time, which beats site.yml::base
1867
+ // (site/config.js precedence: explicit > UNIWEB_BASE > site.yml).
1868
+ // Not an error — site.yml's value is just dormant for that target —
1869
+ // but worth flagging so the user knows the value isn't taking effect.
1870
+ const siteYml = loadSiteYml(sitePath)
1871
+ const workflowPath = join(
1872
+ workspaceDir,
1873
+ '.github/workflows/deploy-github-pages.yml'
1874
+ )
1875
+ if (siteYml?.base && existsSync(workflowPath) && ghTarget) {
1876
+ issues.push({
1877
+ id: 'site-base-overridden-by-workflow',
1878
+ type: 'warn',
1879
+ site: siteName,
1880
+ message: `site.yml::base ('${siteYml.base}') is dormant — the GH Pages workflow sets UNIWEB_BASE at build time, which takes precedence`
1881
+ })
1882
+ warn(
1883
+ `[site-base-overridden-by-workflow] ${siteName}: site.yml has \`base: ${siteYml.base}\`, but the GH Pages workflow sets UNIWEB_BASE.`
1884
+ )
1885
+ log(
1886
+ ` ${colors.dim}site.yml::base is the fallback when no UNIWEB_BASE env var is set. The workflow always sets it, so this value is ignored on GH Pages deploys.${colors.reset}`
1887
+ )
1888
+ log(
1889
+ ` ${colors.dim}If the GH Pages deploy is the only target, you can remove \`base:\` from site.yml. Otherwise leave it — it still applies to other deploy targets.${colors.reset}`
1890
+ )
1891
+ }
1892
+ }
1893
+
1894
+ const cliVersion = getCliVersion()
1895
+
1896
+ // Check @uniweb/* dep alignment with the running CLI
1897
+ log('')
1898
+ const depSurvey = await surveyWorkspaceDeps(workspaceDir)
1899
+ const behindDeps = depSurvey.rows.filter((r) => r.status === 'behind')
1900
+ if (behindDeps.length > 0) {
1901
+ const names = [...new Set(behindDeps.map((r) => r.name))].sort()
1902
+ warn(
1903
+ `${behindDeps.length} workspace dep declaration${behindDeps.length === 1 ? '' : 's'} lag the CLI (v${cliVersion}): ${names.join(', ')}`
1904
+ )
1905
+ info(`Run: uniweb update`)
1906
+ issues.push({
1907
+ type: 'warn',
1908
+ message: `${behindDeps.length} @uniweb/* dep declaration(s) behind CLI v${cliVersion}`
1909
+ })
1910
+ } else if (depSurvey.anyAhead) {
1911
+ success(`@uniweb/* deps are aligned or ahead of the CLI (v${cliVersion})`)
1912
+ } else {
1913
+ success(`@uniweb/* deps are aligned with the CLI (v${cliVersion})`)
1914
+ }
1915
+
1916
+ // Check AGENTS.md freshness
1917
+ log('')
1918
+ const agentsPath = join(workspaceDir, 'AGENTS.md')
1919
+ const agentsVersion = readAgentsVersion(agentsPath)
1920
+
1921
+ if (!existsSync(agentsPath)) {
1922
+ warn('AGENTS.md not found')
1923
+ info(`Run: uniweb update`)
1924
+ issues.push({ type: 'warn', message: 'AGENTS.md not found' })
1925
+ } else if (!agentsVersion) {
1926
+ // No stamp — manually created or pre-stamp version
1927
+ warn('AGENTS.md has no version stamp (may be outdated)')
1928
+ info(`Run: uniweb update`)
1929
+ issues.push({ type: 'warn', message: 'AGENTS.md has no version stamp' })
1930
+ } else if (agentsVersion !== cliVersion) {
1931
+ warn(`AGENTS.md is outdated (v${agentsVersion} → v${cliVersion})`)
1932
+ info(`Run: uniweb update`)
1933
+ issues.push({
1934
+ type: 'warn',
1935
+ message: `AGENTS.md outdated (v${agentsVersion} → v${cliVersion})`
1936
+ })
1937
+ } else {
1938
+ success(`AGENTS.md is up to date (v${cliVersion})`)
1939
+ }
1940
+
1941
+ // Summary
1942
+ log('')
1943
+ log('─'.repeat(50))
1944
+
1945
+ // Fixed issues no longer count toward errors/warnings — they're
1946
+ // resolved during this run. Exit code is based on what remains.
1947
+ const errors = issues.filter((i) => i.type === 'error' && !i.fixed)
1948
+ const warnings = issues.filter((i) => i.type === 'warn' && !i.fixed)
1949
+ const fixedCount = issues.filter((i) => i.fixed).length
1950
+
1951
+ if (errors.length === 0 && warnings.length === 0 && fixedCount === 0) {
1952
+ log('')
1953
+ log(`${colors.green}${colors.bright}All checks passed!${colors.reset}`)
1954
+ log('')
1955
+ } else {
1956
+ log('')
1957
+ if (fixedCount > 0) {
1958
+ log(`${colors.green}${fixedCount} issue(s) fixed${colors.reset}`)
1959
+ }
1960
+ if (errors.length > 0) {
1961
+ log(`${colors.red}${errors.length} error(s) remaining${colors.reset}`)
1962
+ }
1963
+ if (warnings.length > 0) {
1964
+ log(
1965
+ `${colors.yellow}${warnings.length} warning(s) remaining${colors.reset}`
1966
+ )
1967
+ }
1968
+ if (fixFlag && (errors.length > 0 || warnings.length > 0)) {
1969
+ log(
1970
+ `${colors.dim}Some issues were not auto-fixable. See the diagnostics above.${colors.reset}`
1971
+ )
1972
+ }
1973
+ log('')
1974
+ }
1975
+
1976
+ return {
1977
+ issues,
1978
+ errors: errors.length,
1979
+ warnings: warnings.length,
1980
+ fixed: fixedCount
1981
+ }
1982
+ }