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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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
|
+
}
|