From ee606053141089b1cf5d91284ff0cff47b738a1e Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 14:12:07 -0700 Subject: [PATCH 1/7] feat(copy): SEL-452 copy gate with four-status capabilities registry and baseline ratchet Signed-off-by: Christopher Sellers <1@christophersellers.com> --- .github/workflows/copy-gate.yml | 31 +++ frontend/src/lib/capabilities.ts | 12 +- scripts/copy-lint/baseline.json | 333 ++++++++++++++++++++++++++ scripts/copy-lint/check-copy.mjs | 59 ++++- scripts/copy-lint/check-copy.test.mjs | 88 ++++--- scripts/workflow_inventory.txt | 1 + 6 files changed, 490 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/copy-gate.yml create mode 100644 scripts/copy-lint/baseline.json diff --git a/.github/workflows/copy-gate.yml b/.github/workflows/copy-gate.yml new file mode 100644 index 000000000..b58e0791f --- /dev/null +++ b/.github/workflows/copy-gate.yml @@ -0,0 +1,31 @@ +name: Copy gate + +on: + pull_request: + paths: + - 'frontend/**' + - 'scripts/copy-lint/**' + - '.github/workflows/copy-lint.yml' + - 'scripts/copy-lint/baseline.json' + push: + branches: [main] + paths: + - 'frontend/**' + - 'scripts/copy-lint/**' + - '.github/workflows/copy-lint.yml' + - 'scripts/copy-lint/baseline.json' + +permissions: + contents: read + +jobs: + gate: + name: Copy gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: '20' + - run: node --test scripts/copy-lint/check-copy.test.mjs + - run: node scripts/copy-lint/check-copy.mjs . --gate --github diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index efa00ece8..af57ba1c3 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -2,5 +2,15 @@ export const capabilities = { live: ['CSV upload', 'JSON events API'], preview: ['EPCIS intake', 'EPCIS export'], - neverShown: ['EDI', 'SFTP'], + planned: [], + off: ['EDI', 'SFTP'], } as const; + +export type CapabilityStatus = keyof typeof capabilities; + +export function statusOf(name: string): CapabilityStatus | undefined { + for (const status of ['live', 'preview', 'planned', 'off'] as const) { + if ((capabilities[status] as readonly string[]).includes(name)) return status; + } + return undefined; +} diff --git a/scripts/copy-lint/baseline.json b/scripts/copy-lint/baseline.json new file mode 100644 index 000000000..7b004c641 --- /dev/null +++ b/scripts/copy-lint/baseline.json @@ -0,0 +1,333 @@ +{ + "frontend/src/app/auth/verify/page.tsx": { + "empty-render": 2 + }, + "frontend/src/app/blog/fda-food-traceability-requirements/page.tsx": { + "epcis-unlabeled": 1 + }, + "frontend/src/app/blog/food-traceability-software/page.tsx": { + "epcis-unlabeled": 1, + "sample-demo": 2 + }, + "frontend/src/app/blog/fsma-204-compliance-checklist-2026/page.tsx": { + "invented-entity": 2 + }, + "frontend/src/app/blog/fsma-204-compliance-guide/page.tsx": { + "epcis-unlabeled": 2 + }, + "frontend/src/app/blog/fsma-204-cte-kde-guide/page.tsx": { + "epcis-unlabeled": 6 + }, + "frontend/src/app/blog/regengine-vs-spreadsheets-traceability/page.tsx": { + "epcis-unlabeled": 1 + }, + "frontend/src/app/buyer-programs/page.tsx": { + "sample-demo": 2 + }, + "frontend/src/app/case-studies/page.tsx": { + "epcis-unlabeled": 1, + "invented-entity": 2, + "sample-demo": 7 + }, + "frontend/src/app/compare/foodlogiq/page.tsx": { + "sandbox": 1 + }, + "frontend/src/app/compare/repositrak/page.tsx": { + "sandbox": 1 + }, + "frontend/src/app/compare/tracegains/page.tsx": { + "sandbox": 1 + }, + "frontend/src/app/compliance/page.tsx": { + "epcis-unlabeled": 1 + }, + "frontend/src/app/contact/layout.tsx": { + "sample-demo": 2 + }, + "frontend/src/app/controls/page.tsx": { + "epcis-unlabeled": 2 + }, + "frontend/src/app/dashboard/compliance/page.tsx": { + "epcis-unlabeled": 2 + }, + "frontend/src/app/dashboard/export-jobs/page.tsx": { + "epcis-unlabeled": 4 + }, + "frontend/src/app/dashboard/integrations/page.tsx": { + "empty-render": 1, + "sample-demo": 3 + }, + "frontend/src/app/dashboard/layout.tsx": { + "sample-demo": 2, + "sandbox": 1 + }, + "frontend/src/app/dashboard/qa-health/page.tsx": { + "dash-placeholder": 1 + }, + "frontend/src/app/dashboard/recall-drills/page.tsx": { + "epcis-unlabeled": 1 + }, + "frontend/src/app/dashboard/recall-report/page.tsx": { + "dash-placeholder": 4, + "invented-entity": 12, + "sample-demo": 1 + }, + "frontend/src/app/dashboard/recall-response/page.tsx": { + "dash-placeholder": 1 + }, + "frontend/src/app/dashboard/receiving/page.tsx": { + "em-dash": 1 + }, + "frontend/src/app/dashboard/settings/users/page.tsx": { + "empty-render": 1 + }, + "frontend/src/app/demo/layout.tsx": { + "sample-demo": 3 + }, + "frontend/src/app/demo/opengraph-image.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/demo/page.tsx": { + "invented-entity": 4, + "sample-demo": 1 + }, + "frontend/src/app/design-system/page.tsx": { + "invented-entity": 1, + "sample-demo": 1, + "sandbox": 1 + }, + "frontend/src/app/developer/portal/page.tsx": { + "empty-render": 1 + }, + "frontend/src/app/developers/page.tsx": { + "epcis-unlabeled": 1 + }, + "frontend/src/app/docs/api/ApiReferenceClient.tsx": { + "hyphen-dash": 8 + }, + "frontend/src/app/docs/changelog/page.tsx": { + "epcis-unlabeled": 1, + "sample-demo": 1 + }, + "frontend/src/app/docs/connectors/inflow-lab/page.tsx": { + "sandbox": 6 + }, + "frontend/src/app/docs/quickstart/page.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/docs/walkthrough/page.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/exceptions/page.tsx": { + "dash-placeholder": 3, + "empty-render": 2 + }, + "frontend/src/app/fsma-204/[cte]/page.tsx": { + "invented-entity": 1, + "sample-demo": 1 + }, + "frontend/src/app/fsma/page.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/identity/page.tsx": { + "dash-placeholder": 2, + "empty-render": 1 + }, + "frontend/src/app/ingest/page.tsx": { + "epcis-unlabeled": 7, + "sample-demo": 2, + "sandbox": 3 + }, + "frontend/src/app/ingest/results/[id]/AnalysisResultClient.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/ingest/sandbox-handoff.ts": { + "sandbox": 6 + }, + "frontend/src/app/integrations/page.tsx": { + "epcis-unlabeled": 1 + }, + "frontend/src/app/legal/vulnerability-disclosure/page.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/onboarding/bulk-upload/page.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/page.tsx": { + "sample-demo": 6 + }, + "frontend/src/app/pricing/page.tsx": { + "epcis-unlabeled": 1, + "sandbox": 1 + }, + "frontend/src/app/product/page.tsx": { + "sandbox": 2 + }, + "frontend/src/app/proof/page.tsx": { + "epcis-unlabeled": 7, + "sample-demo": 5 + }, + "frontend/src/app/proof/recall-drill/page.tsx": { + "invented-entity": 6, + "sample-demo": 7 + }, + "frontend/src/app/proof/transformation/page.tsx": { + "invented-entity": 19, + "sample-demo": 5 + }, + "frontend/src/app/records/page.tsx": { + "dash-placeholder": 3, + "edi-sftp": 1, + "empty-render": 1, + "epcis-unlabeled": 2 + }, + "frontend/src/app/requests/page.tsx": { + "em-dash": 1 + }, + "frontend/src/app/rules/page.tsx": { + "em-dash": 1, + "empty-render": 2 + }, + "frontend/src/app/sandbox/SandboxClient.tsx": { + "sample-demo": 3, + "sandbox": 8 + }, + "frontend/src/app/sandbox/opengraph-image.tsx": { + "sandbox": 2 + }, + "frontend/src/app/sandbox/page.tsx": { + "sample-demo": 2, + "sandbox": 3 + }, + "frontend/src/app/sandbox/results/[id]/SharedSandboxResult.tsx": { + "sandbox": 1 + }, + "frontend/src/app/sandbox/results/[id]/page.tsx": { + "sandbox": 2 + }, + "frontend/src/app/security/page.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/sysadmin/page.tsx": { + "dash-placeholder": 3 + }, + "frontend/src/app/tools/ask/page.tsx": { + "sample-demo": 6 + }, + "frontend/src/app/tools/data-import/components/DataImportClient.tsx": { + "sample-demo": 2 + }, + "frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx": { + "sample-demo": 4 + }, + "frontend/src/app/tools/export/page.tsx": { + "empty-render": 2, + "sample-demo": 6 + }, + "frontend/src/app/tools/fsma-unified/components/UnifiedDashboardClient.tsx": { + "sample-demo": 2 + }, + "frontend/src/app/tools/inflow-lab/components/DashboardInflowLab.tsx": { + "invented-entity": 5, + "sandbox": 14 + }, + "frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx": { + "invented-entity": 15, + "sample-demo": 4, + "sandbox": 19 + }, + "frontend/src/app/tools/inflow-lab/components/InflowLabTutorial.tsx": { + "sample-demo": 2, + "sandbox": 2 + }, + "frontend/src/app/tools/notice-validator/page.tsx": { + "invented-entity": 1, + "sample-demo": 2 + }, + "frontend/src/app/tools/scan/page.tsx": { + "empty-render": 1 + }, + "frontend/src/app/tools/sop-generator/components/SOPGeneratorClient.tsx": { + "invented-entity": 2 + }, + "frontend/src/app/tools/tlc-validator/components/TLCValidatorClient.tsx": { + "sample-demo": 1 + }, + "frontend/src/app/trust/architecture/page.tsx": { + "epcis-unlabeled": 2 + }, + "frontend/src/app/trust/page.tsx": { + "epcis-unlabeled": 3, + "sample-demo": 1 + }, + "frontend/src/app/trust/retention/page.tsx": { + "epcis-unlabeled": 2 + }, + "frontend/src/app/walkthrough/page.tsx": { + "epcis-unlabeled": 1, + "invented-entity": 1 + }, + "frontend/src/app/why-regengine/page.tsx": { + "epcis-unlabeled": 7 + }, + "frontend/src/components/control-plane/demo-banner.tsx": { + "sample-demo": 1 + }, + "frontend/src/components/dashboard/compliance-status-widget.tsx": { + "empty-render": 1 + }, + "frontend/src/components/dashboard/metrics-overview-widget.tsx": { + "em-dash": 1, + "empty-render": 1 + }, + "frontend/src/components/fsma/FSMAToolShell.tsx": { + "sample-demo": 1 + }, + "frontend/src/components/layout/breadcrumbs.tsx": { + "empty-render": 1 + }, + "frontend/src/components/lead-gate/LeadGate.tsx": { + "empty-render": 1 + }, + "frontend/src/components/marketing/sandbox-grid/SandboxPdfReport.ts": { + "sandbox": 4 + }, + "frontend/src/components/marketing/sandbox-grid/SandboxResultView.tsx": { + "sandbox": 2 + }, + "frontend/src/components/marketing/sandbox-grid/diagnosis.ts": { + "sandbox": 1 + }, + "frontend/src/components/mobile/FieldCaptureClient.tsx": { + "dash-placeholder": 5 + }, + "frontend/src/lib/capabilities.ts": { + "edi-sftp": 2 + }, + "frontend/src/lib/customer-readiness.ts": { + "epcis-unlabeled": 6, + "sample-demo": 3 + }, + "frontend/src/lib/dev-compliance-snapshots.ts": { + "dash-placeholder": 2 + }, + "frontend/src/lib/epcis-upload.ts": { + "epcis-unlabeled": 4 + }, + "frontend/src/lib/mock-tenants.ts": { + "sample-demo": 1 + }, + "frontend/src/lib/roi-calculator-data.ts": { + "hyphen-dash": 4 + }, + "frontend/src/lib/sandbox-cta.ts": { + "sample-demo": 2, + "sandbox": 2 + }, + "frontend/src/lib/trust-subprocessors.ts": { + "sample-demo": 1 + }, + "frontend/src/lib/utils.ts": { + "dash-placeholder": 1 + } +} diff --git a/scripts/copy-lint/check-copy.mjs b/scripts/copy-lint/check-copy.mjs index 43f84f7c1..c04ee6613 100644 --- a/scripts/copy-lint/check-copy.mjs +++ b/scripts/copy-lint/check-copy.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // RegEngine copy rule check. Node only, no dependencies. -// Usage: node check-copy.mjs [repoRoot] [--rules copy-rules.json] [--allow allowlist.json] [--json] [--github] [--strict] +// Usage: node check-copy.mjs [repoRoot] [--rules copy-rules.json] [--allow allowlist.json] [--baseline baseline.json] [--json] [--github] [--strict] [--gate] [--update-baseline] import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -91,11 +91,16 @@ const word = s => new RegExp(`(? m[1]); - if (!groups[name].length) throw new Error(`Empty ${name} capability registry`); + if (name !== 'planned' && !groups[name].length) throw new Error(`Empty ${name} capability registry`); + for (const feature of groups[name]) { + if (seen.has(feature)) throw new Error(`Duplicate capability status for ${feature}`); + seen.add(feature); + } } return groups; } @@ -122,7 +127,7 @@ export function check(seg, srcLine, cfg, capabilities, copyLine = '') { if (t.includes('\u2013')) add('en-dash', t); } if (/[A-Za-z0-9.,)"'%] - [A-Za-z0-9("'$]/.test(t) && !/calc\(/.test(t) && !(seg.kind === 'str' && looksLikeCode(t))) add('hyphen-dash', t); - if (capabilities.neverShown.some(name => new RegExp(`(^|[^A-Za-z0-9_])${escapeRegex(name)}(?![A-Za-z0-9_])`).test(t)) && !(seg.kind === 'str' && /^[A-Z_]+$/.test(t.trim()) && t.trim().length > 4)) add('edi-sftp', t); + if (capabilities.off.some(name => new RegExp(`(^|[^A-Za-z0-9_])${escapeRegex(name)}(?![A-Za-z0-9_])`).test(t)) && !(seg.kind === 'str' && /^[A-Z_]+$/.test(t.trim()) && t.trim().length > 4)) add('edi-sftp', t); const previewNames = [...new Set(capabilities.preview.map(name => name.split(/\s+/)[0]))]; if (previewNames.some(name => new RegExp(`\\b${escapeRegex(name)}\\b`).test(t)) && !/Preview/i.test(t) && !/Preview/i.test(srcLine)) add('epcis-unlabeled', t); if ((seg.kind !== 'str' && /\b(NaN|undefined)\b/.test(t)) || (seg.kind === 'str' && /\b(NaN|undefined)\b/.test(t) && /\s/.test(t.trim()) && !/typeof|===|!==/.test(t))) add('nan-undefined', t); @@ -130,7 +135,7 @@ export function check(seg, srcLine, cfg, capabilities, copyLine = '') { cfg.denylist.forEach(d => { if (low.includes(d.toLowerCase())) add('invented-entity', d); }); const copy = copyText(seg, srcLine); if (copy) { - const nonLive = [...capabilities.preview, ...capabilities.neverShown]; + const nonLive = [...capabilities.preview, ...capabilities.planned, ...capabilities.off]; const capability = nonLive.find(name => word(name).test(copy) || (name.startsWith('EPCIS ') && word('EPCIS').test(copy))); if (capability && (/\b(?:Live|Implemented|GA|Available)\b/.test(copyLine || copy) || (/\bstatus\s*:\s*['"]available['"]/.test(srcLine) && /\bavailable\b/.test(copyLine || copy)))) add('live-claim', capability); if (copy.trim() === 'Pilot' && /\b(?:badge|status)\b/i.test(srcLine)) add('pilot-status', copy); @@ -186,12 +191,39 @@ export function summarize(findings, filesScanned, cfg) { return { total: findings.length, filesWithFindings: new Set(findings.map(x => x.file)).size, filesScanned, byRule }; } +export function countsByFileRule(findings) { + const counts = {}; + for (const { file, rule } of findings) { + counts[file] ??= {}; + counts[file][rule] = (counts[file][rule] || 0) + 1; + } + return Object.fromEntries(Object.keys(counts).sort().map(file => [ + file, Object.fromEntries(Object.keys(counts[file]).sort().map(rule => [rule, counts[file][rule]])), + ])); +} + +export function compareBaseline(current, baseline) { + const regressions = []; + const reductions = []; + for (const file of [...new Set([...Object.keys(current), ...Object.keys(baseline)])].sort()) { + const actualRules = current[file] || {}; + const baselineRules = baseline[file] || {}; + for (const rule of [...new Set([...Object.keys(actualRules), ...Object.keys(baselineRules)])].sort()) { + const actual = actualRules[rule] || 0; + const previous = baselineRules[rule] || 0; + if (actual > previous) regressions.push({ file, rule, baseline: previous, current: actual }); + if (actual < previous) reductions.push({ file, rule, baseline: previous, current: actual }); + } + } + return { regressions, reductions }; +} + const annotationEscape = s => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); const propertyEscape = s => annotationEscape(s).replace(/:/g, '%3A').replace(/,/g, '%2C'); function main(args) { const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; }; - const pos = args.filter((a, i) => !a.startsWith('--') && !(i > 0 && ['--rules', '--allow'].includes(args[i - 1]))); + const pos = args.filter((a, i) => !a.startsWith('--') && !(i > 0 && ['--rules', '--allow', '--baseline'].includes(args[i - 1]))); const repo = path.resolve(pos[0] || '.'); const cfg = JSON.parse(fs.readFileSync(opt('--rules', path.join(here, 'copy-rules.json')), 'utf8')); const allowPath = opt('--allow', path.join(here, 'allowlist.json')); @@ -202,6 +234,12 @@ function main(args) { const files = cfg.roots.flatMap(r => walk(path.join(repo, r), [], cfg)); const findings = files.flatMap(f => scanSource(fs.readFileSync(f, 'utf8'), path.relative(repo, f), cfg, capabilities, allow)); const summary = summarize(findings, files.length, cfg); + const baselinePath = path.resolve(repo, opt('--baseline', 'scripts/copy-lint/baseline.json')); + const current = countsByFileRule(findings); + if (args.includes('--update-baseline')) { + fs.writeFileSync(baselinePath, `${JSON.stringify(current, null, 2)}\n`); + console.log(`Updated copy baseline: ${baselinePath}`); + } if (args.includes('--json')) console.log(JSON.stringify({ summary, findings }, null, 1)); else { for (const x of findings) { @@ -213,6 +251,15 @@ function main(args) { console.log(`\n${summary.total} copy rule violations in ${summary.filesWithFindings} files (${summary.filesScanned} scanned)`); for (const [rule, count] of Object.entries(summary.byRule)) console.log(` ${rule}: ${count} ${cfg.rules[rule].message}`); } + if (args.includes('--gate')) { + const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const { regressions, reductions } = compareBaseline(current, baseline); + for (const { file, rule, baseline: previous, current: actual } of regressions) { + console.log(`Copy gate regression: ${file} ${rule}: ${previous} -> ${actual}`); + } + if (reductions.length) console.log('Copy findings decreased. Run --update-baseline to ratchet the baseline down.'); + if (regressions.length) process.exitCode = 1; + } if (summary.total && (args.includes('--strict') || process.env.COPY_LINT_STRICT === '1')) process.exitCode = 1; } diff --git a/scripts/copy-lint/check-copy.test.mjs b/scripts/copy-lint/check-copy.test.mjs index 1b3751f63..a5f8fbb50 100644 --- a/scripts/copy-lint/check-copy.test.mjs +++ b/scripts/copy-lint/check-copy.test.mjs @@ -3,66 +3,100 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { check, parseCapabilities, scanSource, summarize } from './check-copy.mjs'; +import { check, compareBaseline, countsByFileRule, parseCapabilities, scanSource, summarize } from './check-copy.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const cfg = JSON.parse(fs.readFileSync(path.join(here, 'copy-rules.json'), 'utf8')); const registryText = fs.readFileSync(path.resolve(here, '../../frontend/src/lib/capabilities.ts'), 'utf8'); const capabilities = parseCapabilities(registryText); const rules = (src, rel = 'frontend/src/app/page.tsx') => scanSource(src, rel, cfg, capabilities).map(x => x.rule); +const liveName = capabilities.live[0]; +const previewTerm = capabilities.preview[0].split(' ')[0]; +const [sampleTerm, demoTerm] = 'sample-demo'.split('-'); +const sandboxTerm = 'sandbox'; +const registrySource = groups => Object.entries(groups).map(([status, names]) => + `${status}: [${names.map(name => `'${name}'`).join(', ')}]`).join(',\n'); test('rule sources and capability registry are complete', () => { - assert.deepEqual(Object.keys(capabilities), ['live', 'preview', 'neverShown']); - assert.equal(capabilities.preview.includes('EPCIS export'), true); - assert.equal(capabilities.live.includes('CSV upload'), true); - assert.equal(capabilities.neverShown.includes('SFTP'), true); + assert.deepEqual(Object.keys(capabilities), ['live', 'preview', 'planned', 'off']); + assert.equal(capabilities.planned.length, 0); + assert.equal(capabilities.live.length > 0 && capabilities.preview.length > 0 && capabilities.off.length > 0, true); + assert.deepEqual(parseCapabilities(registrySource(capabilities)), capabilities); assert.equal(Object.values(cfg.rules).every(rule => ['visual-designer', 'product-designer', 'both'].includes(rule.source)), true); }); +test('a feature cannot have two capability statuses', () => { + const duplicate = { ...capabilities, planned: [capabilities.live[0]] }; + assert.throws(() => parseCapabilities(registrySource(duplicate)), /Duplicate capability status/); +}); + test('VD dash and preview rules remain, with stricter empty values', () => { - assert.deepEqual(rules('

