From c164b82ff49916affbc7548247c1b4e164241d2b Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 30 Oct 2026 11:20:03 -0400 Subject: [PATCH 1/4] feat: inert server-feature forms in the forms gate --- skills/convert-site/references/forms.md | 9 ++++ skills/parity-gates/scripts/verify/forms.mjs | 48 ++++++++++++++++++-- test/gates/forms-inert.test.mjs | 21 +++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 test/gates/forms-inert.test.mjs diff --git a/skills/convert-site/references/forms.md b/skills/convert-site/references/forms.md index 2951199..e52185f 100644 --- a/skills/convert-site/references/forms.md +++ b/skills/convert-site/references/forms.md @@ -26,6 +26,15 @@ live like a GET form (the same full action URL, method and fields) and notes "N is (posts to )"; rewiring one to the endpoint fails the gate. A site whose POST forms all post elsewhere needs no `--endpoint` and no form id. +## Server features: inert, never rewired + +A login, account, password-reset, cart, coupon or checkout form cannot work on a static site, and +rewiring it would send passwords or billing details to the form service. Never rewire one. Keep +its markup and fields, set `action="#"` and `onsubmit="return false"`, and list it in +`inert-forms.json` as `{ path, form, reason, decision }`, where `form` is its 0-based index among +the page's forms. The forms gate requires exactly that and counts it as skipped: it does not work, +and the PR and runbook say so. + ## What stays the same The forms gate compares each built form with `baseline/forms.json`: the same field names in diff --git a/skills/parity-gates/scripts/verify/forms.mjs b/skills/parity-gates/scripts/verify/forms.mjs index f8604ee..0a8b1a4 100644 --- a/skills/parity-gates/scripts/verify/forms.mjs +++ b/skills/parity-gates/scripts/verify/forms.mjs @@ -19,6 +19,7 @@ const OPTIONS = { endpoint: { type: 'string', default: '', description: 'the URL every POST form posts to; required when the baseline has a POST form' }, honeypot: { type: 'string', default: '', description: 'a field name every POST form must have; allowed even where the live form had none' }, 'thank-you': { type: 'string', default: '', description: 'the page every POST form leads to: a hidden _next or redirect field set to it (the path, or an absolute URL on the canonical host), and a built page' }, + 'inert-forms': { type: 'string', default: 'inert-forms.json', description: 'JSON list of { path, form, reason, decision }: POST forms of a server feature (login, checkout) that must send nothing' }, }; const NOTES = ` A GET form (method get, or no method: a search box) is compared with live as it is: the same @@ -40,6 +41,12 @@ path must be a page in dist. The field may hold the path itself, or an absolute endpoints require) whose host is the baseline's canonicalOrigin (origin when meta.json has none) and whose path is --thank-you. Without it the thank-you page is not checked, and the summary counts each form as skipped. +A login, account, cart or checkout form is a server feature, and rewiring it to a form service +would send passwords or billing details to that service. List such a form in --inert-forms +(default inert-forms.json, when it exists) with its page, its 0-based index among the page's +forms, a reason and a decision id. The gate then requires the same fields and method, an action +of "#" and onsubmit="return false", so the form cannot send anything; the summary counts it as +skipped, because it does not work. A listed form that can still submit fails. `; const THANK_YOU_FIELDS = ['_next', 'redirect']; @@ -47,6 +54,7 @@ const THANK_YOU_FIELDS = ['_next', 'redirect']; export function formsOf(html) { return findAll(parseHtml(html), byTag('form')).map((form) => ({ action: attr(form, 'action') || null, + onsubmit: attr(form, 'onsubmit') ?? null, method: (attr(form, 'method') || 'get').toLowerCase(), fields: formFields(form).map((f) => f.name), hidden: Object.fromEntries( @@ -186,7 +194,22 @@ export function compareForm(path, label, expected, actual, { endpoint, honeypot, return failures; } -export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = '', meta = {}, paths = () => true }) { +// A form listed as inert: the same fields and method as live, and no way to send them. +export function compareInertForm(path, label, expected, actual) { + const failures = []; + const field = (name) => (label ? `${label} ${name}` : name); + const want = expected.fields.map((f) => (typeof f === 'string' ? f : f.name)); + if (!same(want, actual.fields)) failures.push({ path, field: field('fields'), expected: want, actual: actual.fields }); + const method = (expected.method || 'get').toLowerCase(); + if (method !== actual.method) failures.push({ path, field: field('method'), expected: method, actual: actual.method }); + if (actual.action !== '#') failures.push({ path, field: field('inert action'), expected: '#', actual: actual.action }); + if (actual.onsubmit !== 'return false') failures.push({ path, field: field('inert onsubmit'), expected: 'return false', actual: actual.onsubmit }); + return failures; +} + +export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = '', meta = {}, paths = () => true, inert = [] }) { + const inertKeys = new Set(inert.map((entry) => `${entry.path}#${entry.form}`)); + let inertCount = 0; const siteHost = siteHostOf(meta); const origins = [meta.origin, meta.canonicalOrigin].map((o) => originOf(o ?? '')).filter(Boolean); let getCount = 0; @@ -212,6 +235,11 @@ export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = '' } expected.forEach((form, i) => { const label = expected.length > 1 ? `form ${i + 1}` : ''; + if (inertKeys.has(`${path}#${i}`)) { + inertCount += 1; + failures.push(...compareInertForm(path, label, form, actual[i])); + return; + } const host = externalHost(form, path, origins); if (host) { keptHosts.push(host); @@ -236,8 +264,9 @@ export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = '' const candidates = pageFiles(thankYou, format); if (!candidates.some((c) => files.includes(c))) failures.push({ path: thankYou, field: 'thank-you', expected: candidates[0], actual: null }); } - if (thankYou || formCount === 0) return { checked, failures, notes }; - return { checked, failures, skipped: formCount, skipReason: 'thank-you not checked', notes: [...notes, 'thank-you: not checked (no --thank-you)'] }; + const inertSkip = inertCount ? { skipped: inertCount, skipReason: 'inert server-feature forms, they do not work' } : {}; + if (thankYou || formCount === 0) return { checked, failures, notes, ...inertSkip }; + return { checked, failures, skipped: formCount + inertCount, skipReason: `thank-you not checked${inertCount ? ', inert server-feature forms' : ''}`, notes: [...notes, 'thank-you: not checked (no --thank-you)'] }; } function main() { @@ -248,8 +277,16 @@ function main() { const metaFile = join(args.baseline, 'meta.json'); const meta = existsSync(metaFile) ? readJson(metaFile) : {}; const origins = [meta.origin, meta.canonicalOrigin].map((o) => originOf(o ?? '')).filter(Boolean); - const postsToSite = (form, path) => isPost(form) && !externalHost(form, path, origins); - const withForms = Object.entries(forms).filter(([path, list]) => args.paths(path) && list.some((form) => postsToSite(form, path))); + const inert = existsSync(args.inertForms) ? readJson(args.inertForms) : []; + for (const entry of inert) { + if (!entry.path || !Number.isInteger(entry.form) || !entry.reason || !entry.decision) { + process.stderr.write(`${NAME}: every ${args.inertForms} entry needs path, form, reason and decision\n`); + process.exit(2); + } + } + const inertKeys = new Set(inert.map((entry) => `${entry.path}#${entry.form}`)); + const postsToSite = (form, path, i) => isPost(form) && !externalHost(form, path, origins) && !inertKeys.has(`${path}#${i}`); + const withForms = Object.entries(forms).filter(([path, list]) => args.paths(path) && list.some((form, i) => postsToSite(form, path, i))); if (withForms.length > 0 && !args.endpoint) { process.stderr.write(`${NAME}: --endpoint is required: the baseline has POST forms on ${withForms.map(([p]) => p).join(', ')}\n`); process.exit(2); @@ -262,6 +299,7 @@ function main() { thankYou: args.thankYou, meta, paths: args.paths, + inert, }); report(NAME, results, { json: args.json }); } diff --git a/test/gates/forms-inert.test.mjs b/test/gates/forms-inert.test.mjs new file mode 100644 index 0000000..eec419b --- /dev/null +++ b/test/gates/forms-inert.test.mjs @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { compareInertForm } from '../../skills/parity-gates/scripts/verify/forms.mjs'; + +const live = { method: 'post', action: 'https://example.com/wp-login.php', fields: ['log', 'pwd'] }; + +test('an inert form with the live fields and no way to send passes', () => { + const built = { method: 'post', action: '#', onsubmit: 'return false', fields: ['log', 'pwd'] }; + assert.deepEqual(compareInertForm('/log-in/', '', live, built), []); +}); + +test('a listed form that can still submit fails', () => { + const built = { method: 'post', action: 'https://formspree.io/f/abc', onsubmit: null, fields: ['log', 'pwd'] }; + const fields = compareInertForm('/log-in/', '', live, built).map((f) => f.field); + assert.deepEqual(fields, ['inert action', 'inert onsubmit']); +}); + +test('a listed form that lost a field fails', () => { + const built = { method: 'post', action: '#', onsubmit: 'return false', fields: ['log'] }; + assert.deepEqual(compareInertForm('/log-in/', '', live, built).map((f) => f.field), ['fields']); +}); From 626e6a3050da5dca8828aa6fd5cd666d804b1fbf Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 30 Oct 2026 11:25:39 -0400 Subject: [PATCH 2/4] fix: keep doubled-slash same-origin CSS URLs root-relative --- skills/convert-site/scripts/css-prune.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/convert-site/scripts/css-prune.mjs b/skills/convert-site/scripts/css-prune.mjs index 0c1c69d..4c1bd9e 100644 --- a/skills/convert-site/scripts/css-prune.mjs +++ b/skills/convert-site/scripts/css-prune.mjs @@ -249,6 +249,8 @@ export function rewriteRef(ref, base, siteOrigin) { return null; } if (resolved.origin === PLACEHOLDER || resolved.origin === siteOrigin) { + // A live URL with a doubled slash (https://site//a.ttf) must not become protocol-relative. + resolved.pathname = resolved.pathname.replace(/^\/{2,}/, '/'); return resolved.pathname + resolved.search + resolved.hash; } return absolute || value.startsWith('//') ? null : resolved.href; From 459284e6e1c5055cab1295f21ad45b0d291f025d Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 30 Oct 2026 11:56:58 -0400 Subject: [PATCH 3/4] fix: read CDATA sitemap locations in the sitemaps gate --- skills/parity-gates/scripts/verify/sitemaps.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/parity-gates/scripts/verify/sitemaps.mjs b/skills/parity-gates/scripts/verify/sitemaps.mjs index 47b2268..01393fe 100644 --- a/skills/parity-gates/scripts/verify/sitemaps.mjs +++ b/skills/parity-gates/scripts/verify/sitemaps.mjs @@ -47,7 +47,7 @@ const decode = (value) => { const unescapeXml = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&'); export function locs(xml) { - return [...xml.matchAll(/\s*([^<]*?)\s*<\/loc>/g)].map((m) => unescapeXml(m[1])); + return [...xml.matchAll(/\s*(?:|([^<]*?))\s*<\/loc>/g)].map((m) => m[1] ?? unescapeXml(m[2])); } export const itemCount = (xml) => (xml.match(/<(?:item|entry)[\s>]/g) ?? []).length; From 7487edf45075e0c15751af9a1fd9382f350ccfd6 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 30 Oct 2026 14:58:06 -0400 Subject: [PATCH 4/4] refactor: keep the inert-forms rule to a principle and one check No CLI flag or decision id; the agent decides how to handle a server-only form, and the gate only checks that a form kept but switched off sends nowhere. --- skills/convert-site/references/forms.md | 15 +++++----- skills/parity-gates/scripts/verify/forms.mjs | 29 +++++++++----------- test/gates/forms-inert.test.mjs | 9 +++--- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/skills/convert-site/references/forms.md b/skills/convert-site/references/forms.md index e52185f..cb2f2c9 100644 --- a/skills/convert-site/references/forms.md +++ b/skills/convert-site/references/forms.md @@ -26,14 +26,13 @@ live like a GET form (the same full action URL, method and fields) and notes "N is (posts to )"; rewiring one to the endpoint fails the gate. A site whose POST forms all post elsewhere needs no `--endpoint` and no form id. -## Server features: inert, never rewired - -A login, account, password-reset, cart, coupon or checkout form cannot work on a static site, and -rewiring it would send passwords or billing details to the form service. Never rewire one. Keep -its markup and fields, set `action="#"` and `onsubmit="return false"`, and list it in -`inert-forms.json` as `{ path, form, reason, decision }`, where `form` is its 0-based index among -the page's forms. The forms gate requires exactly that and counts it as skipped: it does not work, -and the PR and runbook say so. +## Forms the static site cannot serve + +A login, account, cart or checkout form needs a server, and rewiring it to a form service would +send passwords or payment details there. Never rewire one. How to handle it is a judgement for +the site: keep it switched off, link out to wherever that feature will live, or replace it with +an embed. A form kept but switched off gets an action that sends nowhere and a row in +`inert-forms.json` (`{ path, form, reason }`); the PR and runbook say it does not work. ## What stays the same diff --git a/skills/parity-gates/scripts/verify/forms.mjs b/skills/parity-gates/scripts/verify/forms.mjs index 0a8b1a4..9a7f109 100644 --- a/skills/parity-gates/scripts/verify/forms.mjs +++ b/skills/parity-gates/scripts/verify/forms.mjs @@ -19,7 +19,6 @@ const OPTIONS = { endpoint: { type: 'string', default: '', description: 'the URL every POST form posts to; required when the baseline has a POST form' }, honeypot: { type: 'string', default: '', description: 'a field name every POST form must have; allowed even where the live form had none' }, 'thank-you': { type: 'string', default: '', description: 'the page every POST form leads to: a hidden _next or redirect field set to it (the path, or an absolute URL on the canonical host), and a built page' }, - 'inert-forms': { type: 'string', default: 'inert-forms.json', description: 'JSON list of { path, form, reason, decision }: POST forms of a server feature (login, checkout) that must send nothing' }, }; const NOTES = ` A GET form (method get, or no method: a search box) is compared with live as it is: the same @@ -41,12 +40,10 @@ path must be a page in dist. The field may hold the path itself, or an absolute endpoints require) whose host is the baseline's canonicalOrigin (origin when meta.json has none) and whose path is --thank-you. Without it the thank-you page is not checked, and the summary counts each form as skipped. -A login, account, cart or checkout form is a server feature, and rewiring it to a form service -would send passwords or billing details to that service. List such a form in --inert-forms -(default inert-forms.json, when it exists) with its page, its 0-based index among the page's -forms, a reason and a decision id. The gate then requires the same fields and method, an action -of "#" and onsubmit="return false", so the form cannot send anything; the summary counts it as -skipped, because it does not work. A listed form that can still submit fails. +A form the static site keeps but switches off (a login or checkout it cannot serve) is listed +in inert-forms.json, when that file exists, as { path, form, reason }, form being its 0-based +index among the page's forms. The gate requires live's fields and method and an action that +sends nowhere (none, "#" or javascript:), and counts the form as skipped. `; const THANK_YOU_FIELDS = ['_next', 'redirect']; @@ -54,7 +51,6 @@ const THANK_YOU_FIELDS = ['_next', 'redirect']; export function formsOf(html) { return findAll(parseHtml(html), byTag('form')).map((form) => ({ action: attr(form, 'action') || null, - onsubmit: attr(form, 'onsubmit') ?? null, method: (attr(form, 'method') || 'get').toLowerCase(), fields: formFields(form).map((f) => f.name), hidden: Object.fromEntries( @@ -194,7 +190,9 @@ export function compareForm(path, label, expected, actual, { endpoint, honeypot, return failures; } -// A form listed as inert: the same fields and method as live, and no way to send them. +// A form listed as inert: the same fields and method as live, and an action that sends nowhere. +const sendsNowhere = (action) => !action || action === '#' || /^javascript:/i.test(action); + export function compareInertForm(path, label, expected, actual) { const failures = []; const field = (name) => (label ? `${label} ${name}` : name); @@ -202,8 +200,7 @@ export function compareInertForm(path, label, expected, actual) { if (!same(want, actual.fields)) failures.push({ path, field: field('fields'), expected: want, actual: actual.fields }); const method = (expected.method || 'get').toLowerCase(); if (method !== actual.method) failures.push({ path, field: field('method'), expected: method, actual: actual.method }); - if (actual.action !== '#') failures.push({ path, field: field('inert action'), expected: '#', actual: actual.action }); - if (actual.onsubmit !== 'return false') failures.push({ path, field: field('inert onsubmit'), expected: 'return false', actual: actual.onsubmit }); + if (!sendsNowhere(actual.action)) failures.push({ path, field: field('inert action'), expected: 'none, # or javascript:', actual: actual.action }); return failures; } @@ -264,9 +261,9 @@ export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = '' const candidates = pageFiles(thankYou, format); if (!candidates.some((c) => files.includes(c))) failures.push({ path: thankYou, field: 'thank-you', expected: candidates[0], actual: null }); } - const inertSkip = inertCount ? { skipped: inertCount, skipReason: 'inert server-feature forms, they do not work' } : {}; + const inertSkip = inertCount ? { skipped: inertCount, skipReason: 'inert forms' } : {}; if (thankYou || formCount === 0) return { checked, failures, notes, ...inertSkip }; - return { checked, failures, skipped: formCount + inertCount, skipReason: `thank-you not checked${inertCount ? ', inert server-feature forms' : ''}`, notes: [...notes, 'thank-you: not checked (no --thank-you)'] }; + return { checked, failures, skipped: formCount + inertCount, skipReason: `thank-you not checked${inertCount ? ', inert forms' : ''}`, notes: [...notes, 'thank-you: not checked (no --thank-you)'] }; } function main() { @@ -277,10 +274,10 @@ function main() { const metaFile = join(args.baseline, 'meta.json'); const meta = existsSync(metaFile) ? readJson(metaFile) : {}; const origins = [meta.origin, meta.canonicalOrigin].map((o) => originOf(o ?? '')).filter(Boolean); - const inert = existsSync(args.inertForms) ? readJson(args.inertForms) : []; + const inert = existsSync('inert-forms.json') ? readJson('inert-forms.json') : []; for (const entry of inert) { - if (!entry.path || !Number.isInteger(entry.form) || !entry.reason || !entry.decision) { - process.stderr.write(`${NAME}: every ${args.inertForms} entry needs path, form, reason and decision\n`); + if (!entry.path || !Number.isInteger(entry.form) || !entry.reason) { + process.stderr.write(`${NAME}: every inert-forms.json entry needs path, form and reason\n`); process.exit(2); } } diff --git a/test/gates/forms-inert.test.mjs b/test/gates/forms-inert.test.mjs index eec419b..a462031 100644 --- a/test/gates/forms-inert.test.mjs +++ b/test/gates/forms-inert.test.mjs @@ -5,17 +5,18 @@ import { compareInertForm } from '../../skills/parity-gates/scripts/verify/forms const live = { method: 'post', action: 'https://example.com/wp-login.php', fields: ['log', 'pwd'] }; test('an inert form with the live fields and no way to send passes', () => { - const built = { method: 'post', action: '#', onsubmit: 'return false', fields: ['log', 'pwd'] }; + const built = { method: 'post', action: '#', fields: ['log', 'pwd'] }; assert.deepEqual(compareInertForm('/log-in/', '', live, built), []); + assert.deepEqual(compareInertForm('/log-in/', '', live, { ...built, action: null }), []); }); test('a listed form that can still submit fails', () => { - const built = { method: 'post', action: 'https://formspree.io/f/abc', onsubmit: null, fields: ['log', 'pwd'] }; + const built = { method: 'post', action: 'https://formspree.io/f/abc', fields: ['log', 'pwd'] }; const fields = compareInertForm('/log-in/', '', live, built).map((f) => f.field); - assert.deepEqual(fields, ['inert action', 'inert onsubmit']); + assert.deepEqual(fields, ['inert action']); }); test('a listed form that lost a field fails', () => { - const built = { method: 'post', action: '#', onsubmit: 'return false', fields: ['log'] }; + const built = { method: 'post', action: '#', fields: ['log'] }; assert.deepEqual(compareInertForm('/log-in/', '', live, built).map((f) => f.field), ['fields']); });