@weft-js/cli 0.1.0 → 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.
Files changed (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +108 -0
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +174 -188
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +68 -444
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +199 -207
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +228 -224
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +142 -0
  74. package/src/deploy/files.mjs +266 -0
  75. package/src/deploy/fit.mjs +166 -0
  76. package/src/deploy/hosts.mjs +126 -0
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +387 -0
  81. package/src/deploy/needs.mjs +149 -0
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -42
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +144 -116
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +194 -92
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. 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 8 KB (PLAN §7). Never edited by hand: `--check` fails when it is stale.
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 = 8 * 1024
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 (j + 1 < codes.length && codes[j + 1][0] === codes[i][0] && num(codes[j + 1]) === num(codes[j]) + 1) j++
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 = ['state', 'derived', 'resource', 'Boundary', 'watch', 'onMount', 'onCleanup', 'context', 'defineState', 'flush', 'untrack', 'batch']
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(['agents-md', 'doctor', 'mcp', 'plugins', 'start', 'style', 'surfaces', 'tasks', 'tokens'])
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 = { '@weft-js/core/dom': ['render', 'hydrate'], '@weft-js/core/ssr': ['renderToString', 'renderToStringAsync'], '@weft-js/core/browser': ['online', 'visible', 'lifecycle', 'media', 'on', 'interval'] }
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 ? text.replaceAll('#/', alias) : text.replace(/ Import your own code as [^(]*\(`src\/server\/todos`\); generated files are/, ' Generated files are')
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.filter(c => c.manifest.agents).map(c => agentsSection(c.manifest.agents))
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.filter(e => e.code.startsWith('D0') && (e.by === 'cli' || info.status[e.code]?.implemented)).map(e => e.code).sort()
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.filter(e => e.code.startsWith('T') && info.status[e.code]?.implemented).map(e => e.code)
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
- // with plugins: the `weft plugins` command and their sections; without, neither
57
- const page = plugins => [
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
- ? ['10. **Async data is `resource(signal => fetch(…))`** (`.value .pending .error .refresh()`), never `derived(async …)` (W012).',
72
- core.includes('Boundary') ? ' Loading and errors: `<Boundary pending={…} error={(e, retry) => …}>`. More: `weft docs async`.' : '']
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.filter(n => planned.includes(n)).map(n => `\`${n}\``).join(', ')}` + (core.includes('state') ? ', `state.linked`' : ''),
80
- ...['@weft-js/core/dom', '@weft-js/core/ssr'].map((mod, i, all) => {
81
- const names = entryPoints[mod].filter(n => info.exports[mod]?.includes(n))
82
- const last = all.slice(i + 1).every(m => !entryPoints[m].some(n => info.exports[m]?.includes(n)))
83
- return names.length ? `- \`${mod}\`: ${names.map(n => `\`${n}\``).join(', ')}.${last ? ' Never import the rest.' : ''}` : ''
84
- }).filter(Boolean),
85
- info.exports['@weft-js/core/browser'] ? `- \`@weft-js/core/browser\`: ${entryPoints['@weft-js/core/browser'].filter(n => info.exports['@weft-js/core/browser'].includes(n)).map(n => `\`${n}\``).join(', ')}. Create them in setup (W051); server-rendered \`media\` needs \`ssr\` (W054). More: \`weft docs browser\`; hover and press as state: \`weft docs ui-state\`.` : '',
86
- info.exports['@weft-js/core/surface'] ? '- `@weft-js/core/surface`: canvas and WebGL, never raw `<canvas>`: `weft docs surface`.' : '',
87
- info.exports['@weft-js/core/test'] ? `- \`@weft-js/core/test\`: ${info.exports['@weft-js/core/test'].filter(n => !['canvasCalls', 'cleanup', 'fakeMeasure', 'leaks'].includes(n)).map(n => `\`${n}\``).join(', ')}, and \`expect(fn).toTouchAtMost(n, { runs })\`. Query by role/label/text; \`user\` actions and \`clock.advance\` end in \`flush()\`, so never sleep or \`waitFor\`.` : '',
88
- missing.length ? `- Not in this version yet: ${missing.map(n => `\`${n}\``).join(', ')}. Don't import them.` : '',
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 ? `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>\`.` : '',
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
- '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`.',
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
- ...(startApp
99
- ? ['## Full stack', '', fullStack(root), '']
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
- ? ['## Styling',
103
- '',
104
- `**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.`,
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({ ...list, tasks: list.tasks.filter(t => !elsewhere.has(t.name)) }, { planned: false }),
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(ts.readConfigFile(tsconfig, path => ts.sys.readFile(path)).config, host, dir)
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 ?? {})) add(pattern, resolve(base, target))
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(JSON.parse(readFileSync(pkg, 'utf8')).imports ?? {})) {
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 ? target : target.replace('*', spec.slice(prefix.length, spec.length - suffix.length))
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(r => r.target === join(resolve(root), 'src', '*') && !r.suffix)
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
+ }