word — word

'), ['em-dash']); - assert.deepEqual(rules('

word – word

'), ['en-dash']); - assert.deepEqual(rules('

word - word

'), ['hyphen-dash']); + assert.deepEqual(rules(`

${liveName} — ${liveName}

`), ['em-dash']); + assert.deepEqual(rules(`

${liveName} – ${liveName}

`), ['en-dash']); + assert.deepEqual(rules(`

${liveName} - ${liveName}

`), ['hyphen-dash']); assert.deepEqual(rules('

—

'), ['dash-placeholder']); assert.deepEqual(rules("

{value ?? '--'}

"), ['dash-placeholder']); assert.deepEqual(rules("

{value ?? '-'}

"), ['dash-placeholder']); - assert.deepEqual(rules('

EPCIS

'), ['epcis-unlabeled']); - assert.deepEqual(rules('

EPCIS Preview

'), []); + assert.deepEqual(rules(`

${previewTerm}

`), ['epcis-unlabeled']); + assert.deepEqual(rules(`

${previewTerm} Preview

`), []); }); test('capability claims and forbidden names use the registry', () => { - assert.deepEqual(rules('

EPCIS export is Live

'), ['epcis-unlabeled', 'live-claim']); - assert.deepEqual(rules('

CSV upload is Live

'), []); - assert.deepEqual(rules("const items = [{ name: 'EPCIS export', status: 'available' }];"), ['epcis-unlabeled', 'live-claim']); - assert.deepEqual(rules('

EDI 856

'), ['edi-sftp']); - assert.deepEqual(rules('

SFTP Import

'), ['edi-sftp']); - assert.deepEqual(rules('Pilot'), ['pilot-status']); + assert.deepEqual(rules(`

