- commit
- 9a32b1d5b1c3bce0b6f03d7300612f9c3dd0e66c
- parent
- 07c3d4603e913a147696716a6e999985334539ab
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 29 | +++++++++++++ |
| M | src/ | 24 | +++++++++-- |
| M | tests/ | 68 | ++++++++++++++++++++++++++++++ |
@@ -1,4 +1,5 @@
11 import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';2+import { parseRules, RuleParseLimitError } from './engine/rules.js';23 export { collectModWarnings, MAX_MOD_WARNINGS } from './mods/diagnostics.js';34 45 /**@@ -44,6 +45,33 @@ function copy(value) {
4445 return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));4546 }4647 48+function validateInitialRuleComplexity(level, index, errors) {49+ if (!isRecord(level) || !Array.isArray(level.objects) || !isInteger(level.width) || !isInteger(level.height)) return;50+ // Structural validation owns the detailed malformed object messages. Only51+ // ask the parser about boards that have enough shape to be parsed safely.52+ if (level.objects.some(object => !isRecord(object) || !isInteger(object.x) || !isInteger(object.y))) return;53+ const semanticProperties = new Set(CORE_PROPERTIES);54+ for (const automaton of Array.isArray(level.automata) ? level.automata : []) {55+ if (typeof automaton?.property === 'string') semanticProperties.add(automaton.property.toUpperCase());56+ }57+ const objects = level.objects.map((object, objectIndex) => ({58+ ...object,59+ id: object.id || `o${objectIndex}`,60+ kind: object.kind || (object.text !== undefined ? 'word' : 'object'),61+ ...(typeof object.text === 'string' ? { text: object.text.toUpperCase() } : {}),62+ ...(typeof object.noun === 'string' ? { noun: object.noun.toUpperCase() } : {}),63+ }));64+ try {65+ parseRules(objects, semanticProperties);66+ } catch (error) {67+ if (error instanceof RuleParseLimitError) {68+ const kind = error.kind || 'complexity';69+ const limit = Number.isInteger(error.limit) ? ` (${error.limit})` : '';70+ errors.push(issue(`levels[${index}].objects`, `rule parse ${kind} budget exceeded${limit}; reduce stacked rule alternatives`));71+ }72+ }73+}74+4775 function validatePalette(palette, errors) {4876 if (palette === undefined) return;4977 if (!isRecord(palette)) {@@ -215,6 +243,7 @@ export function validateMod(mod) {
215243 }216244 });217245 }246+ if (Array.isArray(mod.levels)) mod.levels.forEach((level, index) => validateInitialRuleComplexity(level, index, errors));218247 validatePalette(mod.palette, errors);219248 if (mod.vocabulary !== undefined) {220249 if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));@@ -1,4 +1,4 @@
1-import { parseRules } from '../engine/rules.js';1+import { parseRules, RuleParseLimitError } from '../engine/rules.js';22 import { CORE_PROPERTY_TOKENS } from '../engine/tokens.js';33 44 export const MAX_MOD_WARNINGS = 64;@@ -15,20 +15,36 @@ export function collectModWarnings(normalizedMod) {
1515 if (typeof automaton?.property === 'string') allProperties.add(automaton.property.toUpperCase());1616 }1717 for (const [levelIndex, level] of levels.entries()) {18+ const levelId = typeof level?.id === 'string' ? level.id : undefined;19+ const levelTitle = typeof level?.title === 'string' && level.title ? level.title : `Proof ${levelIndex + 1}`;1820 const objects = (Array.isArray(level?.objects) ? level.objects : []).map((object, index) => ({ ...object, id: object.id || `o${index}` }));1921 const semantic = new Set(CORE_PROPERTIES);2022 for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {2123 if (typeof automaton?.property === 'string') semantic.add(automaton.property.toUpperCase());2224 }23- const parsed = parseRules(objects, semantic);25+ let parsed;26+ try {27+ parsed = parseRules(objects, semantic);28+ } catch (error) {29+ if (!(error instanceof RuleParseLimitError)) throw error;30+ const limit = Number.isInteger(error.limit) ? ` (${error.limit})` : '';31+ warnings.push({32+ word: 'RULES',33+ ...(levelId ? { levelId } : {}),34+ levelTitle,35+ sentence: null,36+ message: `Rule text is too complex to analyze safely; reduce stacked rule alternatives.`,37+ detail: `levels[${levelIndex}].objects: rule parse ${error.kind || 'complexity'} budget exceeded${limit}.`,38+ });39+ if (warnings.length >= MAX_MOD_WARNINGS) return warnings;40+ continue;41+ }2442 const words = new Map(objects.filter(object => object?.kind === 'word').map(object => [object.id, object]));2543 const objectIndexes = new Map(objects.map((object, index) => [object.id, index]));2644 const knownNouns = new Set(objects.filter(object => object?.kind !== 'word' && typeof object?.noun === 'string').map(object => object.noun.toUpperCase()));2745 for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {2846 if (typeof automaton?.subject === 'string') knownNouns.add(automaton.subject.toUpperCase());2947 }30- const levelId = typeof level?.id === 'string' ? level.id : undefined;31- const levelTitle = typeof level?.title === 'string' && level.title ? level.title : `Proof ${levelIndex + 1}`;3248 const seenWords = new Set();3349 const sourceLocation = sourceId => `levels[${levelIndex}].objects[${objectIndexes.get(sourceId)}].text`;3450 const formed = new Map();@@ -6,6 +6,34 @@ import { collectModWarnings, loadMod, MAX_LEVEL_OBJECTS, MAX_MOD_LEVELS, MAX_MOD
66 const exampleSource = await readFile(new URL('../mods/example.cjs', import.meta.url), 'utf8');77 const automataExampleSource = await readFile(new URL('../mods/automata-example.cjs', import.meta.url), 'utf8');88 9+function pathologicalRuleLevel({ kind = 'spans' } = {}) {10+ const objects = [];11+ if (kind === 'paths') {12+ let x = 0;13+ for (let depth = 0; depth < 8; depth += 1) {14+ for (let alternative = 0; alternative < 2; alternative += 1) {15+ objects.push({ kind: 'word', text: depth === 0 ? 'MIAU' : `N${depth}`, x, y: 0, id: `n${depth}-${alternative}` });16+ }17+ x += 1;18+ for (let alternative = 0; alternative < 2; alternative += 1) {19+ objects.push({ kind: 'word', text: 'AND', x, y: 0, id: `a${depth}-${alternative}` });20+ }21+ x += 1;22+ }23+ for (let alternative = 0; alternative < 2; alternative += 1) {24+ objects.push({ kind: 'word', text: 'IS', x, y: 0, id: `is-${alternative}` });25+ objects.push({ kind: 'word', text: 'YOU', x: x + 1, y: 0, id: `you-${alternative}` });26+ }27+ return { id: 'path-budget', width: x + 2, height: 1, objects };28+ }29+ for (let index = 0; index < 257; index += 1) {30+ objects.push({ kind: 'word', text: 'MIAU', x: 0, y: 0, id: `subject-${index}` });31+ objects.push({ kind: 'word', text: 'IS', x: 1, y: 0, id: `is-${index}` });32+ objects.push({ kind: 'word', text: 'YOU', x: 2, y: 0, id: `predicate-${index}` });33+ }34+ return { id: 'span-budget', width: 3, height: 1, objects };35+}36+937 test('loads the complete example CommonJS mod', () => {1038 const mod = loadMod(exampleSource, { filename: 'example.cjs' });1139 assert.equal(mod.manifest.id, 'moon-cat');@@ -15,6 +43,46 @@ test('loads the complete example CommonJS mod', () => {
1543 assert.deepEqual(validateMod(mod), []);1644 });1745 46+test('rejects a pathological initial board before a CommonJS pack can install', () => {47+ const level = pathologicalRuleLevel();48+ const source = `module.exports = ${JSON.stringify({49+ manifest: { id: 'rule-budget', name: 'Rule Budget', version: '1.0.0' },50+ levels: [level],51+ })};`;52+ assert.throws(() => loadMod(source, { filename: 'rule-budget.cjs' }), error => {53+ assert.ok(error instanceof ModValidationError);54+ assert.match(error.message, /levels\[0\]\.objects: rule parse spans budget exceeded/);55+ assert.match(error.message, /65536/);56+ return true;57+ });58+});59+60+test('rejects path expansion before previewing a CommonJS pack', () => {61+ const source = `module.exports = ${JSON.stringify({62+ manifest: { id: 'path-budget', name: 'Path Budget', version: '1.0.0' },63+ levels: [pathologicalRuleLevel({ kind: 'paths' })],64+ })};`;65+ assert.throws(() => loadMod(source, { filename: 'path-budget.cjs' }), error => {66+ assert.ok(error instanceof ModValidationError);67+ assert.match(error.message, /levels\[0\]\.objects: rule parse paths budget exceeded/);68+ assert.match(error.message, /16384/);69+ return true;70+ });71+});72+73+test('reports parser budget exhaustion as a bounded diagnostics warning', () => {74+ const mod = {75+ manifest: { id: 'rule-warning', name: 'Rule Warning', version: '1.0.0' },76+ levels: [pathologicalRuleLevel()],77+ };78+ const warnings = collectModWarnings(mod);79+ assert.equal(warnings.length, 1);80+ assert.equal(warnings[0].word, 'RULES');81+ assert.equal(warnings[0].levelId, 'span-budget');82+ assert.match(warnings[0].message, /too complex/);83+ assert.match(warnings[0].detail, /spans budget exceeded/);84+});85+1886 test('loads the minimal playable fox automata proof', () => {1987 const mod = loadMod(automataExampleSource, { filename: 'automata-example.cjs' });2088 assert.equal(mod.manifest.id, 'automata-example');