@weft-js/cli 0.1.1 → 0.1.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/README.md +38 -14
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
package/src/agents-md.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `weft agents-md`: AGENTS.md generated from the installed weft (exports, the codes its compiler reports) and this
|
|
2
|
-
// project's tasks. Under
|
|
2
|
+
// project's tasks. Under 9 KB (PLAN §7, raised from 8 KB in 2026-11). Never edited by hand: `--check` fails when it is stale.
|
|
3
3
|
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { join, resolve } from 'node:path'
|
|
@@ -10,32 +10,69 @@ import { loadConfig } from './load-config.mjs'
|
|
|
10
10
|
import { agentsSection } from './contributions.mjs'
|
|
11
11
|
import { tasks } from './tasks.mjs'
|
|
12
12
|
|
|
13
|
-
export const limit =
|
|
13
|
+
export const limit = 9 * 1024
|
|
14
14
|
|
|
15
15
|
/** `T001, T002, T003, P007` → `T001–T003, P007`: runs of consecutive codes as ranges (three or more). */
|
|
16
16
|
export function ranges(codes) {
|
|
17
17
|
const out = []
|
|
18
|
-
for (let i = 0; i < codes.length;
|
|
18
|
+
for (let i = 0; i < codes.length;) {
|
|
19
19
|
let j = i
|
|
20
|
-
const num = c => Number(c.slice(1))
|
|
21
|
-
while (
|
|
20
|
+
const num = (c) => Number(c.slice(1))
|
|
21
|
+
while (
|
|
22
|
+
j + 1 < codes.length &&
|
|
23
|
+
codes[j + 1][0] === codes[i][0] &&
|
|
24
|
+
num(codes[j + 1]) === num(codes[j]) + 1
|
|
25
|
+
)
|
|
26
|
+
j++
|
|
22
27
|
out.push(j - i >= 2 ? `${codes[i]}–${codes[j]}` : codes.slice(i, j + 1).join(', '))
|
|
23
28
|
i = j + 1
|
|
24
29
|
}
|
|
25
30
|
return out.join(', ')
|
|
26
31
|
}
|
|
27
32
|
/** PLAN §3.1 primitives an agent may reach for; listed as missing when the installed weft doesn't export them. */
|
|
28
|
-
const planned = [
|
|
33
|
+
const planned = [
|
|
34
|
+
'state',
|
|
35
|
+
'derived',
|
|
36
|
+
'resource',
|
|
37
|
+
'Boundary',
|
|
38
|
+
'watch',
|
|
39
|
+
'onMount',
|
|
40
|
+
'onCleanup',
|
|
41
|
+
'context',
|
|
42
|
+
'defineState',
|
|
43
|
+
'flush',
|
|
44
|
+
'untrack',
|
|
45
|
+
'batch',
|
|
46
|
+
]
|
|
29
47
|
/** Commands AGENTS.md leaves to `weft tasks --json`: rarely run by hand, or taught in their own section. */
|
|
30
|
-
const elsewhere = new Set([
|
|
48
|
+
const elsewhere = new Set([
|
|
49
|
+
'agents-md',
|
|
50
|
+
'doctor',
|
|
51
|
+
'mcp',
|
|
52
|
+
'plugins',
|
|
53
|
+
'start',
|
|
54
|
+
'style',
|
|
55
|
+
'surfaces',
|
|
56
|
+
'tasks',
|
|
57
|
+
'tokens',
|
|
58
|
+
])
|
|
31
59
|
/** The app-facing part of subpaths whose other exports are helpers compiled code calls. */
|
|
32
|
-
const entryPoints = {
|
|
60
|
+
const entryPoints = {
|
|
61
|
+
'@weft-js/core/dom': ['render', 'hydrate'],
|
|
62
|
+
'@weft-js/core/ssr': ['renderToString', 'renderToStringAsync'],
|
|
63
|
+
'@weft-js/core/browser': ['online', 'visible', 'lifecycle', 'media', 'on', 'interval'],
|
|
64
|
+
}
|
|
33
65
|
|
|
34
66
|
/** The Full stack section, with this app's alias for its source. */
|
|
35
67
|
function fullStack(root) {
|
|
36
68
|
const alias = sourceAlias(root)
|
|
37
69
|
const text = readDoc('start-short')
|
|
38
|
-
return alias
|
|
70
|
+
return alias
|
|
71
|
+
? text.replaceAll('#/', alias)
|
|
72
|
+
: text.replace(
|
|
73
|
+
/ Import your own code as [^(]*\(`src\/server\/todos`\); generated files are/,
|
|
74
|
+
' Generated files are',
|
|
75
|
+
)
|
|
39
76
|
}
|
|
40
77
|
|
|
41
78
|
/** This project's AGENTS.md, written from what the installed weft reports: the text, where it goes, and whether weft's own part is over the limit. */
|
|
@@ -43,18 +80,39 @@ export async function agentsMd(root) {
|
|
|
43
80
|
root = resolve(root)
|
|
44
81
|
const info = installed(root)
|
|
45
82
|
const list = await tasks(root)
|
|
46
|
-
const sections = (await loadConfig(root)).contributions
|
|
83
|
+
const sections = (await loadConfig(root)).contributions
|
|
84
|
+
.filter((c) => c.manifest.agents)
|
|
85
|
+
.map((c) => agentsSection(c.manifest.agents))
|
|
47
86
|
const core = info.exports['@weft-js/core'] ?? []
|
|
48
|
-
const missing = planned.filter(n => !core.includes(n))
|
|
87
|
+
const missing = planned.filter((n) => !core.includes(n))
|
|
49
88
|
|
|
50
89
|
// Style-group codes (D0xx, W060) and `weft style` belong to the Styling section, which only a weft with @weft-js/core/style gets.
|
|
51
90
|
const styled = info.exports['@weft-js/core/style']?.includes('group')
|
|
52
|
-
const dcodes = catalog
|
|
91
|
+
const dcodes = catalog
|
|
92
|
+
.filter((e) => e.code.startsWith('D0') && (e.by === 'cli' || info.status[e.code]?.implemented))
|
|
93
|
+
.map((e) => e.code)
|
|
94
|
+
.sort()
|
|
53
95
|
const startApp = existsSync(join(root, 'src', 'routes'))
|
|
54
|
-
const tcodes = catalog
|
|
96
|
+
const tcodes = catalog
|
|
97
|
+
.filter((e) => e.code.startsWith('T') && info.status[e.code]?.implemented)
|
|
98
|
+
.map((e) => e.code)
|
|
99
|
+
|
|
100
|
+
const text = page({ root, info, core, missing, tcodes, startApp, styled, dcodes, list }, sections)
|
|
101
|
+
const bytes = Buffer.byteLength(text)
|
|
102
|
+
// the limit is weft's own text: plugin sections have their own 1 KB each (P112) on top
|
|
103
|
+
return {
|
|
104
|
+
path: join(root, 'AGENTS.md'),
|
|
105
|
+
text,
|
|
106
|
+
bytes,
|
|
107
|
+
overLimit:
|
|
108
|
+
Buffer.byteLength(
|
|
109
|
+
page({ root, info, core, missing, tcodes, startApp, styled, dcodes, list }, []),
|
|
110
|
+
) > limit,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
55
113
|
|
|
56
|
-
|
|
57
|
-
|
|
114
|
+
function pageIntro({ root, info, core, missing }) {
|
|
115
|
+
return [
|
|
58
116
|
`# AGENTS.md`,
|
|
59
117
|
'',
|
|
60
118
|
`<!-- Generated by \`weft agents-md\` from weft@${info.weft.version}. Do not edit: change weft.config.ts and regenerate. -->`,
|
|
@@ -63,50 +121,98 @@ export async function agentsMd(root) {
|
|
|
63
121
|
`React hooks don't exist. Run \`weft check --json\` after every change; each diagnostic gives code, location, why and fix.`,
|
|
64
122
|
'',
|
|
65
123
|
'Start with `.weft/map.md` (`weft map`): what this app has and what to call.',
|
|
124
|
+
...(existsSync(join(root, 'src', 'theme.css'))
|
|
125
|
+
? ['', "`src/theme.css` is this app's CSS; `weft docs theme` changes the look."]
|
|
126
|
+
: []),
|
|
66
127
|
'',
|
|
67
128
|
'## Rules',
|
|
68
129
|
'',
|
|
69
130
|
readDoc('rules'),
|
|
70
131
|
...(core.includes('resource')
|
|
71
|
-
? [
|
|
72
|
-
|
|
132
|
+
? [
|
|
133
|
+
'10. **Async data is `resource(signal => fetch(…))`** (`.value .pending .error .refresh()`), never `derived(async …)` (W012).',
|
|
134
|
+
core.includes('Boundary')
|
|
135
|
+
? ' Loading and errors: `<Boundary pending={…} error={(e, retry) => …}>`.'
|
|
136
|
+
: '',
|
|
137
|
+
]
|
|
73
138
|
: []),
|
|
74
139
|
'',
|
|
75
140
|
'**Leave props untyped**: `weft docs props`.',
|
|
76
141
|
'',
|
|
77
142
|
`## API in weft@${info.weft.version}`,
|
|
78
143
|
'',
|
|
79
|
-
`- \`@weft-js/core\`: ${core
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
144
|
+
`- \`@weft-js/core\`: ${core
|
|
145
|
+
.filter((n) => planned.includes(n))
|
|
146
|
+
.map((n) => `\`${n}\``)
|
|
147
|
+
.join(', ')}` + (core.includes('state') ? ', `state.linked`' : ''),
|
|
148
|
+
...['@weft-js/core/dom', '@weft-js/core/ssr']
|
|
149
|
+
.map((mod, i, all) => {
|
|
150
|
+
const names = entryPoints[mod].filter((n) => info.exports[mod]?.includes(n))
|
|
151
|
+
const last = all
|
|
152
|
+
.slice(i + 1)
|
|
153
|
+
.every((m) => !entryPoints[m].some((n) => info.exports[m]?.includes(n)))
|
|
154
|
+
return names.length
|
|
155
|
+
? `- \`${mod}\`: ${names.map((n) => `\`${n}\``).join(', ')}.${last ? ' Never import the rest.' : ''}`
|
|
156
|
+
: ''
|
|
157
|
+
})
|
|
158
|
+
.filter(Boolean),
|
|
159
|
+
info.exports['@weft-js/core/browser']
|
|
160
|
+
? `- \`@weft-js/core/browser\`: ${entryPoints['@weft-js/core/browser']
|
|
161
|
+
.filter((n) => info.exports['@weft-js/core/browser'].includes(n))
|
|
162
|
+
.map((n) => `\`${n}\``)
|
|
163
|
+
.join(
|
|
164
|
+
', ',
|
|
165
|
+
)}. Create them in setup (W051); server-rendered \`media\` needs \`ssr\` (W054). More: \`weft docs browser\`; hover and press as state: \`weft docs ui-state\`.`
|
|
166
|
+
: '',
|
|
167
|
+
info.exports['@weft-js/core/surface']
|
|
168
|
+
? '- `@weft-js/core/surface`: canvas and WebGL, never raw `<canvas>`: `weft docs surface`.'
|
|
169
|
+
: '',
|
|
170
|
+
info.exports['@weft-js/core/test']
|
|
171
|
+
? `- \`@weft-js/core/test\`: ${info.exports['@weft-js/core/test']
|
|
172
|
+
.filter((n) => !['canvasCalls', 'cleanup', 'fakeMeasure', 'leaks'].includes(n))
|
|
173
|
+
.map((n) => `\`${n}\``)
|
|
174
|
+
.join(
|
|
175
|
+
', ',
|
|
176
|
+
)}, and \`expect(fn).toTouchAtMost(n, { runs })\`. Query by role/label/text; \`user\` actions and \`clock.advance\` end in \`flush()\`, so never sleep or \`waitFor\`.`
|
|
177
|
+
: '',
|
|
178
|
+
missing.length
|
|
179
|
+
? `- Not in this version yet: ${missing.map((n) => `\`${n}\``).join(', ')}. Don't import them.`
|
|
180
|
+
: '',
|
|
89
181
|
'',
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// with plugins: the `weft plugins` command and their sections; without, neither
|
|
186
|
+
function page({ root, info, core, missing, tcodes, startApp, styled, dcodes, list }, plugins) {
|
|
187
|
+
return [
|
|
188
|
+
...pageIntro({ root, info, core, missing }),
|
|
90
189
|
'## Diagnostics',
|
|
91
190
|
'',
|
|
92
|
-
'`weft docs <code>` explains one. Also flagged: raw `addEventListener`, `setInterval` or sockets with no cleanup (W050), and a `watch` that folds a stream (W052) or copies a live value into `state` (W056).',
|
|
191
|
+
'`weft docs <code>` explains one. Also flagged: an early `return` in a `.map` callback (W013), raw `addEventListener`, `setInterval` or sockets with no cleanup (W050), and a `watch` that folds a stream (W052) or copies a live value into `state` (W056).',
|
|
93
192
|
'',
|
|
94
|
-
tcodes.length
|
|
193
|
+
tcodes.length
|
|
194
|
+
? `Test checks (\`weft check\` on test files): ${ranges(tcodes)}. No assertion, tautologies, sleeps, \`container\`/selectors/test ids, mocking weft, snapshots, conditional \`expect\`, \`.only\`/\`.skip\`. To allow one: \`// weft-allow T00x: <reason>\`.`
|
|
195
|
+
: '',
|
|
95
196
|
'',
|
|
96
|
-
|
|
197
|
+
"Before a change is done, see it work (`weft browser snapshot|act|assert`, or `__weft__.agent` in your own browser) and run `weft test --gate`: new tests must fail on the old code and kill a mutant (T010, T011, T020). `weft build` ends by auditing the pages it built at a phone's and a desktop's width (L-codes: names, contrast, console, the head, sideways scroll, layout shift) and fails on an error; `weft audit --url <deploy>` checks a live site. More: `weft docs verify`.",
|
|
97
198
|
'',
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
199
|
+
'Before publishing, choose `build.audit.indexing`: `public` makes noindex an error; `private` preserves intentional exclusions. Audit the actual deployed URL with `weft audit --url <url> --indexing public` for public search: a proxy header can override correct markup. Never remove private indexing directives just to improve a score.',
|
|
200
|
+
'',
|
|
201
|
+
...(startApp ? ['## Full stack', '', fullStack(root), ''] : []),
|
|
101
202
|
...(styled
|
|
102
|
-
? [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
203
|
+
? [
|
|
204
|
+
'## Styling',
|
|
205
|
+
'',
|
|
206
|
+
`**Run \`weft style find <what it is>\` first**, then reuse a group or add an axis value; never class lists. New: \`const card = group('card', { base: { pad: 'group' }, axes: { status: { late: { tone: 'critical' } } } })\` from \`@weft-js/core/style\`, \`<li {...card({ status })}>\`. Tokens only. ${ranges(dcodes)}, W060: \`weft docs style\`, \`weft docs design\`; \`weft fix\` applies the safe fixes.`,
|
|
207
|
+
'',
|
|
208
|
+
]
|
|
106
209
|
: []),
|
|
107
210
|
'## Commands',
|
|
108
211
|
'',
|
|
109
|
-
commandsPage(
|
|
212
|
+
commandsPage(
|
|
213
|
+
{ ...list, tasks: list.tasks.filter((t) => !elsewhere.has(t.name)) },
|
|
214
|
+
{ planned: false },
|
|
215
|
+
),
|
|
110
216
|
'',
|
|
111
217
|
'`weft tasks --json` lists the rest.',
|
|
112
218
|
'',
|
|
@@ -118,11 +224,6 @@ export async function agentsMd(root) {
|
|
|
118
224
|
]
|
|
119
225
|
.filter((line, i, all) => !(line === '' && all[i - 1] === ''))
|
|
120
226
|
.join('\n')
|
|
121
|
-
|
|
122
|
-
const text = page(sections)
|
|
123
|
-
const bytes = Buffer.byteLength(text)
|
|
124
|
-
// the limit is weft's own text: plugin sections have their own 1 KB each (P112) on top
|
|
125
|
-
return { path: join(root, 'AGENTS.md'), text, bytes, overLimit: Buffer.byteLength(page([])) > limit }
|
|
126
227
|
}
|
|
127
228
|
|
|
128
229
|
/** Write AGENTS.md, or with `check` only compare. Returns whether the file on disk was current. */
|
package/src/alias.mjs
CHANGED
|
@@ -18,13 +18,20 @@ function rulesIn(dir) {
|
|
|
18
18
|
const tsconfig = join(dir, 'tsconfig.json')
|
|
19
19
|
if (existsSync(tsconfig)) {
|
|
20
20
|
const host = { ...ts.sys, readDirectory: () => [] }
|
|
21
|
-
const { options } = ts.parseJsonConfigFileContent(
|
|
21
|
+
const { options } = ts.parseJsonConfigFileContent(
|
|
22
|
+
ts.readConfigFile(tsconfig, (path) => ts.sys.readFile(path)).config,
|
|
23
|
+
host,
|
|
24
|
+
dir,
|
|
25
|
+
)
|
|
22
26
|
const base = options.pathsBasePath ?? options.baseUrl ?? dir
|
|
23
|
-
for (const [pattern, [target]] of Object.entries(options.paths ?? {}))
|
|
27
|
+
for (const [pattern, [target]] of Object.entries(options.paths ?? {}))
|
|
28
|
+
add(pattern, resolve(base, target))
|
|
24
29
|
}
|
|
25
30
|
const pkg = join(dir, 'package.json')
|
|
26
31
|
if (existsSync(pkg)) {
|
|
27
|
-
for (const [pattern, target] of Object.entries(
|
|
32
|
+
for (const [pattern, target] of Object.entries(
|
|
33
|
+
JSON.parse(readFileSync(pkg, 'utf8')).imports ?? {},
|
|
34
|
+
)) {
|
|
28
35
|
if (typeof target === 'string' && target.startsWith('.')) add(pattern, resolve(dir, target))
|
|
29
36
|
}
|
|
30
37
|
}
|
|
@@ -46,7 +53,9 @@ export function local(fromAbs, spec) {
|
|
|
46
53
|
if (spec.startsWith('.')) return join(dirname(fromAbs), spec)
|
|
47
54
|
for (const { prefix, suffix, exact, target } of rulesFor(dirname(fromAbs))) {
|
|
48
55
|
if (exact ? spec === prefix : spec.startsWith(prefix) && spec.endsWith(suffix)) {
|
|
49
|
-
return exact
|
|
56
|
+
return exact
|
|
57
|
+
? target
|
|
58
|
+
: target.replace('*', spec.slice(prefix.length, spec.length - suffix.length))
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
61
|
return null
|
|
@@ -54,6 +63,8 @@ export function local(fromAbs, spec) {
|
|
|
54
63
|
|
|
55
64
|
/** The prefix that maps to `<root>/src/`, like `#/`; null when the app has none. */
|
|
56
65
|
export function sourceAlias(root) {
|
|
57
|
-
const rule = rulesFor(resolve(root)).find(
|
|
66
|
+
const rule = rulesFor(resolve(root)).find(
|
|
67
|
+
(r) => r.target === join(resolve(root), 'src', '*') && !r.suffix,
|
|
68
|
+
)
|
|
58
69
|
return rule?.prefix ?? null
|
|
59
70
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { fail, sleep } from './utils.mjs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* axe-core is what Lighthouse runs for Accessibility and for Agentic Browsing's accessibility tree: the WCAG 2 A and AA
|
|
7
|
+
* rules, and the best-practice ones Lighthouse also grades. `region` (all content in landmarks) Lighthouse leaves out.
|
|
8
|
+
*/
|
|
9
|
+
const AXE = {
|
|
10
|
+
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] },
|
|
11
|
+
rules: {
|
|
12
|
+
'heading-order': { enabled: true },
|
|
13
|
+
'empty-heading': { enabled: true },
|
|
14
|
+
tabindex: { enabled: true },
|
|
15
|
+
'duplicate-id-aria': { enabled: true },
|
|
16
|
+
'image-redundant-alt': { enabled: true },
|
|
17
|
+
'skip-link': { enabled: true },
|
|
18
|
+
'landmark-one-main': { enabled: true },
|
|
19
|
+
list: { enabled: true },
|
|
20
|
+
listitem: { enabled: true },
|
|
21
|
+
region: { enabled: false },
|
|
22
|
+
},
|
|
23
|
+
resultTypes: ['violations'],
|
|
24
|
+
}
|
|
25
|
+
/** axe rules with a code of their own; every other violation is L004, with the rule's id. */
|
|
26
|
+
const RULES = {
|
|
27
|
+
...Object.fromEntries(
|
|
28
|
+
[
|
|
29
|
+
'button-name',
|
|
30
|
+
'link-name',
|
|
31
|
+
'input-button-name',
|
|
32
|
+
'label',
|
|
33
|
+
'select-name',
|
|
34
|
+
'aria-command-name',
|
|
35
|
+
'aria-input-field-name',
|
|
36
|
+
'aria-toggle-field-name',
|
|
37
|
+
'aria-progressbar-name',
|
|
38
|
+
'aria-meter-name',
|
|
39
|
+
'aria-tooltip-name',
|
|
40
|
+
'aria-treeitem-name',
|
|
41
|
+
'aria-dialog-name',
|
|
42
|
+
'frame-title',
|
|
43
|
+
'summary-name',
|
|
44
|
+
].map((r) => [r, 'L001']),
|
|
45
|
+
),
|
|
46
|
+
...Object.fromEntries(
|
|
47
|
+
['image-alt', 'role-img-alt', 'svg-img-alt', 'input-image-alt', 'object-alt', 'area-alt'].map(
|
|
48
|
+
(r) => [r, 'L002'],
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
'color-contrast': 'L003',
|
|
52
|
+
}
|
|
53
|
+
let axe
|
|
54
|
+
const axeSource = () =>
|
|
55
|
+
(axe ??= readFileSync(fileURLToPath(import.meta.resolve('axe-core/axe.min.js')), 'utf8'))
|
|
56
|
+
/** The issue types Lighthouse's `inspector-issues` audit counts (low contrast is axe's, as L003). */
|
|
57
|
+
const ISSUES = new Set([
|
|
58
|
+
'MixedContentIssue',
|
|
59
|
+
'CookieIssue',
|
|
60
|
+
'BlockedByResponseIssue',
|
|
61
|
+
'HeavyAdIssue',
|
|
62
|
+
'ContentSecurityPolicyIssue',
|
|
63
|
+
'SharedArrayBufferIssue',
|
|
64
|
+
'CorsIssue',
|
|
65
|
+
'AttributionReportingIssue',
|
|
66
|
+
'QuirksModeIssue',
|
|
67
|
+
'FederatedAuthRequestIssue',
|
|
68
|
+
'BounceTrackingIssue',
|
|
69
|
+
'StylesheetLoadingIssue',
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
/** In the page: how far the layout moved without a person asking (CLS, summed), and the first elements that moved. */
|
|
73
|
+
const SHIFT = `new Promise(ok => {
|
|
74
|
+
let cls = 0
|
|
75
|
+
const moved = []
|
|
76
|
+
new PerformanceObserver(list => {
|
|
77
|
+
for (const e of list.getEntries()) {
|
|
78
|
+
if (e.hadRecentInput) continue
|
|
79
|
+
cls += e.value
|
|
80
|
+
for (const s of e.sources ?? []) {
|
|
81
|
+
if (!s.node || s.node.nodeType !== 1 || moved.length === 3) continue
|
|
82
|
+
const open = s.node.outerHTML.slice(0, s.node.outerHTML.indexOf('>') + 1)
|
|
83
|
+
moved.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}).observe({ type: 'layout-shift', buffered: true })
|
|
87
|
+
setTimeout(() => ok({ cls: Math.round(cls * 1000) / 1000, moved }), 50)
|
|
88
|
+
})`
|
|
89
|
+
|
|
90
|
+
/** In the page: what a crawler reads from the head, and what pokes past a phone's right edge. */
|
|
91
|
+
const HEAD = `(() => {
|
|
92
|
+
const meta = n => document.querySelector('meta[name="' + n + '"]')?.getAttribute('content')?.trim() ?? null
|
|
93
|
+
const wide = []
|
|
94
|
+
const edge = innerWidth + 1
|
|
95
|
+
if (document.documentElement.scrollWidth > edge) {
|
|
96
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
97
|
+
const r = el.getBoundingClientRect()
|
|
98
|
+
if (r.right > edge && r.width > 0 && getComputedStyle(el).position !== 'fixed' && !(el.parentElement && el.parentElement.getBoundingClientRect().right > edge)) {
|
|
99
|
+
const open = el.outerHTML.slice(0, el.outerHTML.indexOf('>') + 1)
|
|
100
|
+
wide.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
|
|
101
|
+
if (wide.length === 3) break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
title: document.title.trim(), lang: document.documentElement.getAttribute('lang') ?? '', description: meta('description'),
|
|
107
|
+
viewport: meta('viewport'), robots: meta('robots'), scroll: document.documentElement.scrollWidth - innerWidth, wide,
|
|
108
|
+
icon: !!document.querySelector('link[rel~="icon"]'),
|
|
109
|
+
}
|
|
110
|
+
})()`
|
|
111
|
+
|
|
112
|
+
function listenPage({ browser, sessionId, found, statics, served, loaded, document }) {
|
|
113
|
+
const where = (u, line) =>
|
|
114
|
+
u
|
|
115
|
+
? `${u.replace(/^https?:\/\/127\.0\.0\.1:\d+/, '')}${line === undefined ? '' : `:${line + 1}`}`
|
|
116
|
+
: ''
|
|
117
|
+
const off = browser.on((m) => {
|
|
118
|
+
if (m.sessionId !== sessionId) return
|
|
119
|
+
const p = m.params
|
|
120
|
+
if (m.method === 'Page.loadEventFired') loaded()
|
|
121
|
+
else if (m.method === 'Network.responseReceived' && p.type === 'Document' && !document.value)
|
|
122
|
+
document.value = p.response
|
|
123
|
+
else if (m.method === 'Runtime.exceptionThrown') {
|
|
124
|
+
const d = p.exceptionDetails
|
|
125
|
+
found.push({
|
|
126
|
+
code: 'L010',
|
|
127
|
+
key: d.exception?.description?.split('\n')[0] ?? d.text,
|
|
128
|
+
at: where(d.url, d.lineNumber),
|
|
129
|
+
text: d.exception?.description?.split('\n')[0] ?? d.text,
|
|
130
|
+
})
|
|
131
|
+
} else if (
|
|
132
|
+
m.method === 'Runtime.consoleAPICalled' &&
|
|
133
|
+
(p.type === 'error' || p.type === 'assert')
|
|
134
|
+
) {
|
|
135
|
+
const text = p.args
|
|
136
|
+
.map((a) => a.value ?? a.description ?? '')
|
|
137
|
+
.join(' ')
|
|
138
|
+
.split('\n')[0]
|
|
139
|
+
const top = p.stackTrace?.callFrames?.[0]
|
|
140
|
+
found.push({ code: 'L010', key: text, at: where(top?.url, top?.lineNumber), text })
|
|
141
|
+
} else if (m.method === 'Log.entryAdded' && p.entry.level === 'error') {
|
|
142
|
+
// a build's pages served as files have no server: its realtime and function routes answer 404 here and nowhere else
|
|
143
|
+
if (statics && /\/_weft\/(rt|ws|fn|data|m|x)\b/.test(`${p.entry.url ?? ''} ${p.entry.text}`))
|
|
144
|
+
return
|
|
145
|
+
if (
|
|
146
|
+
statics &&
|
|
147
|
+
p.entry.url &&
|
|
148
|
+
/status of 404/.test(p.entry.text) &&
|
|
149
|
+
served(new URL(p.entry.url).pathname)
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
// the favicon: Chrome asks for it lazily, once a session, so the head probe asks instead (below)
|
|
153
|
+
if ((p.entry.url ?? '').endsWith('/favicon.ico')) return
|
|
154
|
+
found.push({
|
|
155
|
+
code: 'L011',
|
|
156
|
+
key: `${p.entry.url ?? ''} ${p.entry.text}`,
|
|
157
|
+
at: where(p.entry.url),
|
|
158
|
+
text: p.entry.text,
|
|
159
|
+
})
|
|
160
|
+
} else if (m.method === 'Audits.issueAdded') {
|
|
161
|
+
const { code, details } = p.issue
|
|
162
|
+
if (code === 'DeprecationIssue') {
|
|
163
|
+
const d = details.deprecationIssueDetails
|
|
164
|
+
found.push({
|
|
165
|
+
code: 'L012',
|
|
166
|
+
key: d.type,
|
|
167
|
+
at: where(d.sourceCodeLocation?.url, d.sourceCodeLocation?.lineNumber),
|
|
168
|
+
text: d.type,
|
|
169
|
+
})
|
|
170
|
+
} else if (ISSUES.has(code))
|
|
171
|
+
found.push({
|
|
172
|
+
code: 'L013',
|
|
173
|
+
key: code,
|
|
174
|
+
text: code.replace(/Issue$/, '').replace(/([a-z])([A-Z])/g, '$1 $2'),
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
return off
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function inspectHead(send, document, f, found) {
|
|
182
|
+
if (document.value && document.value.status >= 400)
|
|
183
|
+
found.push({
|
|
184
|
+
code: 'L025',
|
|
185
|
+
key: String(document.value.status),
|
|
186
|
+
text: `HTTP ${document.value.status}`,
|
|
187
|
+
})
|
|
188
|
+
const header = Object.entries(document.value?.headers ?? {}).find(
|
|
189
|
+
([k]) => k.toLowerCase() === 'x-robots-tag',
|
|
190
|
+
)?.[1]
|
|
191
|
+
const head = (await send('Runtime.evaluate', { expression: HEAD, returnByValue: true })).result
|
|
192
|
+
.value
|
|
193
|
+
if (!head.icon) {
|
|
194
|
+
const status = (
|
|
195
|
+
await send('Runtime.evaluate', {
|
|
196
|
+
expression: `fetch('/favicon.ico', { method: 'HEAD' }).then(r => r.status, () => 0)`,
|
|
197
|
+
awaitPromise: true,
|
|
198
|
+
returnByValue: true,
|
|
199
|
+
})
|
|
200
|
+
).result.value
|
|
201
|
+
if (status >= 400)
|
|
202
|
+
found.push({ code: 'L011', key: 'favicon', at: '/favicon.ico', text: `HTTP ${status}` })
|
|
203
|
+
}
|
|
204
|
+
if (!head.title) found.push({ code: 'L020', key: 'title' })
|
|
205
|
+
if (!head.description) found.push({ code: 'L021', key: 'description' })
|
|
206
|
+
if (!head.lang) found.push({ code: 'L022', key: 'lang' })
|
|
207
|
+
if (!head.viewport || !/width=device-width/.test(head.viewport))
|
|
208
|
+
found.push({ code: 'L023', key: 'viewport' })
|
|
209
|
+
const noindex = [
|
|
210
|
+
header && /noindex|none/i.test(header) && `the \`x-robots-tag: ${header}\` header`,
|
|
211
|
+
head.robots &&
|
|
212
|
+
/noindex|none/i.test(head.robots) &&
|
|
213
|
+
`<meta name="robots" content="${head.robots}">`,
|
|
214
|
+
].filter(Boolean)
|
|
215
|
+
if (noindex.length)
|
|
216
|
+
found.push({
|
|
217
|
+
code: 'L024',
|
|
218
|
+
key: noindex.join(),
|
|
219
|
+
text: noindex.join(' and '),
|
|
220
|
+
header: !!header,
|
|
221
|
+
})
|
|
222
|
+
if (f.mobile && head.scroll > 0)
|
|
223
|
+
found.push({
|
|
224
|
+
code: 'L030',
|
|
225
|
+
key: head.wide.join() || 'page',
|
|
226
|
+
text: `${head.scroll}px`,
|
|
227
|
+
wide: head.wide,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function inspectAxe(send, url, found) {
|
|
232
|
+
await send('Runtime.evaluate', { expression: axeSource() })
|
|
233
|
+
const { result, exceptionDetails } = await send('Runtime.evaluate', {
|
|
234
|
+
expression: `axe.run(document, ${JSON.stringify(AXE)}).then(r => r.violations)`,
|
|
235
|
+
awaitPromise: true,
|
|
236
|
+
returnByValue: true,
|
|
237
|
+
})
|
|
238
|
+
if (exceptionDetails)
|
|
239
|
+
throw fail(
|
|
240
|
+
'E_AUDIT',
|
|
241
|
+
`axe-core failed on ${url}: ${exceptionDetails.exception?.description ?? exceptionDetails.text}`,
|
|
242
|
+
)
|
|
243
|
+
for (const v of result.value) {
|
|
244
|
+
for (const n of v.nodes) {
|
|
245
|
+
const tag = n.html.slice(0, n.html.indexOf('>') + 1)
|
|
246
|
+
found.push({
|
|
247
|
+
code: RULES[v.id] ?? 'L004',
|
|
248
|
+
key: `${v.id} ${n.target.join(' ')}`,
|
|
249
|
+
rule: v.id,
|
|
250
|
+
help: v.help,
|
|
251
|
+
fix: n.failureSummary
|
|
252
|
+
?.replace(/^Fix (any|all) of the following:\s*/, '')
|
|
253
|
+
.split('\n')[0]
|
|
254
|
+
.trim(),
|
|
255
|
+
selector: n.target.join(' '),
|
|
256
|
+
tag: tag.length > 160 ? `${tag.slice(0, 157)}…>` : tag,
|
|
257
|
+
contrast: v.id === 'color-contrast' ? n.any[0]?.data : undefined,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Audit one page in one form factor: what Chrome said while it loaded, its head, its layout, and axe-core's rules. */
|
|
264
|
+
export async function auditPage({ browser, url, form, statics, served, forms }) {
|
|
265
|
+
const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' })
|
|
266
|
+
const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true })
|
|
267
|
+
const send = (m, p) => browser.send(m, p, sessionId)
|
|
268
|
+
const found = []
|
|
269
|
+
const document = { value: null }
|
|
270
|
+
let loaded
|
|
271
|
+
const load = new Promise((ok) => {
|
|
272
|
+
loaded = ok
|
|
273
|
+
})
|
|
274
|
+
const off = listenPage({ browser, sessionId, found, statics, served, loaded, document })
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const f = forms[form]
|
|
278
|
+
await Promise.all(
|
|
279
|
+
['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable', 'Audits.enable'].map((m) =>
|
|
280
|
+
send(m),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
await send('Emulation.setDeviceMetricsOverride', {
|
|
284
|
+
width: f.width,
|
|
285
|
+
height: f.height,
|
|
286
|
+
deviceScaleFactor: f.deviceScaleFactor,
|
|
287
|
+
mobile: f.mobile,
|
|
288
|
+
})
|
|
289
|
+
await send('Emulation.setTouchEmulationEnabled', {
|
|
290
|
+
enabled: f.mobile,
|
|
291
|
+
maxTouchPoints: f.mobile ? 5 : 1,
|
|
292
|
+
})
|
|
293
|
+
await send('Page.navigate', { url })
|
|
294
|
+
await Promise.race([load, sleep(15_000)])
|
|
295
|
+
await sleep(800) // hydration's errors, and a font swap's shift, land after load
|
|
296
|
+
await inspectHead(send, document, f, found)
|
|
297
|
+
const shift = (
|
|
298
|
+
await send('Runtime.evaluate', { expression: SHIFT, awaitPromise: true, returnByValue: true })
|
|
299
|
+
).result.value
|
|
300
|
+
if (shift.cls > 0.05)
|
|
301
|
+
found.push({
|
|
302
|
+
code: 'L031',
|
|
303
|
+
key: shift.moved.join() || 'page',
|
|
304
|
+
cls: shift.cls,
|
|
305
|
+
moved: shift.moved,
|
|
306
|
+
})
|
|
307
|
+
await inspectAxe(send, url, found)
|
|
308
|
+
return found
|
|
309
|
+
} finally {
|
|
310
|
+
off()
|
|
311
|
+
await browser.send('Target.closeTarget', { targetId }).catch(() => {})
|
|
312
|
+
}
|
|
313
|
+
}
|