${capabilities.preview[1]} is Live

`), ['epcis-unlabeled', 'live-claim']); + assert.deepEqual(rules(`

${capabilities.live[0]} is Live

`), []); + assert.deepEqual(rules(`const items = [{ name: '${capabilities.preview[1]}', status: 'available' }];`), ['epcis-unlabeled', 'live-claim']); + assert.deepEqual(rules(`

${capabilities.off[0]}

`), ['edi-sftp']); + assert.deepEqual(rules(`

${capabilities.off[1]}

`), ['edi-sftp']); + assert.deepEqual(rules(`${cfg.rules['pilot-status'].message.split(' ')[0]}`), ['pilot-status']); +}); + +test('live claim covers off and planned registry features', () => { + const planned = { ...capabilities, live: capabilities.live.slice(1), planned: [capabilities.live[0]] }; + const parsed = parseCapabilities(registrySource(planned)); + for (const feature of [parsed.off[0], parsed.planned[0]]) { + const hits = check({ text: `${feature} Live`, kind: 'jsx' }, '', cfg, parsed); + assert.equal(hits.some(hit => hit.rule === 'live-claim'), true); + } }); test('copy word rules skip comments, imports, identifiers, classes and paths', () => { - assert.deepEqual(rules('

sample demo simulated synthetic sandbox

'), ['sample-demo', 'sandbox']); - assert.deepEqual(rules('

test data

'), ['invented-entity']); - assert.deepEqual(rules("import demo from '/demo'\nconst route = '/demo'\nconst id = 'sample_id'\nconst SANDBOX_SLUG = 'sandbox'\nconst item = { lastRun: 'sample' }\n// sandbox\n
Ready
"), []); - assert.deepEqual(rules('Ready'), []); - assert.deepEqual(rules('

/demo

'), []); - assert.deepEqual(rules('

See (/demo) for details

'), []); - assert.deepEqual(rules("const route = 'demo-route'"), []); - assert.deepEqual(rules('

demo

', 'frontend/src/app/page.test.tsx'), []); + assert.deepEqual(rules(`

