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:
Yvv
2026-08-11 09:22:19 +02:00
co-authored by Claude Fable 5
parent 53d8752e40
commit f707b5f15d
18 changed files with 10324 additions and 181 deletions
+142
View File
@@ -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('')
})
})