Repository navigation
SEL-452: RegEngine copy-rules lint (report-only) #3510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'], | ||
|
Comment on lines
+4
to
+5
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win ✅ Runtime observed
Allowlist the registry entries. The copy checker reports Proposed allowlist entries
🤖 Prompt for AI Agents
|
||
| } as const; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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." } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The skip list contains Match against the repo-relative path with normalized separators. Pass the repo root into Proposed fix
🤖 Prompt for AI Agents
|
||
| 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 = true; | ||
| if (htmlc) { 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(`(?<![A-Za-z0-9_/-])${escapeRegex(s)}(?![A-Za-z0-9_/-])`, 'i'); | ||
|
|
||
| export function parseCapabilities(src) { | ||
| const groups = {}; | ||
| for (const name of ['live', 'preview', 'neverShown']) { | ||
| const match = src.match(new RegExp(`\\b${name}\\s*:\\s*\\[([^\\]]*)\\]`)); | ||
| if (!match) throw new Error(`Missing ${name} capability registry`); | ||
| groups[name] = [...match[1].matchAll(/'([^']+)'/g)].map(m => 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+|(?<![A-Za-z0-9])\/[A-Za-z0-9_./-]+/g, ' '); | ||
| } | ||
|
|
||
| export function check(seg, srcLine, cfg, capabilities, copyLine = '') { | ||
| const t = seg.text, hits = []; | ||
| const add = (rule, m) => 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); | ||
|
Comment on lines
+121
to
+122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When JSX uses an HTML entity, the browser renders the prohibited dash but these checks only recognize literal Unicode characters. For example, Useful? React with 👍 / 👎. |
||
| } | ||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an empty-string fallback appears in any JSX attribute, this prefix test treats it as rendered copy. The new checker consequently reports valid controlled values and non-copy props such as Useful? React with 👍 / 👎. |
||
| 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 []; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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)); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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('<p>word — word</p>'), ['em-dash']); | ||
| assert.deepEqual(rules('<p>word – word</p>'), ['en-dash']); | ||
| assert.deepEqual(rules('<p>word - word</p>'), ['hyphen-dash']); | ||
| assert.deepEqual(rules('<p>—</p>'), ['dash-placeholder']); | ||
| assert.deepEqual(rules("<p>{value ?? '--'}</p>"), ['dash-placeholder']); | ||
| assert.deepEqual(rules("<p>{value ?? '-'}</p>"), ['dash-placeholder']); | ||
| assert.deepEqual(rules('<p>EPCIS</p>'), ['epcis-unlabeled']); | ||
| assert.deepEqual(rules('<p>EPCIS Preview</p>'), []); | ||
| }); | ||
|
|
||
| test('capability claims and forbidden names use the registry', () => { | ||
| assert.deepEqual(rules('<p>EPCIS export is Live</p>'), ['epcis-unlabeled', 'live-claim']); | ||
| assert.deepEqual(rules('<p>CSV upload is Live</p>'), []); | ||
| assert.deepEqual(rules("const items = [{ name: 'EPCIS export', status: 'available' }];"), ['epcis-unlabeled', 'live-claim']); | ||
| assert.deepEqual(rules('<p>EDI 856</p>'), ['edi-sftp']); | ||
| assert.deepEqual(rules('<p>SFTP Import</p>'), ['edi-sftp']); | ||
| assert.deepEqual(rules('<span className="badge">Pilot</span>'), ['pilot-status']); | ||
| }); | ||
|
|
||
| test('copy word rules skip comments, imports, identifiers, classes and paths', () => { | ||
| assert.deepEqual(rules('<p>sample demo simulated synthetic sandbox</p>'), ['sample-demo', 'sandbox']); | ||
| assert.deepEqual(rules('<p>test data</p>'), ['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<div className='demo'>Ready</div>"), []); | ||
| assert.deepEqual(rules('<a href="/demo">Ready</a>'), []); | ||
| assert.deepEqual(rules('<p>/demo</p>'), []); | ||
| assert.deepEqual(rules('<p>See (/demo) for details</p>'), []); | ||
| assert.deepEqual(rules("const route = 'demo-route'"), []); | ||
| assert.deepEqual(rules('<p>demo</p>', 'frontend/src/app/page.test.tsx'), []); | ||
| }); | ||
|
|
||
| test('blank rendered fallbacks are caught only in JSX expressions', () => { | ||
| assert.deepEqual(rules("<p>{value ?? ''}</p>"), ['empty-render']); | ||
| assert.deepEqual(rules('<p>{value || ""}</p>'), ['empty-render']); | ||
| assert.deepEqual(rules("const fallback = value ?? '';"), []); | ||
| assert.deepEqual(rules("<button onClick={() => { setValue(value ?? ''); }}>Ready</button>"), []); | ||
| assert.deepEqual(rules("// <p>{value ?? ''}</p>"), []); | ||
| }); | ||
|
|
||
| test('allowlist and summary counts', () => { | ||
| const findings = scanSource('<p>demo</p>', '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']); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: regengine/RegEngine
Length of output: 915
🏁 Script executed:
Repository: regengine/RegEngine
Length of output: 3497
Pin both actions to commit SHAs. The
workflow-tool-pinningjob fails on the@v4references.The
workflow-tool-pinningjob in.github/workflows/security.ymlruns on pull requests and has noif:condition. It scans every workflow under.github/workflowsand rejects anyuses:ref that is not a 40-character commit SHA. This PR adds.github/workflows/copy-lint.yml, so itsactions/checkout@v4andactions/setup-node@v4references fail that job.Pin each reference to the commit SHA shown below, and keep the version as a trailing comment.
Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 zizmor (1.30.1)
[warning] 23-23: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
[error] 23-23: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
[error] 24-24: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
View Security blast radius
🤖 Prompt for AI Agents