${sampleTerm} ${sandboxTerm}

`), ['sample-demo', 'sandbox']); + assert.deepEqual(rules(`

${cfg.denylist[0]}

`), ['invented-entity']); + assert.deepEqual(rules(`import ${demoTerm} from '/${demoTerm}'\nconst route = '/${demoTerm}'\nconst id = '${sampleTerm}_id'\nconst SANDBOX_SLUG = '${sandboxTerm}'\nconst item = { lastRun: '${sampleTerm}' }\n// ${sandboxTerm}\n
${liveName}
`), []); + assert.deepEqual(rules(`${liveName}`), []); + assert.deepEqual(rules(`

/${demoTerm}

`), []); + assert.deepEqual(rules(`

${liveName} (/${demoTerm}) ${liveName}

`), []); + assert.deepEqual(rules(`const route = '${demoTerm}-route'`), []); + assert.deepEqual(rules(`

${demoTerm}

`, 'frontend/src/app/page.test.tsx'), []); }); test('blank rendered fallbacks are caught only in JSX expressions', () => { assert.deepEqual(rules("

{value ?? ''}

"), ['empty-render']); assert.deepEqual(rules('

{value || ""}

'), ['empty-render']); assert.deepEqual(rules("const fallback = value ?? '';"), []); - assert.deepEqual(rules(""), []); + assert.deepEqual(rules(``), []); assert.deepEqual(rules("//

{value ?? ''}

"), []); }); test('allowlist and summary counts', () => { - const findings = scanSource('

demo

', 'frontend/src/app/page.tsx', cfg, capabilities, [ - { file: 'frontend/src/app/page.tsx', rule: 'sample-demo', match: 'demo' }, + const findings = scanSource(`

