code-foundry 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.17.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.16.0...v1.17.0) (2026-09-09)
4
+
5
+
6
+ ### Features
7
+
8
+ * **quality:** add opt-in site, app, Worker, and package acceptance profiles ([#545](https://github.com/0xPlayerOne/code-foundry/issues/545)) ([1201793](https://github.com/0xPlayerOne/code-foundry/commit/1201793805b443c835b608609704bc43bcb035f5))
9
+
3
10
  ## [1.16.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.15.0...v1.16.0) (2026-09-09)
4
11
 
5
12
 
package/README.md CHANGED
@@ -39,6 +39,8 @@ contract. For normal updates, edit that file and run `npx code-foundry sync`.
39
39
  audits, Draft Guard, Draft PR, Release PR, and Release.
40
40
  - A deterministic performance lane with ordered command support, stable result
41
41
  artifacts, and an optional shared Node package budget profile.
42
+ - An opt-in product-quality runner for static sites, web apps, Workers, and
43
+ published packages.
42
44
  - A small `.githooks/pre-commit` launcher with language-aware formatting and
43
45
  linting.
44
46
  - An optional `.mise.toml`/`mise.lock`, repository profile configuration, and
@@ -15,9 +15,9 @@ belong to the run that produced them, not the source tree.
15
15
  | Format, lint, type-check, and build | 15 s | Bound the local CI feedback loop |
16
16
  | Runtime dependencies | 0 | Keep the installed CLI dependency-free |
17
17
  | Development dependencies | 4 | Prevent unreviewed toolchain growth |
18
- | Packed artifact | 225 kB | Bound registry transfer and install cost |
19
- | Unpacked artifact | 860 kB | Bound installed footprint |
20
- | Packed files | 100 | Detect accidental release contents |
18
+ | Packed artifact | 240 kB | Bound registry transfer and install cost |
19
+ | Unpacked artifact | 900 kB | Bound installed footprint |
20
+ | Packed files | 110 | Detect accidental release contents |
21
21
 
22
22
  The performance workflow disables build-cache reads and writes for this task.
23
23
  That makes timing comparisons independent of a warm protected-branch cache and
@@ -26,7 +26,10 @@ prevents benchmark code from populating shared cache entries.
26
26
  The consumer qualification workflow adds the installed-package harness and
27
27
  workflow contract to the distributable. The merged candidate measured 221,495
28
28
  packed bytes, 853,573 unpacked bytes, and 100 files on Node 24.18.0; the updated
29
- budgets leave a small margin while continuing to bound package growth.
29
+ budgets leave a small margin while continuing to bound package growth. The
30
+ product-quality profiles and consumer qualification workflow measured 232,650
31
+ packed bytes, 890,418 unpacked bytes, and 103 files on Node 24.18.0, so the
32
+ limits remain intentionally bounded while covering both opt-in feature sets.
30
33
 
31
34
  ## v1.6.1 baseline
32
35
 
package/docs/README.md CHANGED
@@ -15,6 +15,7 @@ its own names, environments, and deployment details.
15
15
  - [Caching and remote caching](CACHING.md)
16
16
  - [Performance budgets and baselines](PERFORMANCE.md)
17
17
  - [Required capabilities and task evidence](required-capabilities.md)
18
+ - [Product quality profiles](product-quality.md)
18
19
  - [Agent-facing validation commands](agent-validation.md)
19
20
  - [Declarative fleet inventory and staged rollouts](fleet-rollouts.md)
20
21
 
@@ -0,0 +1,173 @@
1
+ # Product Quality Profiles
2
+
3
+ Opt-in acceptance checks for built sites, interactive applications, Workers, and packages.
4
+
5
+ **Status:** Additive runner; existing repositories are not opted in automatically.
6
+ **Evidence:** Fresh per-run reports under `.code-foundry/quality/`.
7
+
8
+ ## Activation
9
+
10
+ Commit a version-1 JSON manifest and use the installed, pinned Foundry runtime:
11
+
12
+ ```json
13
+ {
14
+ "scripts": {
15
+ "quality:build": "node node_modules/code-foundry/src/quality.mjs .github/product-quality.json build",
16
+ "quality:browser": "node node_modules/code-foundry/src/quality.mjs .github/product-quality.json browser"
17
+ }
18
+ }
19
+ ```
20
+
21
+ Wire these scripts into the consumer's existing validation entrypoints. The
22
+ Foundry runtime discovers only `performance:check`/`perf:check` and
23
+ `test:e2e`/`e2e`; if those names do not already exist, they can run
24
+ `bun run quality:build` and `bun run quality:browser`. If they do exist, preserve their current
25
+ commands and compose the quality command after them rather than replacing the
26
+ existing performance or E2E checks. The standalone entrypoint deliberately avoids
27
+ changing the public CLI dispatch being introduced in the separate agent-validation
28
+ PR. Install the reviewed Foundry version in the consumer's existing dependency
29
+ manager and lockfile, not an unpinned network invocation. Add `.code-foundry/` to
30
+ Git/package ignores and retain selected evidence through the consumer workflow's
31
+ artifact uploader. Reports and browser traces may contain application data; review
32
+ retention and never upload authenticated traces publicly without sanitization.
33
+
34
+ ```json
35
+ {
36
+ "schema_version": 1,
37
+ "prepare": { "build": [["bun", "run", "build"]] },
38
+ "profiles": [
39
+ {
40
+ "id": "marketing",
41
+ "type": "static-site",
42
+ "dist": "dist",
43
+ "origin": "https://example.com",
44
+ "routes": [{ "path": "/", "html": "index.html", "javascriptAssets": [] }],
45
+ "budgets": { "htmlBytes": 100000, "javascriptBytes": 80000, "imageBytes": 600000 },
46
+ "sitemap": "sitemap.xml",
47
+ "robots": "robots.txt",
48
+ "redirects": { "file": "_redirects", "rules": [{ "from": "/old", "to": "/", "status": 301 }] }
49
+ }
50
+ ]
51
+ }
52
+ ```
53
+
54
+ Example numbers are not fleet defaults. Measure and review thresholds in the
55
+ consumer repository. `prepare` runs once per selected phase, so several profiles
56
+ can share one build without repeating it. A phase with no applicable profiles
57
+ fails instead of pretending to validate something. Available phases are `build`,
58
+ `browser`, and `deployed`; assign a profile's `phase` explicitly to override its
59
+ default (`browser` for web-app, `build` for the others).
60
+
61
+ ## Static sites
62
+
63
+ The static profile checks explicit rendered routes for titles, descriptions,
64
+ canonical identity, accidental noindex, local links/anchors, directly referenced
65
+ resources, declared additional JavaScript chunks, sitemap inclusion, robots
66
+ sitemap declaration, and exact simple `_redirects` rules. All responsive image
67
+ candidates count toward the asset budget. Budgets measure build bytes, not an
68
+ estimate of browser transfer size or Core Web Vitals. `javascriptAssets` must
69
+ include transitive chunks selected from the consumer's bundler manifest; this
70
+ runner does not invent a framework dependency graph. External resources fail
71
+ closed rather than being treated as zero-byte dependencies.
72
+
73
+ This conservative generated-HTML parser is not a browser DOM or a complete HTML
74
+ conformance validator. Complex base-element routing, sitemap indexes, wildcard or
75
+ conditional redirects, client-only metadata, and CDN behavior need repository-owned
76
+ or deployed tests. All generated/public routes should be declared (or the manifest
77
+ generated from the framework's route output) to avoid untested routes. Redirect
78
+ checks prove the artifact declaration, not production routing behavior.
79
+
80
+ ## Interactive applications
81
+
82
+ ```json
83
+ {
84
+ "id": "application",
85
+ "type": "web-app",
86
+ "baseURL": "http://127.0.0.1:4321",
87
+ "webServerCommand": "bun run preview --host 127.0.0.1",
88
+ "journeys": "tests/quality-journeys.mjs",
89
+ "snapshots": "tests/quality-snapshots",
90
+ "routes": [{ "id": "home", "path": "/", "readySelector": "main h1", "visual": true }]
91
+ }
92
+ ```
93
+
94
+ `baseURL` must be an HTTP(S) origin without a path, query, or fragment; routes
95
+ are root-relative paths on that origin. Use the repository's locked
96
+ `@playwright/test`, `@axe-core/playwright`, and matching Chromium installation.
97
+ No browser tooling is silently downloaded. The runner
98
+ imports the consumer's journey module and requires at least one additional real
99
+ passing test beyond its generated route tests. Skips, flakes, expected-failure
100
+ annotations, incomplete results, and missing routes fail the evidence check.
101
+ Each generated route validates navigation, waits for the declared readiness
102
+ selector, checks page runtime errors, and performs axe WCAG 2/2.1 A/AA checks.
103
+ Automation does not establish complete accessibility compliance.
104
+
105
+ Selected visual routes compare repository-owned baselines with
106
+ `updateSnapshots: none`. Missing baselines fail; this runner never approves them.
107
+ Create and review baselines separately in the same browser/OS/font environment.
108
+ The generated configuration fixes Chromium, viewport, retries, and screenshot
109
+ animation handling; specialized device/authentication fixtures remain in the
110
+ consumer's journey tests. Remote base URLs require explicit `allowRemote: true`;
111
+ that is consent to network testing, not isolation. Secrets, account fixtures, and
112
+ production-safe test behavior remain the consumer's responsibility.
113
+
114
+ ## Cloudflare Workers
115
+
116
+ ```json
117
+ {
118
+ "id": "worker",
119
+ "type": "worker",
120
+ "buildCommand": ["bun", "run", "worker:bundle", "{output}"],
121
+ "files": ["worker.js"],
122
+ "budgets": { "rawBytes": 1000000, "gzipBytes": 250000 },
123
+ "runtimeCommand": ["bun", "run", "worker:compatibility", "{output}"]
124
+ }
125
+ ```
126
+
127
+ The build command must emit selected bundles into the fresh output directory.
128
+ `{output}` arguments and `CODE_FOUNDRY_QUALITY_OUTPUT_DIR` expose that directory.
129
+ Raw bytes and the sum of individually gzipped files are measured; they are not
130
+ claimed to equal Cloudflare's uploaded aggregate size or startup limits. Use
131
+ locked Wrangler/workerd to test compatibility dates, flags, and bindings in the
132
+ mandatory native runtime command. Production smoke probes belong in the verified
133
+ delivery workflow, not a hidden dependency of the deterministic build phase.
134
+ No Worker, Durable Object, binding, migration, or production resource is created.
135
+
136
+ ## Published packages
137
+
138
+ ```json
139
+ {
140
+ "id": "package",
141
+ "type": "package",
142
+ "imports": ["my-package", "my-package/feature"],
143
+ "runtimes": ["node"],
144
+ "offline": true
145
+ }
146
+ ```
147
+
148
+ The package profile packs once without lifecycle scripts, installs that archive
149
+ into an isolated temporary consumer, and exercises declared package exports using
150
+ Node and/or Bun already installed on the runner. Run build/test preparation
151
+ explicitly first. Both install scripts and package lifecycle hooks are disabled;
152
+ packages requiring installation hooks need a separately reviewed specialized
153
+ consumer suite. Runtime dependencies must be cached for the default offline mode;
154
+ `offline: false` explicitly permits normal registry resolution. Temporary package
155
+ installations are removed. Evidence is rejected if it leaks into the package.
156
+
157
+ ## Evidence and trust
158
+
159
+ Each summary has a unique run ID, manifest hash, phase, source commit/dirty status
160
+ when available, per-profile metrics or failure reasons, and final status. A new
161
+ output directory prevents old reports from becoming current evidence. The source
162
+ fields are descriptive, not a signed dirty-worktree snapshot. Commands are argv
163
+ arrays without shell expansion; repository-owned commands still have the normal
164
+ permissions of the executing process and are not sandboxed. Playwright's optional
165
+ web-server command is its native shell-command interface and must be reviewed.
166
+
167
+ The runner does not replace Lighthouse CI, native application tests, manual
168
+ accessibility testing, real consumer canaries, or release provenance. Existing
169
+ Lighthouse assertions can remain in repository-owned scripts/preparation; no
170
+ Lighthouse score or threshold is silently introduced by this feature.
171
+
172
+ References: [Playwright accessibility](https://playwright.dev/docs/accessibility-testing),
173
+ [Playwright snapshot configuration](https://playwright.dev/docs/api/class-testconfig#test-config-update-snapshots).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
6
6
  "bugs": {
@@ -0,0 +1,685 @@
1
+ // @ts-check
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { createRequire } from 'node:module'
5
+ import {
6
+ existsSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ rmSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from 'node:fs'
16
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
17
+ import { pathToFileURL } from 'node:url'
18
+ import { tmpdir } from 'node:os'
19
+ import { gzipSync } from 'node:zlib'
20
+
21
+ /** @typedef {Record<string, any>} Profile */
22
+ /** @param {unknown} condition @param {string} message @returns {asserts condition} */
23
+ function requireValue(condition, message) {
24
+ if (!condition) throw new Error(message)
25
+ }
26
+
27
+ /** Refuse traversal and symlinks, including symlinked parents. @param {string} root @param {string} path */
28
+ export function ownedPath(root, path) {
29
+ requireValue(
30
+ typeof path === 'string' && path.length > 0 && !isAbsolute(path),
31
+ 'Expected a relative repository path'
32
+ )
33
+ const base = realpathSync(root)
34
+ const destination = resolve(base, path)
35
+ const rel = relative(base, destination)
36
+ requireValue(
37
+ rel !== '' &&
38
+ rel !== '..' &&
39
+ !rel.startsWith('../') &&
40
+ !rel.startsWith('..\\') &&
41
+ !isAbsolute(rel),
42
+ 'Path escapes its root'
43
+ )
44
+ let current = base
45
+ for (const part of rel.split(/[\\/]/)) {
46
+ current = join(current, part)
47
+ let entry
48
+ try {
49
+ entry = lstatSync(current)
50
+ } catch (error) {
51
+ if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'ENOENT') throw error
52
+ }
53
+ requireValue(!entry?.isSymbolicLink(), 'Symlinks are not accepted in quality inputs or outputs')
54
+ }
55
+ return destination
56
+ }
57
+
58
+ /** @param {unknown} value @param {string} name */
59
+ function budget(value, name) {
60
+ requireValue(
61
+ typeof value === 'number' && Number.isFinite(value) && value >= 0,
62
+ `${name} must be a finite nonnegative budget`
63
+ )
64
+ return value
65
+ }
66
+
67
+ /** @param {string} value */
68
+ function decode(value) {
69
+ return value.replace(/&(#x[0-9a-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (_, entity) => {
70
+ if (entity[0] === '#')
71
+ return String.fromCodePoint(
72
+ entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1))
73
+ )
74
+ return /** @type {Record<string,string>} */ ({
75
+ amp: '&',
76
+ quot: '"',
77
+ apos: "'",
78
+ lt: '<',
79
+ gt: '>',
80
+ })[entity.toLowerCase()]
81
+ })
82
+ }
83
+
84
+ /** Conservative generated-HTML tag reader, not a DOM implementation.
85
+ * Raw-text HTML elements and comments cannot create fake metadata or links.
86
+ * @param {string} html
87
+ */
88
+ export function readTags(html) {
89
+ const clean = html
90
+ .replace(/<!--[\s\S]*?-->/g, '')
91
+ .replace(
92
+ /(<(script|style|textarea|template|xmp|iframe|noembed|noframes|noscript)\b(?:"[^"]*"|'[^']*'|[^'">])*>)[\s\S]*?<\/\2\s*>/gi,
93
+ '$1'
94
+ )
95
+ .replace(
96
+ /(<title\b(?:"[^"]*"|'[^']*'|[^'">])*>)([\s\S]*?)<\/title\s*>/gi,
97
+ (_, open, content) => `${open}${content.replace(/<[^>]*>/g, '')}</title>`
98
+ )
99
+ /** @type {Array<{ name: string, attrs: Record<string,string> }>} */
100
+ const tags = []
101
+ for (const match of clean.matchAll(/<([a-z][a-z0-9:-]*)\b((?:"[^"]*"|'[^']*'|[^'">])*)>/gi)) {
102
+ /** @type {Record<string,string>} */
103
+ const attrs = Object.create(null)
104
+ for (const attr of match[2].matchAll(
105
+ /([a-z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/gi
106
+ )) {
107
+ const key = attr[1].toLowerCase()
108
+ requireValue(!(key in attrs), `Duplicate HTML attribute: ${key}`)
109
+ attrs[key] = decode(attr[2] ?? attr[3] ?? attr[4] ?? '')
110
+ }
111
+ tags.push({ name: match[1].toLowerCase(), attrs })
112
+ }
113
+ return { clean, tags }
114
+ }
115
+
116
+ /** @param {string} root @param {Profile} profile */
117
+ export function checkStaticSite(root, profile) {
118
+ const dist = ownedPath(root, profile.dist)
119
+ requireValue(statSync(dist).isDirectory(), 'Static dist must be a directory')
120
+ const origin = new URL(profile.origin)
121
+ requireValue(
122
+ ['http:', 'https:'].includes(origin.protocol) &&
123
+ !origin.username &&
124
+ !origin.password &&
125
+ origin.pathname === '/' &&
126
+ !origin.search &&
127
+ !origin.hash,
128
+ 'origin must be an HTTP(S) origin without credentials or a path'
129
+ )
130
+ requireValue(
131
+ Array.isArray(profile.routes) && profile.routes.length > 0,
132
+ 'Static routes must be nonempty'
133
+ )
134
+ const limits = profile.budgets ?? {}
135
+ const htmlBudget = budget(limits.htmlBytes, 'htmlBytes')
136
+ const scriptBudget = budget(limits.javascriptBytes, 'javascriptBytes')
137
+ const imageBudget = budget(limits.imageBytes, 'imageBytes')
138
+ /** @type {Map<string, { file: string, clean: string, tags: ReturnType<typeof readTags>['tags'] }>} */
139
+ const routes = new Map()
140
+ /** @type {Map<string, string[]>} */
141
+ const routeAssets = new Map()
142
+ for (const route of profile.routes) {
143
+ requireValue(
144
+ typeof route.path === 'string' && route.path.startsWith('/') && !route.path.startsWith('//'),
145
+ 'Routes must be origin-relative paths'
146
+ )
147
+ const url = new URL(route.path, origin)
148
+ requireValue(
149
+ url.origin === origin.origin && !url.search && !url.hash && !routes.has(url.pathname),
150
+ 'Duplicate or invalid static route'
151
+ )
152
+ const javascriptAssets = route.javascriptAssets ?? []
153
+ requireValue(
154
+ Array.isArray(javascriptAssets) &&
155
+ javascriptAssets.every((asset) => typeof asset === 'string' && asset.length > 0),
156
+ `${url.pathname}: javascriptAssets must be a list of nonempty strings`
157
+ )
158
+ const file = ownedPath(dist, route.html)
159
+ const html = readFileSync(file, 'utf8')
160
+ routes.set(url.pathname, { file, ...readTags(html) })
161
+ routeAssets.set(url.pathname, javascriptAssets)
162
+ }
163
+ /** @type {Array<{ route: string, htmlBytes: number, javascriptBytes: number, imageBytes: number }>} */
164
+ const metrics = []
165
+ for (const [path, page] of routes) {
166
+ const canonical = new URL(path, origin).href
167
+ requireValue(
168
+ !page.tags.some((tag) => tag.name === 'base'),
169
+ `${path}: base elements require a repository-owned checker`
170
+ )
171
+ const title = page.clean.match(/<title(?:\s[^>]*)?>([^<]*)<\/title\s*>/i)?.[1]?.trim()
172
+ requireValue(title, `${path}: missing or empty title`)
173
+ const descriptions = page.tags.filter(
174
+ (tag) => tag.name === 'meta' && tag.attrs.name?.toLowerCase() === 'description'
175
+ )
176
+ requireValue(
177
+ descriptions.length === 1 && descriptions[0].attrs.content?.trim(),
178
+ `${path}: expected one nonempty meta description`
179
+ )
180
+ const canonicals = page.tags.filter(
181
+ (tag) =>
182
+ tag.name === 'link' && tag.attrs.rel?.toLowerCase().split(/\s+/).includes('canonical')
183
+ )
184
+ requireValue(
185
+ canonicals.length === 1 && canonicals[0].attrs.href === canonical,
186
+ `${path}: canonical URL mismatch`
187
+ )
188
+ requireValue(
189
+ !page.tags.some(
190
+ (tag) =>
191
+ tag.name === 'meta' &&
192
+ ['robots', 'googlebot'].includes(tag.attrs.name?.toLowerCase()) &&
193
+ /(?:^|[,\s])noindex(?:$|[,\s])/i.test(tag.attrs.content ?? '')
194
+ ),
195
+ `${path}: unexpected noindex`
196
+ )
197
+ const scripts = new Set(routeAssets.get(path))
198
+ const images = new Set()
199
+ for (const tag of page.tags) {
200
+ if (tag.name === 'script' && tag.attrs.src) scripts.add(tag.attrs.src)
201
+ if (tag.name === 'img' && tag.attrs.src && !tag.attrs.src.startsWith('data:'))
202
+ images.add(tag.attrs.src)
203
+ if (['img', 'source'].includes(tag.name) && tag.attrs.srcset) {
204
+ requireValue(
205
+ !tag.attrs.srcset.includes('data:'),
206
+ 'Data-URL srcsets require a repository-owned checker'
207
+ )
208
+ for (const candidate of tag.attrs.srcset.split(',')) {
209
+ const resource = candidate.trim().split(/\s+/)[0]
210
+ requireValue(resource, `${path}: srcset contains an empty candidate`)
211
+ images.add(resource)
212
+ }
213
+ }
214
+ if (tag.name !== 'a' || !tag.attrs.href) continue
215
+ const target = new URL(tag.attrs.href, canonical)
216
+ if (!['http:', 'https:'].includes(target.protocol) || target.origin !== origin.origin)
217
+ continue
218
+ const targetPage = routes.get(target.pathname)
219
+ if (targetPage) {
220
+ if (target.hash) {
221
+ const id = decodeURIComponent(target.hash.slice(1))
222
+ requireValue(
223
+ targetPage.tags.some(
224
+ (entry) => entry.attrs.id === id || (entry.name === 'a' && entry.attrs.name === id)
225
+ ),
226
+ `${path}: missing anchor ${target.hash}`
227
+ )
228
+ }
229
+ } else {
230
+ const asset = ownedPath(dist, decodeURIComponent(target.pathname).replace(/^\//, ''))
231
+ requireValue(
232
+ existsSync(asset) && lstatSync(asset).isFile(),
233
+ `${path}: broken internal link ${target.pathname}`
234
+ )
235
+ }
236
+ }
237
+ const assetBytes = (/** @type {Set<string>} */ assets) =>
238
+ [...assets].reduce((sum, resource) => {
239
+ const url = new URL(resource, canonical)
240
+ requireValue(
241
+ url.origin === origin.origin,
242
+ `${path}: external resource needs a measured repository-owned budget: ${url.origin}`
243
+ )
244
+ const file = ownedPath(dist, decodeURIComponent(url.pathname).replace(/^\//, ''))
245
+ requireValue(lstatSync(file).isFile(), 'Budget assets must be regular files')
246
+ return sum + statSync(file).size
247
+ }, 0)
248
+ const inlineBytes = [
249
+ ...readFileSync(page.file, 'utf8')
250
+ .replace(/<!--[\s\S]*?-->/g, '')
251
+ .matchAll(/<script\b(?:"[^"]*"|'[^']*'|[^'">])*?>([\s\S]*?)<\/script\s*>/gi),
252
+ ].reduce((sum, match) => sum + Buffer.byteLength(match[1]), 0)
253
+ const result = {
254
+ route: path,
255
+ htmlBytes: statSync(page.file).size,
256
+ javascriptBytes: assetBytes(scripts) + inlineBytes,
257
+ imageBytes: assetBytes(images),
258
+ }
259
+ requireValue(
260
+ result.htmlBytes <= htmlBudget &&
261
+ result.javascriptBytes <= scriptBudget &&
262
+ result.imageBytes <= imageBudget,
263
+ `${path}: resource budget exceeded`
264
+ )
265
+ metrics.push(result)
266
+ }
267
+ if (profile.sitemap) {
268
+ const sitemap = readFileSync(ownedPath(dist, profile.sitemap), 'utf8')
269
+ requireValue(!/<!DOCTYPE|<!ENTITY/i.test(sitemap), 'Sitemap entities are not supported')
270
+ const sitemapWithoutComments = sitemap.replace(/<!--[\s\S]*?-->/g, '')
271
+ const locations = new Set(
272
+ [
273
+ ...sitemapWithoutComments.matchAll(
274
+ /<url\b[^>]*>[\s\S]*?<loc>\s*([^<]+)\s*<\/loc>[\s\S]*?<\/url\s*>/gi
275
+ ),
276
+ ].map((match) => decode(match[1].trim()))
277
+ )
278
+ for (const path of routes.keys())
279
+ requireValue(locations.has(new URL(path, origin).href), `${path}: absent from sitemap`)
280
+ const robots = readFileSync(ownedPath(dist, profile.robots ?? 'robots.txt'), 'utf8')
281
+ requireValue(
282
+ robots
283
+ .split(/\r?\n/)
284
+ .some((line) => line.trim() === `Sitemap: ${new URL(profile.sitemap, origin).href}`),
285
+ 'robots.txt must reference the checked sitemap'
286
+ )
287
+ requireValue(!/^Disallow:\s*\/\s*$/im.test(robots), 'robots.txt blocks the entire site')
288
+ }
289
+ if (profile.redirects) {
290
+ requireValue(
291
+ Array.isArray(profile.redirects.rules) && profile.redirects.rules.length > 0,
292
+ 'Redirect expectations must be nonempty'
293
+ )
294
+ const lines = readFileSync(ownedPath(dist, profile.redirects.file), 'utf8')
295
+ .split(/\r?\n/)
296
+ .map((line) => line.trim())
297
+ .filter((line) => line && !line.startsWith('#'))
298
+ for (const rule of profile.redirects.rules) {
299
+ requireValue(
300
+ typeof rule.from === 'string' &&
301
+ rule.from.startsWith('/') &&
302
+ typeof rule.to === 'string' &&
303
+ [301, 302, 303, 307, 308].includes(rule.status),
304
+ 'Invalid redirect expectation'
305
+ )
306
+ // Exact simple Cloudflare/Netlify-style _redirects lines only; this is not a deployed routing probe.
307
+ const matches = lines.filter((line) => line.split(/\s+/)[0] === rule.from)
308
+ requireValue(
309
+ matches.length === 1 &&
310
+ matches[0].split(/\s+/).join(' ') === `${rule.from} ${rule.to} ${rule.status}`,
311
+ `Missing or ambiguous redirect: ${rule.from}`
312
+ )
313
+ }
314
+ }
315
+ return metrics
316
+ }
317
+
318
+ /** @param {unknown} argv @param {string} root @param {string} output @param {number} timeout */
319
+ export function runQualityCommand(argv, root, output, timeout = 120_000) {
320
+ requireValue(
321
+ Array.isArray(argv) &&
322
+ argv.length > 0 &&
323
+ argv.every((value) => typeof value === 'string' && value.length > 0),
324
+ 'Commands must be nonempty argv arrays'
325
+ )
326
+ const args = /** @type {string[]} */ (argv).map((value) => value.replaceAll('{output}', output))
327
+ const result = spawnSync(args[0], args.slice(1), {
328
+ cwd: root,
329
+ encoding: 'utf8',
330
+ timeout,
331
+ maxBuffer: 16 * 1024 * 1024,
332
+ env: { ...process.env, CI: 'true', CODE_FOUNDRY_QUALITY_OUTPUT_DIR: output },
333
+ })
334
+ if (result.stdout) process.stderr.write(result.stdout)
335
+ if (result.stderr) process.stderr.write(result.stderr)
336
+ requireValue(
337
+ !result.error && result.status === 0,
338
+ `Quality command failed: ${args[0]} (${result.status ?? result.error?.message})`
339
+ )
340
+ return result.stdout
341
+ }
342
+
343
+ /** @param {string} root @param {Profile} profile @param {string} output */
344
+ export function checkWorker(root, profile, output) {
345
+ requireValue(
346
+ Array.isArray(profile.files) && profile.files.length > 0,
347
+ 'Worker bundle files are required'
348
+ )
349
+ runQualityCommand(profile.buildCommand, root, output)
350
+ let rawBytes = 0
351
+ let gzipBytes = 0
352
+ for (const name of new Set(/** @type {string[]} */ (profile.files))) {
353
+ const file = ownedPath(output, name)
354
+ requireValue(lstatSync(file).isFile(), 'Worker bundle must be a regular file')
355
+ const data = readFileSync(file)
356
+ rawBytes += data.length
357
+ gzipBytes += gzipSync(data).length
358
+ }
359
+ requireValue(rawBytes > 0, 'Worker bundle is empty')
360
+ requireValue(
361
+ rawBytes <= budget(profile.budgets?.rawBytes, 'rawBytes'),
362
+ 'Worker raw-byte budget exceeded'
363
+ )
364
+ requireValue(
365
+ gzipBytes <= budget(profile.budgets?.gzipBytes, 'gzipBytes'),
366
+ 'Worker gzip budget exceeded'
367
+ )
368
+ runQualityCommand(profile.runtimeCommand, root, output)
369
+ return { rawBytes, gzipBytes }
370
+ }
371
+
372
+ /** @param {string} root @param {Profile} profile @param {string} output */
373
+ export function checkPackage(root, profile, output) {
374
+ const manifest = JSON.parse(readFileSync(ownedPath(root, 'package.json'), 'utf8'))
375
+ requireValue(
376
+ typeof manifest.name === 'string' && typeof manifest.version === 'string',
377
+ 'Package identity is required'
378
+ )
379
+ requireValue(
380
+ Array.isArray(profile.imports) &&
381
+ profile.imports.length > 0 &&
382
+ profile.imports.every(
383
+ (/** @type {unknown} */ value) =>
384
+ typeof value === 'string' &&
385
+ (value === manifest.name || value.startsWith(`${manifest.name}/`))
386
+ ),
387
+ 'Imports must exercise the candidate package exports'
388
+ )
389
+ const runtimes = profile.runtimes ?? ['node']
390
+ requireValue(
391
+ Array.isArray(runtimes) &&
392
+ runtimes.length > 0 &&
393
+ runtimes.every((/** @type {unknown} */ value) =>
394
+ ['node', 'bun'].includes(/** @type {string} */ (value))
395
+ ),
396
+ 'Package runtimes must be a nonempty node/bun list'
397
+ )
398
+ // Pack outside the consumer repository so test installations cannot become package input.
399
+ const temporary = mkdtempSync(join(tmpdir(), 'foundry-package-quality-'))
400
+ try {
401
+ const pack = JSON.parse(
402
+ runQualityCommand(
403
+ ['npm', 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary],
404
+ root,
405
+ output
406
+ )
407
+ )
408
+ requireValue(
409
+ Array.isArray(pack) && pack.length === 1 && /^[A-Za-z0-9._-]+\.tgz$/.test(pack[0].filename),
410
+ 'Expected one package archive'
411
+ )
412
+ requireValue(
413
+ pack[0].files.every(
414
+ (/** @type {{path:string}} */ file) => !/^\.code-foundry(?:\/|$)/.test(file.path)
415
+ ),
416
+ 'Exclude .code-foundry quality evidence from the published package'
417
+ )
418
+ const archive = ownedPath(temporary, pack[0].filename)
419
+ const archiveSha256 = createHash('sha256').update(readFileSync(archive)).digest('hex')
420
+ const consumer = join(temporary, 'consumer')
421
+ mkdirSync(consumer)
422
+ writeFileSync(join(consumer, 'package.json'), '{"private":true,"type":"module"}\n')
423
+ const args = [
424
+ 'npm',
425
+ 'install',
426
+ '--ignore-scripts',
427
+ '--no-audit',
428
+ '--no-fund',
429
+ '--package-lock=false',
430
+ ]
431
+ if (profile.offline !== false) args.push('--offline')
432
+ args.push(archive)
433
+ runQualityCommand(args, consumer, output)
434
+ const script = `for (const name of ${JSON.stringify(profile.imports)}) await import(name)\n`
435
+ writeFileSync(join(consumer, 'smoke.mjs'), script)
436
+ for (const runtime of runtimes)
437
+ runQualityCommand(
438
+ [runtime === 'node' ? process.execPath : 'bun', 'smoke.mjs'],
439
+ consumer,
440
+ output
441
+ )
442
+ requireValue(
443
+ createHash('sha256').update(readFileSync(archive)).digest('hex') === archiveSha256,
444
+ 'Package archive changed during compatibility testing'
445
+ )
446
+ return {
447
+ name: manifest.name,
448
+ version: manifest.version,
449
+ archiveSha256,
450
+ node: process.version,
451
+ runtimes,
452
+ }
453
+ } finally {
454
+ rmSync(temporary, { recursive: true, force: true })
455
+ }
456
+ }
457
+
458
+ /** Native Playwright tests retain repo-owned journeys and screenshot baselines.
459
+ * @param {string} root @param {Profile} profile @param {string} output
460
+ */
461
+ export function browserFiles(root, profile, output) {
462
+ requireValue(
463
+ Array.isArray(profile.routes) && profile.routes.length > 0,
464
+ 'Browser routes are required'
465
+ )
466
+ const url = new URL(profile.baseURL)
467
+ requireValue(
468
+ ['http:', 'https:'].includes(url.protocol) &&
469
+ !url.username &&
470
+ !url.password &&
471
+ url.pathname === '/' &&
472
+ !url.search &&
473
+ !url.hash,
474
+ 'browser baseURL must be an HTTP(S) origin without credentials or a path'
475
+ )
476
+ requireValue(
477
+ profile.allowRemote === true || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname),
478
+ 'Remote browser checks require allowRemote: true'
479
+ )
480
+ const journeys = ownedPath(root, profile.journeys)
481
+ requireValue(lstatSync(journeys).isFile(), 'A repository-owned journey suite is required')
482
+ const snapshots = ownedPath(root, profile.snapshots ?? '.github/quality-snapshots')
483
+ const routes = profile.routes.map((/** @type {Profile} */ route) => {
484
+ requireValue(
485
+ typeof route.id === 'string' && /^[a-z0-9-]+$/.test(route.id),
486
+ 'Route IDs must be safe snapshot names'
487
+ )
488
+ requireValue(
489
+ typeof route.path === 'string' &&
490
+ route.path.startsWith('/') &&
491
+ new URL(route.path, url).origin === url.origin,
492
+ 'Browser routes must stay on the selected origin'
493
+ )
494
+ requireValue(
495
+ typeof route.readySelector === 'string' && route.readySelector.length > 0,
496
+ 'A readySelector is required for each browser route'
497
+ )
498
+ return route
499
+ })
500
+ requireValue(
501
+ new Set(routes.map((/** @type {Profile} */ route) => route.id)).size === routes.length,
502
+ 'Duplicate browser route IDs'
503
+ )
504
+ if (profile.webServerCommand !== undefined)
505
+ requireValue(
506
+ typeof profile.webServerCommand === 'string' && profile.webServerCommand.length > 0,
507
+ 'webServerCommand must be a nonempty command string'
508
+ )
509
+ const spec = `import { createRequire } from 'node:module'\nconst require = createRequire(${JSON.stringify(join(root, 'package.json'))})\nconst { test, expect } = require('@playwright/test')\nconst AxeBuilder = require('@axe-core/playwright').default\nawait import(${JSON.stringify(pathToFileURL(journeys).href)})\nfor (const route of ${JSON.stringify(routes)}) {\n test('quality: ' + route.id, async ({ page }) => {\n const errors = []\n page.on('pageerror', error => errors.push(error.message))\n const response = await page.goto(route.path)\n expect(response?.ok()).toBeTruthy()\n await expect(page.locator(route.readySelector)).toBeVisible()\n const accessibility = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze()\n expect(accessibility.violations).toEqual([])\n if (route.visual === true) await expect(page).toHaveScreenshot(route.id + '.png', { animations: 'disabled' })\n expect(errors).toEqual([])\n })\n}\n`
510
+ const config = {
511
+ testDir: output,
512
+ testMatch: 'quality.spec.mjs',
513
+ outputDir: join(output, 'results'),
514
+ workers: 1,
515
+ retries: 0,
516
+ updateSnapshots: 'none',
517
+ ignoreSnapshots: false,
518
+ reporter: [['json', { outputFile: join(output, 'playwright.json') }]],
519
+ snapshotPathTemplate: `${snapshots}/{arg}{ext}`,
520
+ use: {
521
+ baseURL: profile.baseURL,
522
+ browserName: 'chromium',
523
+ viewport: { width: 1280, height: 720 },
524
+ trace: 'retain-on-failure',
525
+ },
526
+ ...(profile.webServerCommand
527
+ ? {
528
+ webServer: {
529
+ command: profile.webServerCommand,
530
+ cwd: root,
531
+ url: profile.baseURL,
532
+ reuseExistingServer: false,
533
+ timeout: 120_000,
534
+ },
535
+ }
536
+ : {}),
537
+ }
538
+ return { spec, config }
539
+ }
540
+
541
+ /** @param {string} root @param {Profile} profile @param {string} output */
542
+ export function checkBrowser(root, profile, output) {
543
+ const require = createRequire(join(root, 'package.json'))
544
+ const cli = join(dirname(require.resolve('@playwright/test/package.json')), 'cli.js')
545
+ require.resolve('@axe-core/playwright')
546
+ const { spec, config } = browserFiles(root, profile, output)
547
+ writeFileSync(join(output, 'quality.spec.mjs'), spec)
548
+ writeFileSync(
549
+ join(output, 'playwright.config.mjs'),
550
+ `export default ${JSON.stringify(config, null, 2)}\n`
551
+ )
552
+ runQualityCommand(
553
+ [process.execPath, cli, 'test', '--config', join(output, 'playwright.config.mjs')],
554
+ root,
555
+ output,
556
+ 600_000
557
+ )
558
+ const report = JSON.parse(readFileSync(join(output, 'playwright.json'), 'utf8'))
559
+ validateBrowserReport(
560
+ report,
561
+ profile.routes.map((/** @type {Profile} */ route) => `quality: ${route.id}`)
562
+ )
563
+ return { tests: report.stats.expected, report: join(output, 'playwright.json') }
564
+ }
565
+
566
+ /** Reject expected-failure annotations as well as skips and incomplete reports.
567
+ * @param {Profile} report @param {string[]} expectedRoutes
568
+ */
569
+ export function validateBrowserReport(report, expectedRoutes) {
570
+ /** @type {Profile[]} */
571
+ const specs = []
572
+ const visit = (/** @type {Profile} */ suite) => {
573
+ specs.push(...(suite.specs ?? []))
574
+ for (const child of suite.suites ?? []) visit(child)
575
+ }
576
+ for (const suite of report.suites ?? []) visit(suite)
577
+ requireValue(
578
+ report.stats?.unexpected === 0 &&
579
+ report.stats?.flaky === 0 &&
580
+ report.stats?.skipped === 0 &&
581
+ report.stats?.expected > expectedRoutes.length,
582
+ 'Browser checks need passing routes and at least one journey, without skips or flakes'
583
+ )
584
+ requireValue(
585
+ specs.length > expectedRoutes.length,
586
+ 'Browser report has no repository-owned journey'
587
+ )
588
+ for (const title of expectedRoutes)
589
+ requireValue(
590
+ specs.filter((spec) => spec.title === title).length === 1,
591
+ `Missing or duplicate browser route: ${title}`
592
+ )
593
+ for (const spec of specs) {
594
+ requireValue(
595
+ spec.ok === true && Array.isArray(spec.tests) && spec.tests.length > 0,
596
+ 'Browser specification did not pass'
597
+ )
598
+ for (const item of spec.tests)
599
+ requireValue(
600
+ item.expectedStatus === 'passed' &&
601
+ item.status === 'expected' &&
602
+ Array.isArray(item.results) &&
603
+ item.results.length > 0 &&
604
+ item.results.every((/** @type {Profile} */ value) => value.status === 'passed'),
605
+ 'Expected failures and incomplete browser results are not accepted'
606
+ )
607
+ }
608
+ }
609
+
610
+ /** @param {string} root @param {Profile} config @param {string} [phase] */
611
+ export function runProductQuality(root, config, phase = 'build') {
612
+ requireValue(
613
+ config.schema_version === 1 && Array.isArray(config.profiles) && config.profiles.length > 0,
614
+ 'Expected a version-1 quality manifest with profiles'
615
+ )
616
+ requireValue(['build', 'browser', 'deployed'].includes(phase), 'Unknown quality phase')
617
+ const ids = config.profiles.map((/** @type {Profile} */ profile) => profile.id)
618
+ requireValue(
619
+ ids.every((/** @type {unknown} */ id) => typeof id === 'string' && /^[a-z0-9-]+$/.test(id)) &&
620
+ new Set(ids).size === ids.length,
621
+ 'Profile IDs must be unique safe names'
622
+ )
623
+ for (const profile of config.profiles) {
624
+ requireValue(
625
+ ['static-site', 'web-app', 'worker', 'package'].includes(profile.type),
626
+ `Unknown quality profile: ${profile.type}`
627
+ )
628
+ requireValue(
629
+ ['build', 'browser', 'deployed'].includes(
630
+ profile.phase ?? (profile.type === 'web-app' ? 'browser' : 'build')
631
+ ),
632
+ 'Unknown profile phase'
633
+ )
634
+ }
635
+ const profiles = config.profiles.filter(
636
+ (/** @type {Profile} */ profile) =>
637
+ (profile.phase ?? (profile.type === 'web-app' ? 'browser' : 'build')) === phase
638
+ )
639
+ requireValue(profiles.length > 0, 'No profiles apply to the selected phase')
640
+ const outputRoot = ownedPath(root, '.code-foundry/quality')
641
+ mkdirSync(outputRoot, { recursive: true })
642
+ const output = mkdtempSync(join(outputRoot, `${phase}-`))
643
+ /** @type {Profile[]} */
644
+ const results = []
645
+ const source = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' })
646
+ const dirty = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' })
647
+ const result = {
648
+ schema_version: 1,
649
+ run_id: randomUUID(),
650
+ phase,
651
+ status: 'failed',
652
+ profiles: results,
653
+ source_sha: source.status === 0 ? source.stdout.trim() : null,
654
+ dirty: dirty.status === 0 ? dirty.stdout.length > 0 : null,
655
+ config_sha256: createHash('sha256').update(JSON.stringify(config)).digest('hex'),
656
+ }
657
+ try {
658
+ for (const command of config.prepare?.[phase] ?? []) runQualityCommand(command, root, output)
659
+ for (const profile of profiles) {
660
+ const directory = join(output, profile.id)
661
+ mkdirSync(directory)
662
+ try {
663
+ let metrics
664
+ if (profile.type === 'static-site') metrics = checkStaticSite(root, profile)
665
+ else if (profile.type === 'worker') metrics = checkWorker(root, profile, directory)
666
+ else if (profile.type === 'package') metrics = checkPackage(root, profile, directory)
667
+ else metrics = checkBrowser(root, profile, directory)
668
+ result.profiles.push({ id: profile.id, type: profile.type, status: 'passed', metrics })
669
+ } catch (error) {
670
+ result.profiles.push({
671
+ id: profile.id,
672
+ type: profile.type,
673
+ status: 'failed',
674
+ reason: error instanceof Error ? error.message : String(error),
675
+ })
676
+ throw error
677
+ }
678
+ }
679
+ result.status = 'passed'
680
+ return result
681
+ } finally {
682
+ writeFileSync(join(output, 'summary.json'), JSON.stringify(result, null, 2) + '\n')
683
+ console.error(`Quality evidence: ${relative(root, output)}/summary.json`)
684
+ }
685
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ import { readFileSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
5
+ import { ownedPath, runProductQuality } from './lib/product-quality.mjs'
6
+
7
+ try {
8
+ const [configPath, phase = 'build', ...extra] = process.argv.slice(2)
9
+ if (!configPath || extra.length)
10
+ throw new Error('Usage: node quality.mjs MANIFEST.json [build|browser|deployed]')
11
+ const root = resolve(process.cwd())
12
+ const config = JSON.parse(readFileSync(ownedPath(root, configPath), 'utf8'))
13
+ console.log(JSON.stringify(runProductQuality(root, config, phase)))
14
+ } catch (error) {
15
+ console.error(error instanceof Error ? error.message : String(error))
16
+ process.exitCode = 1
17
+ }