forked from yvv/decision
v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal
- engine/ : parametric (médiane basse, cristallisation, impact linear-share, bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome), settings (resolveSettings + replis), triage (R-U→R6, phrases françaises), impact (concernés calculés), électionResult (blanc, quorum, égalité sans départage machine) — 296 tests vitest verts - moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits), tokens routes/états, socle borderless, print A4, tampon 井 - data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée - Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test - backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer) - test anti-lexique (marqueur ld-v2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Anti-lexicon guard (BLUEPRINT-V2.md Δ22).
|
||||
*
|
||||
* Two sweeps, both case- and accent-insensitive (NFD normalization):
|
||||
* 1. Every UI-visible VALUE exported by app/lexicon.ts — strings, Record
|
||||
* values, array items, and the return of template functions called with
|
||||
* dummy params. Export NAMES and Record KEYS are code identifiers and
|
||||
* are NOT tested (e.g. REVIEW_VERDICTS is a legal identifier even
|
||||
* though « verdict » is forbidden in UI text).
|
||||
* 2. The <template> section of every MARKED .vue file under app/.
|
||||
* A file is scanned only when it carries the magic marker comment
|
||||
* <!-- ld-v2 -->. EVERY v2 screen MUST carry this marker (the screen
|
||||
* agents add it); unmarked v1 leftovers are ignored on purpose — they
|
||||
* are scheduled for replacement, not for compliance.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as lexicon from '../app/lexicon'
|
||||
import { FORBIDDEN_UI_TERMS } from '../app/lexicon'
|
||||
|
||||
const APP_DIR = fileURLToPath(new URL('../app', import.meta.url))
|
||||
|
||||
/** Lowercase + strip diacritics: « Délégué » → « delegue ». */
|
||||
function normalize(text: string): string {
|
||||
return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
const NORMALIZED_TERMS = FORBIDDEN_UI_TERMS.map(term => ({ term, needle: normalize(term) }))
|
||||
|
||||
/** Forbidden terms contained in a text, after normalization. */
|
||||
function forbiddenTermsIn(text: string): string[] {
|
||||
const haystack = normalize(text)
|
||||
return NORMALIZED_TERMS.filter(({ needle }) => haystack.includes(needle)).map(({ term }) => term)
|
||||
}
|
||||
|
||||
interface Violation {
|
||||
source: string // export path or file path
|
||||
term: string
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
/** Call a template function with dummy params (numbers interpolate cleanly
|
||||
* into template literals AND support toLocaleString; fall back to neutral
|
||||
* strings for functions that require them). */
|
||||
function callTemplateFn(fn: (...args: never[]) => unknown): unknown {
|
||||
const arity = Math.max(fn.length, 1)
|
||||
try {
|
||||
return fn(...(Array.from({ length: arity }, (_, i) => i + 1) as never[]))
|
||||
} catch {
|
||||
return fn(...(Array.from({ length: arity }, () => 'exemple') as never[]))
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect forbidden-term violations over exported VALUES only. */
|
||||
function walkValue(value: unknown, path: string, violations: Violation[]): void {
|
||||
if (typeof value === 'string') {
|
||||
for (const term of forbiddenTermsIn(value)) {
|
||||
violations.push({ source: path, term, excerpt: value })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, i) => walkValue(item, `${path}[${i}]`, violations))
|
||||
return
|
||||
}
|
||||
if (typeof value === 'function') {
|
||||
walkValue(callTemplateFn(value as (...args: never[]) => unknown), `${path}(…)`, violations)
|
||||
return
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
// Record: VALUES only — keys are code identifiers.
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
walkValue(item, `${path}.${key}`, violations)
|
||||
}
|
||||
}
|
||||
// numbers / booleans / null / undefined: nothing to check
|
||||
}
|
||||
|
||||
/** Recursive .vue listing under dir. */
|
||||
function listVueFiles(dir: string): string[] {
|
||||
const files: string[] = []
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) files.push(...listVueFiles(full))
|
||||
else if (entry.isFile() && entry.name.endsWith('.vue')) files.push(full)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the <template> section of every .vue file under `dir` that carries
|
||||
* the magic marker comment (default <!-- ld-v2 -->). The whole template is
|
||||
* scanned on purpose — the banned vocabulary must appear NOWHERE in it,
|
||||
* bound attributes included. Every v2 screen MUST carry the marker.
|
||||
*/
|
||||
export function scanVueTemplates(dir: string, marker = 'ld-v2'): Violation[] {
|
||||
const markerRe = new RegExp(`<!--\\s*${marker}\\s*-->`)
|
||||
const violations: Violation[] = []
|
||||
for (const file of listVueFiles(dir)) {
|
||||
const content = readFileSync(file, 'utf-8')
|
||||
if (!markerRe.test(content)) continue
|
||||
const start = content.indexOf('<template')
|
||||
const end = content.lastIndexOf('</template>')
|
||||
if (start === -1 || end === -1) continue
|
||||
const template = content.slice(start, end + '</template>'.length)
|
||||
for (const term of forbiddenTermsIn(template)) {
|
||||
violations.push({ source: file, term, excerpt: `terme présent dans <template>` })
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
function formatReport(violations: Violation[]): string {
|
||||
return violations
|
||||
.map(v => `${v.source} → terme interdit « ${v.term} » : ${v.excerpt}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('anti-lexique — lexicon.ts', () => {
|
||||
it('exports at least the known label tables (sanity check of the walk)', () => {
|
||||
expect(Object.keys(lexicon).length).toBeGreaterThan(10)
|
||||
expect(typeof lexicon.thresholdSentence).toBe('function')
|
||||
})
|
||||
|
||||
it('no exported UI value contains a forbidden term', () => {
|
||||
const violations: Violation[] = []
|
||||
for (const [name, value] of Object.entries(lexicon)) {
|
||||
if (name === 'FORBIDDEN_UI_TERMS') continue // the ban list itself
|
||||
walkValue(value, name, violations)
|
||||
}
|
||||
expect(formatReport(violations)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('anti-lexique — marked .vue templates (<!-- ld-v2 -->)', () => {
|
||||
it('no marked template contains a forbidden term', () => {
|
||||
const violations = scanVueTemplates(APP_DIR)
|
||||
expect(formatReport(violations)).toBe('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user