${demoTerm}

`, 'frontend/src/app/page.tsx', cfg, capabilities, [ + { file: 'frontend/src/app/page.tsx', rule: 'sample-demo', match: demoTerm }, ]); assert.deepEqual(findings, []); assert.equal(summarize(findings, 1, cfg).byRule['sample-demo'], 0); assert.deepEqual(check({ text: 'NaN value', kind: 'str' }, '', cfg, capabilities).map(x => x.rule), ['nan-undefined']); }); + +test('copy gate detects regressions, accepts equal counts and reductions', () => { + const file = path.relative(path.resolve(here, '../..'), path.resolve(here, '../../frontend/src/lib/capabilities.ts')); + const rule = Object.keys(cfg.rules)[0]; + const findings = [{ file, rule }]; + const baseline = countsByFileRule(findings); + assert.deepEqual(compareBaseline(baseline, baseline), { regressions: [], reductions: [] }); + assert.deepEqual(compareBaseline(countsByFileRule([...findings, ...findings]), baseline).regressions, + [{ file, rule, baseline: 1, current: 2 }]); + assert.deepEqual(compareBaseline({}, baseline).reductions, + [{ file, rule, baseline: 1, current: 0 }]); + assert.deepEqual(compareBaseline(baseline, {}).regressions, + [{ file, rule, baseline: 0, current: 1 }]); +}); diff --git a/scripts/workflow_inventory.txt b/scripts/workflow_inventory.txt index d15859563..fe22ba03a 100644 --- a/scripts/workflow_inventory.txt +++ b/scripts/workflow_inventory.txt @@ -33,6 +33,7 @@ agent-sweep.yml api-surface-issue.yml backend-ci.yml bundle-analysis.yml +copy-gate.yml dependabot-lock-sync.yml deploy.yml factory-local-ci.yml From d2fb80fd46290637bc96fbd0ade38c0086709a01 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 14:13:15 -0700 Subject: [PATCH 2/7] ci(copy): copy-gate path filters include copy-gate.yml Signed-off-by: Christopher Sellers <1@christophersellers.com> --- .github/workflows/copy-gate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/copy-gate.yml b/.github/workflows/copy-gate.yml index b58e0791f..3f5baf91e 100644 --- a/.github/workflows/copy-gate.yml +++ b/.github/workflows/copy-gate.yml @@ -5,14 +5,14 @@ on: paths: - 'frontend/**' - 'scripts/copy-lint/**' - - '.github/workflows/copy-lint.yml' + - '.github/workflows/copy-gate.yml' - 'scripts/copy-lint/baseline.json' push: branches: [main] paths: - 'frontend/**' - 'scripts/copy-lint/**' - - '.github/workflows/copy-lint.yml' + - '.github/workflows/copy-gate.yml' - 'scripts/copy-lint/baseline.json' permissions: From b8e49d80e2317e2390ad41d75e32625a18faa340 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 14:18:01 -0700 Subject: [PATCH 3/7] fix(copy): gate against main's baseline so a PR cannot loosen the ratchet Signed-off-by: Christopher Sellers <1@christophersellers.com> --- .github/workflows/copy-gate.yml | 13 +++++++- frontend/package.json | 4 ++- scripts/copy-lint/check-copy.mjs | 40 ++++++++++++++++++----- scripts/copy-lint/check-copy.test.mjs | 47 ++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/.github/workflows/copy-gate.yml b/.github/workflows/copy-gate.yml index 3f5baf91e..d7aef6c7c 100644 --- a/.github/workflows/copy-gate.yml +++ b/.github/workflows/copy-gate.yml @@ -24,8 +24,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + fetch-depth: 0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: '20' - run: node --test scripts/copy-lint/check-copy.test.mjs - - run: node scripts/copy-lint/check-copy.mjs . --gate --github + - name: Read base copy baseline + env: + BASE_REF: ${{ github.base_ref }} + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + git show "origin/${BASE_REF}:scripts/copy-lint/baseline.json" > "$RUNNER_TEMP/base-baseline.json" 2>/dev/null || : + else + git show "HEAD^:scripts/copy-lint/baseline.json" > "$RUNNER_TEMP/base-baseline.json" 2>/dev/null || : + fi + - run: node scripts/copy-lint/check-copy.mjs . --gate --github --base-baseline "$RUNNER_TEMP/base-baseline.json" diff --git a/frontend/package.json b/frontend/package.json index 13d694fae..47becc39f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,9 +22,11 @@ "knip": { "entry": [ "scripts/design-lint.mjs", - "tests/e2e/qa-2026-10-02/fe-regressions.config.ts" + "tests/e2e/qa-2026-10-02/fe-regressions.config.ts", + "src/lib/capabilities.ts" ] }, + "//knip": "src/lib/capabilities.ts is read by scripts/copy-lint/check-copy.mjs (the SEL-452 copy gate registry), not imported by frontend code.", "//": "Pinning policy (#569): next, eslint-config-next, @sentry/nextjs, and @supabase/supabase-js are pinned to exact versions (no ^ or ~ prefix) because breaking changes in these packages have caused production incidents. Bump them deliberately after testing. All other deps use ^ for minor/patch auto-updates.", "dependencies": { "@radix-ui/react-checkbox": "^1.4.1", diff --git a/scripts/copy-lint/check-copy.mjs b/scripts/copy-lint/check-copy.mjs index c04ee6613..4e059203a 100644 --- a/scripts/copy-lint/check-copy.mjs +++ b/scripts/copy-lint/check-copy.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // RegEngine copy rule check. Node only, no dependencies. -// Usage: node check-copy.mjs [repoRoot] [--rules copy-rules.json] [--allow allowlist.json] [--baseline baseline.json] [--json] [--github] [--strict] [--gate] [--update-baseline] +// Usage: node check-copy.mjs [repoRoot] [--rules copy-rules.json] [--allow allowlist.json] [--baseline baseline.json] [--base-baseline main-baseline.json] [--json] [--github] [--strict] [--gate] [--update-baseline] import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -218,12 +218,32 @@ export function compareBaseline(current, baseline) { return { regressions, reductions }; } +function flatCounts(counts) { + return Object.fromEntries(Object.entries(counts).flatMap(([file, rules]) => + Object.entries(rules).map(([rule, count]) => [`${file}|${rule}`, count]))); +} + +export function gateCompare(base, pr, actual) { + const errors = []; + for (const key of [...new Set([...Object.keys(base), ...Object.keys(pr), ...Object.keys(actual)])].sort()) { + const [file, rule] = key.split('|'); + const mainCount = base[key] ?? 0; + const prCount = pr[key] ?? 0; + const actualCount = actual[key] ?? 0; + const counts = `main=${mainCount}, PR baseline=${prCount}, actual=${actualCount}`; + if (actualCount > mainCount) errors.push(`file=${file}, rule=${rule}: count went up vs main (${counts})`); + if (prCount > mainCount) errors.push(`file=${file}, rule=${rule}: PR baseline loosened vs main (${counts})`); + if (prCount !== actualCount) errors.push(`file=${file}, rule=${rule}: baseline.json is stale, run --update-baseline (${counts})`); + } + return { ok: errors.length === 0, errors }; +} + const annotationEscape = s => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); const propertyEscape = s => annotationEscape(s).replace(/:/g, '%3A').replace(/,/g, '%2C'); function main(args) { const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; }; - const pos = args.filter((a, i) => !a.startsWith('--') && !(i > 0 && ['--rules', '--allow', '--baseline'].includes(args[i - 1]))); + const pos = args.filter((a, i) => !a.startsWith('--') && !(i > 0 && ['--rules', '--allow', '--baseline', '--base-baseline'].includes(args[i - 1]))); const repo = path.resolve(pos[0] || '.'); const cfg = JSON.parse(fs.readFileSync(opt('--rules', path.join(here, 'copy-rules.json')), 'utf8')); const allowPath = opt('--allow', path.join(here, 'allowlist.json')); @@ -252,13 +272,15 @@ function main(args) { for (const [rule, count] of Object.entries(summary.byRule)) console.log(` ${rule}: ${count} ${cfg.rules[rule].message}`); } if (args.includes('--gate')) { - const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); - const { regressions, reductions } = compareBaseline(current, baseline); - for (const { file, rule, baseline: previous, current: actual } of regressions) { - console.log(`Copy gate regression: ${file} ${rule}: ${previous} -> ${actual}`); - } - if (reductions.length) console.log('Copy findings decreased. Run --update-baseline to ratchet the baseline down.'); - if (regressions.length) process.exitCode = 1; + const pr = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const basePath = opt('--base-baseline'); + const resolvedBasePath = basePath && path.resolve(repo, basePath); + const baseText = resolvedBasePath && fs.existsSync(resolvedBasePath) ? fs.readFileSync(resolvedBasePath, 'utf8') : ''; + if (!baseText.trim()) console.log('Main has no baseline yet (bootstrap); using the PR baseline as main.'); + const base = baseText.trim() ? JSON.parse(baseText) : pr; + const result = gateCompare(flatCounts(base), flatCounts(pr), flatCounts(current)); + for (const error of result.errors) console.log(`Copy gate: ${error}`); + if (!result.ok) process.exitCode = 1; } if (summary.total && (args.includes('--strict') || process.env.COPY_LINT_STRICT === '1')) process.exitCode = 1; } diff --git a/scripts/copy-lint/check-copy.test.mjs b/scripts/copy-lint/check-copy.test.mjs index a5f8fbb50..c54ebccdd 100644 --- a/scripts/copy-lint/check-copy.test.mjs +++ b/scripts/copy-lint/check-copy.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { check, compareBaseline, countsByFileRule, parseCapabilities, scanSource, summarize } from './check-copy.mjs'; +import { check, compareBaseline, countsByFileRule, gateCompare, parseCapabilities, scanSource, summarize } from './check-copy.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const cfg = JSON.parse(fs.readFileSync(path.join(here, 'copy-rules.json'), 'utf8')); @@ -100,3 +100,48 @@ test('copy gate detects regressions, accepts equal counts and reductions', () => assert.deepEqual(compareBaseline(baseline, {}).regressions, [{ file, rule, baseline: 0, current: 1 }]); }); + +const gateFile = path.relative(path.resolve(here, '../..'), path.resolve(here, '../../frontend/src/lib/capabilities.ts')); +const gateRule = Object.keys(cfg.rules)[0]; +const gateFinding = { file: gateFile, rule: gateRule }; +const gateCounts = findings => Object.fromEntries(Object.entries(countsByFileRule(findings)).flatMap(([file, rules]) => + Object.entries(rules).map(([rule, count]) => [`${file}|${rule}`, count]))); +const noFindings = gateCounts([]); +const oneFinding = gateCounts([gateFinding]); +const twoFindings = gateCounts([gateFinding, gateFinding]); + +test('gateCompare fails when actual count rises', () => { + const result = gateCompare(oneFinding, oneFinding, twoFindings); + const key = `${gateFile}|${gateRule}`; + const counts = `main=${oneFinding[key]}, PR baseline=${oneFinding[key]}, actual=${twoFindings[key]}`; + assert.equal(result.ok, false); + assert.equal(result.errors.some(error => error.includes('count went up vs main')), true); + assert.equal(result.errors.every(error => error.includes(gateFile) && error.includes(gateRule) + && error.includes(counts)), true); +}); + +test('gateCompare fails for a new key absent from main', () => { + const result = gateCompare(noFindings, oneFinding, oneFinding); + assert.equal(result.ok, false); + assert.equal(result.errors.some(error => error.includes('count went up vs main')), true); +}); + +test('gateCompare rejects a PR baseline loosened above main', () => { + const result = gateCompare(noFindings, oneFinding, noFindings); + assert.equal(result.ok, false); + assert.equal(result.errors.some(error => error.includes('PR baseline loosened vs main')), true); +}); + +test('gateCompare requires a baseline update after a drop', () => { + const result = gateCompare(oneFinding, oneFinding, noFindings); + assert.equal(result.ok, false); + assert.equal(result.errors.some(error => error.includes('baseline.json is stale, run --update-baseline')), true); +}); + +test('gateCompare accepts equal counts', () => { + assert.deepEqual(gateCompare(oneFinding, oneFinding, oneFinding), { ok: true, errors: [] }); +}); + +test('gateCompare accepts a drop with a matching baseline update', () => { + assert.deepEqual(gateCompare(oneFinding, noFindings, noFindings), { ok: true, errors: [] }); +}); From ee515161a0f11600134967faf1ea283bf4d2bd65 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 18:17:33 -0700 Subject: [PATCH 4/7] fix(copy-gate): reject unparseable registry, fail on baseline lookup errors, diff pushes against event.before --- .github/workflows/copy-gate.yml | 19 +++++++++++++++++-- scripts/copy-lint/check-copy.mjs | 3 +++ scripts/copy-lint/check-copy.test.mjs | 6 +++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/copy-gate.yml b/.github/workflows/copy-gate.yml index bc9e87e35..ceaeaec75 100644 --- a/.github/workflows/copy-gate.yml +++ b/.github/workflows/copy-gate.yml @@ -34,10 +34,25 @@ jobs: - name: Read base copy baseline env: BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} run: | if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then - git show "origin/${BASE_REF}:scripts/copy-lint/baseline.json" > "$RUNNER_TEMP/base-baseline.json" 2>/dev/null || : + base_ref="origin/${BASE_REF}" else - git show "HEAD^:scripts/copy-lint/baseline.json" > "$RUNNER_TEMP/base-baseline.json" 2>/dev/null || : + if [[ "$BEFORE_SHA" =~ ^0{40}$ ]]; then + exit 0 + fi + base_ref="$BEFORE_SHA" + fi + base_commit=$(git rev-parse --verify "${base_ref}^{commit}") || exit 1 + baseline_path="scripts/copy-lint/baseline.json" + if git cat-file -e "${base_commit}:${baseline_path}" 2>/dev/null; then + git show "${base_commit}:${baseline_path}" > "$RUNNER_TEMP/base-baseline.json" || exit 1 + else + baseline_entry=$(git ls-tree -r --name-only "$base_commit" -- "$baseline_path") || exit 1 + if [ -n "$baseline_entry" ]; then + echo "Unable to read base copy baseline" >&2 + exit 1 + fi fi - run: node scripts/copy-lint/check-copy.mjs . --gate --github --base-baseline "$RUNNER_TEMP/base-baseline.json" diff --git a/scripts/copy-lint/check-copy.mjs b/scripts/copy-lint/check-copy.mjs index 4e059203a..632166a4b 100644 --- a/scripts/copy-lint/check-copy.mjs +++ b/scripts/copy-lint/check-copy.mjs @@ -95,6 +95,9 @@ export function parseCapabilities(src) { for (const name of ['live', 'preview', 'planned', 'off']) { const match = src.match(new RegExp(`\\b${name}\\s*:\\s*\\[([^\\]]*)\\]`)); if (!match) throw new Error(`Missing ${name} capability registry`); + if (match[1].replace(/'[^']+'/g, '').replace(/,/g, '').trim()) { + throw new Error(`Unparseable ${name} capability registry`); + } groups[name] = [...match[1].matchAll(/'([^']+)'/g)].map(m => m[1]); if (name !== 'planned' && !groups[name].length) throw new Error(`Empty ${name} capability registry`); for (const feature of groups[name]) { diff --git a/scripts/copy-lint/check-copy.test.mjs b/scripts/copy-lint/check-copy.test.mjs index c54ebccdd..c056a4021 100644 --- a/scripts/copy-lint/check-copy.test.mjs +++ b/scripts/copy-lint/check-copy.test.mjs @@ -19,7 +19,6 @@ const registrySource = groups => Object.entries(groups).map(([status, names]) => test('rule sources and capability registry are complete', () => { assert.deepEqual(Object.keys(capabilities), ['live', 'preview', 'planned', 'off']); - assert.equal(capabilities.planned.length, 0); assert.equal(capabilities.live.length > 0 && capabilities.preview.length > 0 && capabilities.off.length > 0, true); assert.deepEqual(parseCapabilities(registrySource(capabilities)), capabilities); assert.equal(Object.values(cfg.rules).every(rule => ['visual-designer', 'product-designer', 'both'].includes(rule.source)), true); @@ -30,6 +29,11 @@ test('a feature cannot have two capability statuses', () => { assert.throws(() => parseCapabilities(registrySource(duplicate)), /Duplicate capability status/); }); +test('a double-quoted planned capability is rejected', () => { + const invalid = registrySource({ ...capabilities, planned: ['X'] }).replace("planned: ['X']", 'planned: ["X"]'); + assert.throws(() => parseCapabilities(invalid), /Unparseable planned capability registry/); +}); + test('VD dash and preview rules remain, with stricter empty values', () => { assert.deepEqual(rules(`

