From b8d5735945955a66379462da8d1fe7c12de8150e Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:09:08 -0700 Subject: [PATCH] SEL-452: RegEngine copy-rules lint (report-only) --- .github/workflows/copy-lint.yml | 28 ++++ frontend/src/lib/capabilities.ts | 6 + scripts/copy-lint/allowlist.json | 6 + scripts/copy-lint/check-copy.mjs | 219 ++++++++++++++++++++++++++ scripts/copy-lint/check-copy.test.mjs | 68 ++++++++ scripts/copy-lint/copy-rules.json | 42 +++++ 6 files changed, 369 insertions(+) create mode 100644 .github/workflows/copy-lint.yml create mode 100644 frontend/src/lib/capabilities.ts create mode 100644 scripts/copy-lint/allowlist.json create mode 100644 scripts/copy-lint/check-copy.mjs create mode 100644 scripts/copy-lint/check-copy.test.mjs create mode 100644 scripts/copy-lint/copy-rules.json diff --git a/.github/workflows/copy-lint.yml b/.github/workflows/copy-lint.yml new file mode 100644 index 000000000..80fb0db51 --- /dev/null +++ b/.github/workflows/copy-lint.yml @@ -0,0 +1,28 @@ +name: Copy rules report + +on: + pull_request: + paths: + - 'frontend/**' + - 'scripts/copy-lint/**' + - '.github/workflows/copy-lint.yml' + push: + branches: [main] + paths: + - 'frontend/**' + - 'scripts/copy-lint/**' + - '.github/workflows/copy-lint.yml' + +permissions: + contents: read + +jobs: + report: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: node --test scripts/copy-lint/check-copy.test.mjs + - run: node scripts/copy-lint/check-copy.mjs . --github diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts new file mode 100644 index 000000000..efa00ece8 --- /dev/null +++ b/frontend/src/lib/capabilities.ts @@ -0,0 +1,6 @@ +// Copy status registry. Keep these labels aligned with product availability. +export const capabilities = { + live: ['CSV upload', 'JSON events API'], + preview: ['EPCIS intake', 'EPCIS export'], + neverShown: ['EDI', 'SFTP'], +} as const; diff --git a/scripts/copy-lint/allowlist.json b/scripts/copy-lint/allowlist.json new file mode 100644 index 000000000..f567f3a5f --- /dev/null +++ b/scripts/copy-lint/allowlist.json @@ -0,0 +1,6 @@ +{ + "comment": "Justified exceptions only. Each entry needs file, rule, match (substring of the flagged text) and reason. Optional line pins it to one line. Review every entry in PR.", + "entries": [ + { "file": "frontend/src/lib/api-proxy.ts", "rule": "nan-undefined", "match": "bearer undefined", "reason": "Auth header guard that rejects a malformed token. Never rendered." } + ] +} diff --git a/scripts/copy-lint/check-copy.mjs b/scripts/copy-lint/check-copy.mjs new file mode 100644 index 000000000..43f84f7c1 --- /dev/null +++ b/scripts/copy-lint/check-copy.mjs @@ -0,0 +1,219 @@ +#!/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] +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +function walk(dir, out, cfg) { + if (!fs.existsSync(dir)) return out; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (cfg.skip.some(s => p.includes(s))) continue; + if (e.isDirectory()) walk(p, out, cfg); + else if (cfg.extensions.includes(path.extname(e.name))) out.push(p); + } + return out; +} + +// Lexer: returns copy segments {text, line, col, kind} with comments removed. +// kind: 'str' (quote or template literal text), 'jsx' (JSX text), 'md' (markdown line) +export function segmentsTs(src, isTsx) { + const segs = []; let i = 0, line = 1, col = 1; const n = src.length; + const adv = () => { if (src[i] === '\n') { line++; col = 1; } else col++; i++; }; + const tplStack = []; // brace depth inside ${ } + let braceDepth = 0; + let code = ''; let codeLine = 1, codeCol = 1; // JSX text candidate buffer + const flushJsx = () => { + if (isTsx && code) { + // JSX text: content after '>' up to '<' or '{', only when it reads like prose + const m = code.match(/>([^<>{}]*)$/s); + if (m && /[A-Za-z\u2013\u2014]/.test(m[1]) && !/[;=]|&&|\|\||=>|\)\s*$|^[\s)]*[:?]/.test(m[1])) { + const before = code.slice(0, code.length - m[1].length); + let l = codeLine, c = codeCol; for (const ch of before) { if (ch === '\n') { l++; c = 1; } else c++; } + const lead = m[1].match(/^\s*/)[0]; for (const ch of lead) { if (ch === '\n') { l++; c = 1; } else c++; } + if (m[1].trim()) segs.push({ text: m[1].trim(), line: l, col: c, kind: 'jsx' }); + } + } + code = ''; codeLine = line; codeCol = col; + }; + while (i < n) { + const ch = src[i], nx = src[i + 1]; + if (ch === '/' && nx === '/') { flushJsx(); while (i < n && src[i] !== '\n') adv(); continue; } + if (ch === '/' && nx === '*') { flushJsx(); adv(); adv(); while (i < n && !(src[i] === '*' && src[i + 1] === '/')) adv(); adv(); adv(); continue; } + if (isTsx && ch === '{' && nx === '/' && src[i + 2] === '*') { flushJsx(); while (i < n && !(src[i] === '*' && src[i + 1] === '/')) adv(); adv(); adv(); while (i < n && /\s/.test(src[i])) adv(); if (src[i] === '}') adv(); continue; } + if (ch === '"' || ch === "'" || ch === '`' || (ch === '}' && tplStack.length && tplStack[tplStack.length - 1] === braceDepth)) { + // in JSX text an apostrophe is prose, not a string start + if (isTsx && ch === "'" && /[A-Za-z]$/.test(code) && />[^<>{}]*$/s.test(code)) { code += ch; adv(); continue; } + flushJsx(); + let q = ch; if (ch === '}') { tplStack.pop(); q = '`'; } + adv(); const sl = line, sc = col; let buf = ''; + while (i < n) { + if (src[i] === '\\') { buf += src[i + 1] || ''; adv(); adv(); continue; } + if (q === '`' && src[i] === '$' && src[i + 1] === '{') { adv(); adv(); tplStack.push(braceDepth); break; } + if (src[i] === q) { adv(); break; } + if (q !== '`' && src[i] === '\n') break; + buf += src[i]; adv(); + } + if (buf) segs.push({ text: buf, line: sl, col: sc, kind: 'str' }); + codeLine = line; codeCol = col; continue; + } + if (ch === '{') { flushJsx(); braceDepth++; adv(); codeLine = line; codeCol = col; continue; } + if (ch === '}') { flushJsx(); braceDepth--; adv(); codeLine = line; codeCol = col; continue; } + if (ch === '<') { flushJsx(); } + if (!code) { codeLine = line; codeCol = col; } + code += ch; adv(); + } + flushJsx(); + return segs; +} + +export function segmentsMd(src) { + const segs = []; let fence = false, htmlc = false; + src.split('\n').forEach((t, k) => { + if (/^\s*(```|~~~)/.test(t)) { fence = !fence; return; } + if (fence) return; + if (t.includes('')) htmlc = false; return; } + if (/^\s*(import|export)\s/.test(t)) return; + let s = t.replace(/`[^`]*`/g, ''); // inline code + if (/^\s*---\s*$/.test(s) || /^\s*\|?[\s:|-]+\|?\s*$/.test(s)) return; // rules, table separators + s = s.replace(/^\s*[-*]\s/, ' '); // list bullets + if (s.trim()) segs.push({ text: s, line: k + 1, col: 1, kind: 'md' }); + }); + return segs; +} + +const looksLikeCode = s => /calc\(|var\(--|^[\w\-:/\[\].%#@]+(\s+[\w\-:/\[\].%#@!]+)*$/.test(s) && !/\s[a-z]+\s[a-z]+\s/i.test(s) && !/[\u2013\u2014]/.test(s); +const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const word = s => new RegExp(`(? m[1]); + if (!groups[name].length) throw new Error(`Empty ${name} capability registry`); + } + return groups; +} + +function copyText(seg, srcLine) { + if (/^\s*import\b/.test(srcLine)) return ''; + if (seg.kind === 'str') { + const prefix = srcLine.slice(0, Math.max(0, seg.col - 2)); + if (/\b(?:className|href|src|path|route|id|key|testId|data-[\w-]+)\s*[:=]\s*$/.test(prefix)) return ''; + if (/\bfrom\s*$/.test(prefix)) return ''; + if (/\b(?:lastRun|nextRun|manifestHash)\s*:\s*$/.test(prefix)) return ''; + if (/\b[A-Z][A-Z0-9_]*\s*=\s*$/.test(prefix) && /^[a-z_]+$/.test(seg.text)) return ''; + if (/^[a-z]+(?:[-_][a-z]+)+$/.test(seg.text)) return ''; + } + return seg.text.replace(/https?:\/\/\S+|(? hits.push({ rule, match: m }); + if (/^\s*(?:[\u2014\u2013]|--)\s*$/.test(t) || (t.length < 4 && /^[\s'"`]*[\u2014\u2013][\s'"`]*$/.test(t)) || (t.trim() === '-' && /(?:\?\?|\|\|)\s*['"]-['"]/.test(srcLine))) add('dash-placeholder', t.trim()); + else { + if (t.includes('\u2014')) add('em-dash', t); + 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); + 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); + const low = t.toLowerCase(); + 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 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); + if (/(?:^|[^A-Za-z0-9_/-])(?:sample|demo|simulated|synthetic)(?![A-Za-z0-9_/-])/i.test(copy)) add('sample-demo', copy); + if (word('sandbox').test(copy)) add('sandbox', copy); + } + return hits; +} + +function emptyRenderSegments(src) { + const clean = src.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, m => m.replace(/[^\n]/g, ' ')); + const segs = []; + const re = /\{[^{}]*?(?:\?\?|\|\|)\s*(?:''|""|``)[^{}]*?\}/g; + for (const m of clean.matchAll(re)) { + const prefix = clean.slice(Math.max(0, m.index - 200), m.index); + if (!/(?:<[^>]*|>[^<]*)$/.test(prefix)) continue; + if (/=>|;/.test(m[0])) continue; + const before = clean.slice(0, m.index); + const line = before.split('\n').length; + const col = m.index - before.lastIndexOf('\n'); + segs.push({ text: m[0].replace(/\s+/g, ' ').slice(0, 120), line, col, kind: 'jsx-empty' }); + } + return segs; +} + +export function scanSource(src, rel, cfg, capabilities, allow = []) { + if (cfg.skip.some(part => rel.includes(part))) return []; + const lines = src.split('\n'); + const ext = path.extname(rel); + const segs = ext === '.md' || ext === '.mdx' ? segmentsMd(src) : segmentsTs(src, ext === '.tsx'); + const copyLines = new Map(); + for (const s of segs) { + const copy = copyText(s, lines[s.line - 1] || ''); + if (copy) copyLines.set(s.line, `${copyLines.get(s.line) || ''} ${copy}`); + } + if (ext === '.tsx') segs.push(...emptyRenderSegments(src)); + const findings = []; const seen = new Set(); + for (const s of segs) { + const hits = s.kind === 'jsx-empty' ? [{ rule: 'empty-render' }] : check(s, lines[s.line - 1] || '', cfg, capabilities, copyLines.get(s.line)); + for (const h of hits) { + const allowed = allow.find(a => rel.endsWith(a.file) && a.rule === h.rule && (!a.line || a.line === s.line) && (!a.match || s.text.includes(a.match))); + if (allowed) continue; + const key = `${rel}:${s.line}:${s.col}:${h.rule}`; if (seen.has(key)) continue; seen.add(key); + findings.push({ file: rel, line: s.line, col: s.col, rule: h.rule, text: s.text.replace(/\s+/g, ' ').slice(0, 120) }); + } + } + return findings; +} + +export function summarize(findings, filesScanned, cfg) { + const byRule = Object.fromEntries(Object.keys(cfg.rules).map(rule => [rule, 0])); + findings.forEach(x => { byRule[x.rule]++; }); + return { total: findings.length, filesWithFindings: new Set(findings.map(x => x.file)).size, filesScanned, byRule }; +} + +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 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')); + const allow = fs.existsSync(allowPath) ? JSON.parse(fs.readFileSync(allowPath, 'utf8')).entries || [] : []; + const targetRegistry = path.join(repo, 'frontend/src/lib/capabilities.ts'); + const registry = fs.existsSync(targetRegistry) ? targetRegistry : path.resolve(here, '../../frontend/src/lib/capabilities.ts'); + const capabilities = parseCapabilities(fs.readFileSync(registry, 'utf8')); + 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); + if (args.includes('--json')) console.log(JSON.stringify({ summary, findings }, null, 1)); + else { + for (const x of findings) { + console.log(`${x.file}:${x.line}:${x.col} ${x.rule} ${x.text}`); + if (args.includes('--github') || process.env.GITHUB_ACTIONS === 'true') { + console.log(`::warning file=${propertyEscape(x.file)},line=${x.line},col=${x.col},title=copy-lint/${x.rule}::${annotationEscape(cfg.rules[x.rule].message)}`); + } + } + 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 (summary.total && (args.includes('--strict') || process.env.COPY_LINT_STRICT === '1')) process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2)); diff --git a/scripts/copy-lint/check-copy.test.mjs b/scripts/copy-lint/check-copy.test.mjs new file mode 100644 index 000000000..1b3751f63 --- /dev/null +++ b/scripts/copy-lint/check-copy.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +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'; + +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); + +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.equal(Object.values(cfg.rules).every(rule => ['visual-designer', 'product-designer', 'both'].includes(rule.source)), true); +}); + +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('—
'), ['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
'), []); +}); + +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']); +}); + +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/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'), []); +}); + +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("//{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' }, + ]); + 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']); +}); diff --git a/scripts/copy-lint/copy-rules.json b/scripts/copy-lint/copy-rules.json new file mode 100644 index 000000000..a1ea53b6a --- /dev/null +++ b/scripts/copy-lint/copy-rules.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "source": "C.Lee copy rules and Product Designer empty value rules, Oct 23 2026", + "roots": ["frontend/src/app", "frontend/src/components", "frontend/src/lib"], + "extensions": [".tsx", ".ts", ".md", ".mdx"], + "skip": ["__tests__", "/test/", ".test.", ".spec.", ".stories.", "node_modules", ".next"], + "rules": { + "em-dash": { "message": "Em dash in copy. Rewrite with a comma, period, colon or parentheses.", "source": "visual-designer" }, + "en-dash": { "message": "En dash in copy. Use 'to' for ranges or rewrite.", "source": "visual-designer" }, + "hyphen-dash": { "message": "Spaced hyphen ' - ' used as a dash in copy. Rewrite.", "source": "both" }, + "dash-placeholder": { "message": "Standalone dash as an empty value. Use 'Not set' (fillable field), 'No data yet' plus a next step link (empty metric) or 0 (real zero). See lib/empty-values.ts.", "source": "both" }, + "edi-sftp": { "message": "EDI or SFTP named in copy. These are not mentioned at all, roadmap included.", "source": "both" }, + "epcis-unlabeled": { "message": "EPCIS mentioned without 'Preview' in the same string or line. Intake and export both count.", "source": "both" }, + "nan-undefined": { "message": "'NaN' or 'undefined' in rendered text.", "source": "visual-designer" }, + "invented-entity": { "message": "Invented supplier, facility, lot or number. Real data only: render real data or an empty state.", "source": "both" }, + "live-claim": { "message": "A non-live capability is labeled Live, Implemented, GA or Available.", "source": "product-designer" }, + "pilot-status": { "message": "Pilot is not an approved badge or status label.", "source": "product-designer" }, + "sample-demo": { "message": "Sample, demo, simulated or synthetic wording in copy.", "source": "product-designer" }, + "sandbox": { "message": "Sandbox wording in copy.", "source": "product-designer" }, + "empty-render": { "message": "Blank fallback rendered in JSX. Use an explicit empty state.", "source": "product-designer" } + }, + "denylist": [ + "Salinas Valley Farms", + "Salinas Valley Cooler", + "Salinas Valley grower", + "FreshCut Processing", + "Pacific Coast Growers", + "Valley Fresh Farms", + "Valley Fresh Packhouse", + "MegaMart Distribution Center", + "Premier Grocery Warehouse", + "North River Test Facility", + "test 88493", + "LOT-ROM-", + "SV-20240115-001", + "18,200 lbs", + "~2,400", + "test data", + "FreshCo" + ], + "denylistSource": "punch-list.md sections 'Sample / fake data flags' plus PR #3471 review 5625527202" +}