${liveName} — ${liveName}

`), ['em-dash']); assert.deepEqual(rules(`

${liveName} – ${liveName}

`), ['en-dash']); From cf0000f952948bbaffdf79efe074c896f27468a0 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Sat, 24 Oct 2026 00:22:36 -0700 Subject: [PATCH 5/7] chore(copy-lint): refresh baseline after main removed sandbox copy (SEL-452) Signed-off-by: Christopher Sellers <1@christophersellers.com> --- scripts/copy-lint/baseline.json | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/scripts/copy-lint/baseline.json b/scripts/copy-lint/baseline.json index 7b004c641..0eedc17ae 100644 --- a/scripts/copy-lint/baseline.json +++ b/scripts/copy-lint/baseline.json @@ -188,23 +188,6 @@ "em-dash": 1, "empty-render": 2 }, - "frontend/src/app/sandbox/SandboxClient.tsx": { - "sample-demo": 3, - "sandbox": 8 - }, - "frontend/src/app/sandbox/opengraph-image.tsx": { - "sandbox": 2 - }, - "frontend/src/app/sandbox/page.tsx": { - "sample-demo": 2, - "sandbox": 3 - }, - "frontend/src/app/sandbox/results/[id]/SharedSandboxResult.tsx": { - "sandbox": 1 - }, - "frontend/src/app/sandbox/results/[id]/page.tsx": { - "sandbox": 2 - }, "frontend/src/app/security/page.tsx": { "sample-demo": 1 }, @@ -218,7 +201,7 @@ "sample-demo": 2 }, "frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx": { - "sample-demo": 4 + "sample-demo": 3 }, "frontend/src/app/tools/export/page.tsx": { "empty-render": 2, @@ -289,12 +272,6 @@ "frontend/src/components/lead-gate/LeadGate.tsx": { "empty-render": 1 }, - "frontend/src/components/marketing/sandbox-grid/SandboxPdfReport.ts": { - "sandbox": 4 - }, - "frontend/src/components/marketing/sandbox-grid/SandboxResultView.tsx": { - "sandbox": 2 - }, "frontend/src/components/marketing/sandbox-grid/diagnosis.ts": { "sandbox": 1 }, @@ -320,10 +297,6 @@ "frontend/src/lib/roi-calculator-data.ts": { "hyphen-dash": 4 }, - "frontend/src/lib/sandbox-cta.ts": { - "sample-demo": 2, - "sandbox": 2 - }, "frontend/src/lib/trust-subprocessors.ts": { "sample-demo": 1 }, From 1ebef715f71086c39a5be464594158476f667976 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:16:07 -0700 Subject: [PATCH 6/7] chore(copy-gate): refresh copy baseline after merging main (counts only dropped) --- scripts/copy-lint/baseline.json | 54 +++++---------------------------- 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/scripts/copy-lint/baseline.json b/scripts/copy-lint/baseline.json index 0eedc17ae..5aee75702 100644 --- a/scripts/copy-lint/baseline.json +++ b/scripts/copy-lint/baseline.json @@ -21,23 +21,6 @@ "frontend/src/app/blog/regengine-vs-spreadsheets-traceability/page.tsx": { "epcis-unlabeled": 1 }, - "frontend/src/app/buyer-programs/page.tsx": { - "sample-demo": 2 - }, - "frontend/src/app/case-studies/page.tsx": { - "epcis-unlabeled": 1, - "invented-entity": 2, - "sample-demo": 7 - }, - "frontend/src/app/compare/foodlogiq/page.tsx": { - "sandbox": 1 - }, - "frontend/src/app/compare/repositrak/page.tsx": { - "sandbox": 1 - }, - "frontend/src/app/compare/tracegains/page.tsx": { - "sandbox": 1 - }, "frontend/src/app/compliance/page.tsx": { "epcis-unlabeled": 1 }, @@ -81,16 +64,6 @@ "frontend/src/app/dashboard/settings/users/page.tsx": { "empty-render": 1 }, - "frontend/src/app/demo/layout.tsx": { - "sample-demo": 3 - }, - "frontend/src/app/demo/opengraph-image.tsx": { - "sample-demo": 1 - }, - "frontend/src/app/demo/page.tsx": { - "invented-entity": 4, - "sample-demo": 1 - }, "frontend/src/app/design-system/page.tsx": { "invented-entity": 1, "sample-demo": 1, @@ -123,8 +96,7 @@ "empty-render": 2 }, "frontend/src/app/fsma-204/[cte]/page.tsx": { - "invented-entity": 1, - "sample-demo": 1 + "invented-entity": 1 }, "frontend/src/app/fsma/page.tsx": { "sample-demo": 1 @@ -134,8 +106,8 @@ "empty-render": 1 }, "frontend/src/app/ingest/page.tsx": { - "epcis-unlabeled": 7, - "sample-demo": 2, + "epcis-unlabeled": 6, + "sample-demo": 1, "sandbox": 3 }, "frontend/src/app/ingest/results/[id]/AnalysisResultClient.tsx": { @@ -153,12 +125,8 @@ "frontend/src/app/onboarding/bulk-upload/page.tsx": { "sample-demo": 1 }, - "frontend/src/app/page.tsx": { - "sample-demo": 6 - }, "frontend/src/app/pricing/page.tsx": { - "epcis-unlabeled": 1, - "sandbox": 1 + "epcis-unlabeled": 1 }, "frontend/src/app/product/page.tsx": { "sandbox": 2 @@ -171,13 +139,8 @@ "invented-entity": 6, "sample-demo": 7 }, - "frontend/src/app/proof/transformation/page.tsx": { - "invented-entity": 19, - "sample-demo": 5 - }, "frontend/src/app/records/page.tsx": { "dash-placeholder": 3, - "edi-sftp": 1, "empty-render": 1, "epcis-unlabeled": 2 }, @@ -240,15 +203,13 @@ "epcis-unlabeled": 2 }, "frontend/src/app/trust/page.tsx": { - "epcis-unlabeled": 3, - "sample-demo": 1 + "epcis-unlabeled": 3 }, "frontend/src/app/trust/retention/page.tsx": { "epcis-unlabeled": 2 }, "frontend/src/app/walkthrough/page.tsx": { - "epcis-unlabeled": 1, - "invented-entity": 1 + "epcis-unlabeled": 1 }, "frontend/src/app/why-regengine/page.tsx": { "epcis-unlabeled": 7 @@ -282,8 +243,7 @@ "edi-sftp": 2 }, "frontend/src/lib/customer-readiness.ts": { - "epcis-unlabeled": 6, - "sample-demo": 3 + "epcis-unlabeled": 4 }, "frontend/src/lib/dev-compliance-snapshots.ts": { "dash-placeholder": 2 From e83d4281a652f3e7f3e5fdad91bb9dbe4ca824fc Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:20:19 -0700 Subject: [PATCH 7/7] fix(schema): include public.data_gap_events in expected schema snapshot Regenerate docs/schema/expected_schema.json from a fresh alembic upgrade head so the RLS isolation suite schema parity check stops reporting EXTRA TABLE for the v135 data gap migration. Co-authored-by: Cursor --- docs/schema/expected_schema.json | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/schema/expected_schema.json b/docs/schema/expected_schema.json index 97739280e..e59024e33 100644 --- a/docs/schema/expected_schema.json +++ b/docs/schema/expected_schema.json @@ -5525,6 +5525,55 @@ } ] }, + "public.data_gap_events": { + "columns": { + "closed_at": { + "nullable": true, + "type": "timestamp" + }, + "closing_record_hash": { + "nullable": true, + "type": "text" + }, + "closing_record_id": { + "nullable": true, + "type": "uuid" + }, + "cte_type": { + "nullable": false, + "type": "text" + }, + "gap_key": { + "nullable": false, + "type": "text" + }, + "gap_kind": { + "nullable": false, + "type": "text" + }, + "id": { + "nullable": false, + "type": "uuid" + }, + "lot_code": { + "nullable": false, + "type": "text" + }, + "opened_at": { + "nullable": false, + "type": "timestamp" + }, + "supplier_key": { + "nullable": false, + "type": "text" + }, + "tenant_id": { + "nullable": false, + "type": "uuid" + } + }, + "foreign_keys": [] + }, "public.food_traceability_list": { "columns": { "applicable_ctes": {