Skip to content

Commit 59008c0

Browse files
authored
Merge pull request #29 from renzorlive/feat/content-validator
feat: two-tier content validator — schema + repository-wide semantics
2 parents cb9a050 + caf5122 commit 59008c0

7 files changed

Lines changed: 390 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: se
55

66
## [Unreleased]
77

8+
### Added
9+
- **Two-tier content validator** (issue #26): per-lesson schema rules now also check `initialCursor` within buffer bounds and validate every `solution` key token; a new repository-wide semantic pass catches what per-file validation can't — duplicate lesson IDs (S001), dangling `prerequisites` references (S002), duplicate curriculum order (S003), and index/content drift in both directions (S004). Every violation is reported with `file path → schema location`, all in one run. 14 new tests cover every violation class.
10+
811
### Changed
912
- **Tailwind is now built, not fetched** (TD-10): the ~300 KB render-blocking `cdn.tailwindcss.com` script (explicitly not for production) is replaced by a 32 KB static `css/tailwind.css` generated at build time from the classes actually used (`npm run build:css`, Tailwind v3 — same major as the CDN, guaranteeing visual parity). Wired into `npm run check`/CI; the last third-party console warning is gone, and offline/PWA work is unblocked.
1013

14+
### Fixed
15+
- **Practice lessons are validated again**: the contract runner iterated the `vimLessons` Map with `Object.entries()` (always empty), silently skipping all 17 practice lessons — the suite now checks all 35 lessons instead of 18. Same Map-migration bug family as the dead Practice buttons fixed in v3.0.0.
16+
1117
## [3.0.0] - 2026-07-10 — Community Alpha
1218

1319
> First tagged release. Everything below shipped together as the Community Alpha; nothing earlier was ever tagged.

js/levels.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ export const levels = loadRegularLessons();
1818
// specified in docs/architecture/level-lifecycle.md (ADR-0005); parallel
1919
// initialization code paths are not accepted.
2020
//
21-
// TODO(validateLessonSchema, issue #26): when the JSON content system lands
22-
// (docs/ContentSystem.md, Phase 2), replace these runtime guards with a
23-
// schema validator run in CI over every content file: cursor within buffer,
24-
// buffer shape, objective validity, solution replay.
25-
// https://github.com/renzorlive/vimmaster/issues/26
21+
// Schema validation is enforced in CI by the content validator
22+
// (tests/contract/: per-lesson rules + semantic-rules.js — issue #26):
23+
// cursor within buffer, buffer shape, objective validity, solution key
24+
// tokens, unique IDs, resolvable prerequisites, index consistency; the
25+
// Golden Suite replays every solution. The guards below remain as
26+
// runtime defense-in-depth for content loaded outside CI.
2627

2728
// Exactly one of these must be present on every lesson — it is the objective.
2829
const WIN_CONDITION_PROPS = ['target', 'targetText', 'targetContent', 'exCommands'];

tests/contract/rules/cursor.js

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,47 @@ registerRule({
55
name: 'Cursor Invalid',
66
severity: 'error',
77
validate: (lesson, report) => {
8-
// We currently do not have a declarative `startCursor` property.
9-
// The cursor is set via the `setup(gameState)` function in code.
10-
// We can check if `setup` is a function, and maybe stringify it to ensure it sets cursor.
11-
// In the future (V2), we will have a declarative `startCursor` field.
8+
// Declarative start position: must be a well-formed {row, col} that
9+
// sits INSIDE the initial buffer (issue #26, Tier 1). The runtime
10+
// clamp in initializeLessonState remains as defense-in-depth; CI is
11+
// the enforcement point.
12+
if (lesson.initialCursor !== undefined) {
13+
const cursor = lesson.initialCursor;
14+
if (typeof cursor !== 'object' || cursor === null) {
15+
report('`initialCursor` must be an object {row, col}.', 'initialCursor');
16+
} else if (!Number.isInteger(cursor.row) || !Number.isInteger(cursor.col)) {
17+
report('`initialCursor` must contain integer `row` and `col`.', 'initialCursor');
18+
} else if (Array.isArray(lesson.initialContent) && lesson.initialContent.length > 0) {
19+
const { row, col } = cursor;
20+
if (row < 0 || row >= lesson.initialContent.length) {
21+
report(
22+
`\`initialCursor.row\` ${row} is outside the buffer (0..${lesson.initialContent.length - 1}).`,
23+
'initialCursor.row'
24+
);
25+
} else {
26+
const line = lesson.initialContent[row];
27+
const maxCol = Math.max(0, (typeof line === 'string' ? line.length : 1) - 1);
28+
if (col < 0 || col > maxCol) {
29+
report(
30+
`\`initialCursor.col\` ${col} is outside line ${row} (0..${maxCol}).`,
31+
'initialCursor.col'
32+
);
33+
}
34+
}
35+
}
36+
}
37+
38+
// Legacy fields kept for backward compatibility
1239
if (lesson.startCursor !== undefined) {
1340
if (typeof lesson.startCursor !== 'object' || lesson.startCursor === null) {
1441
report('`startCursor` must be an object {row, col}.', 'startCursor');
1542
} else if (typeof lesson.startCursor.row !== 'number' || typeof lesson.startCursor.col !== 'number') {
1643
report('`startCursor` must contain numeric `row` and `col`.', 'startCursor');
1744
}
1845
}
19-
20-
// For legacy `setup`, we can't fully validate the exact bounds statically without executing it,
21-
// but if it is provided, it must be a function.
46+
47+
// For legacy `setup`, we can't fully validate the exact bounds statically
48+
// without executing it, but if it is provided, it must be a function.
2249
if (lesson.setup !== undefined && typeof lesson.setup !== 'function') {
2350
report('Legacy `setup` must be a function.', 'setup');
2451
}

tests/contract/rules/solution.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,26 @@ registerRule({
1515
);
1616
} else if (lesson.solution.length === 0) {
1717
report('The `solution` array is empty.', 'solution');
18+
} else {
19+
// Each entry must be a valid key token: a single printable
20+
// character, a named key, or a Ctrl+<char> chord — the formats
21+
// the Golden replay understands (issue #26: invalid command
22+
// sequences).
23+
const NAMED_KEYS = new Set(['Enter', 'Escape', 'Backspace', '<Esc>']);
24+
lesson.solution.forEach((key, i) => {
25+
const valid =
26+
typeof key === 'string' &&
27+
(key.length === 1 ||
28+
NAMED_KEYS.has(key) ||
29+
/^Ctrl\+.$/i.test(key));
30+
if (!valid) {
31+
report(
32+
`\`solution[${i}]\` is not a valid key token: ${JSON.stringify(key)}.`,
33+
`solution[${i}]`,
34+
"Use single characters ('d'), named keys ('Enter', 'Escape', 'Backspace'), or chords ('Ctrl+r')."
35+
);
36+
}
37+
});
1838
}
1939
}
2040
});

tests/contract/runner.js

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { validateLesson } from './validator.js';
2+
import { validateContentSet, lessonFilePath } from './semantic-rules.js';
23

3-
// Import all rules to register them
4+
// Import all per-lesson rules to register them (Tier 1)
45
import './rules/id.js';
56
import './rules/cursor.js';
67
import './rules/buffer.js';
@@ -12,38 +13,50 @@ import './rules/metadata.js';
1213
// Import content to validate
1314
import { levels } from '../../js/levels.js';
1415
import { vimLessons } from '../../js/cheat-mode.js';
16+
import { loadIndex } from '../../js/content-loader.js';
1517

16-
// Aggregate all lessons
18+
// Aggregate all lessons — regular levels AND practice lessons.
19+
// (vimLessons is a Map: it must be iterated with .entries(); the previous
20+
// Object.entries() returned nothing, silently skipping all practice lessons.)
1721
const allLessons = [];
1822

19-
// From levels.js (indexed)
2023
levels.forEach((lesson, index) => {
2124
allLessons.push({ lesson, fallbackId: `Level ${index}` });
2225
});
2326

24-
// From cheat-mode.js (object map)
25-
for (const [key, lesson] of Object.entries(vimLessons)) {
26-
allLessons.push({ lesson, fallbackId: `CheatMode ${key}` });
27+
for (const [key, lesson] of vimLessons.entries()) {
28+
allLessons.push({ lesson, fallbackId: `Practice ${key}` });
2729
}
2830

2931
let totalErrors = 0;
3032
let totalWarnings = 0;
3133
let totalInfos = 0;
3234

35+
// Reporting contract (issue #26): every violation is printed with the
36+
// lesson's file path and schema location; ALL violations across ALL lessons
37+
// are collected in one run; a single non-zero exit happens at the end.
38+
const printViolation = (severityColor, ruleId, filePath, location, message, suggestion) => {
39+
console.log(` ${severityColor}${ruleId}\x1b[0m ${filePath}${location || '(lesson)'}`);
40+
console.log(` ${message}`);
41+
if (suggestion) {
42+
console.log(` ↳ \x1b[90m${suggestion}\x1b[0m`);
43+
}
44+
};
45+
3346
console.log('\n🔍 Running VIM Master Contract Suite\n');
3447

48+
// ---- Tier 1: per-lesson (schema-level) ------------------------------------
3549
for (const { lesson, fallbackId } of allLessons) {
3650
const errors = validateLesson(lesson, fallbackId);
37-
51+
3852
if (errors.length === 0) {
3953
console.log(`✓ ${lesson.name || fallbackId}`);
4054
continue;
4155
}
4256

43-
// Determine the highest severity for this lesson to color the status
4457
const hasError = errors.some(e => e.severity === 'error');
4558
const hasWarning = errors.some(e => e.severity === 'warning');
46-
59+
4760
if (hasError) {
4861
console.log(`\x1b[31m✗\x1b[0m ${lesson.name || fallbackId}`);
4962
} else if (hasWarning) {
@@ -52,25 +65,38 @@ for (const { lesson, fallbackId } of allLessons) {
5265
console.log(`\x1b[36mℹ\x1b[0m ${lesson.name || fallbackId}`);
5366
}
5467

55-
// Print all violations
5668
for (const err of errors) {
69+
const filePath = lessonFilePath(lesson);
5770
if (err.severity === 'error') {
5871
totalErrors++;
59-
console.log(` \x1b[31m${err.ruleId || 'ERROR'}\x1b[0m ${err.message}`);
72+
printViolation('\x1b[31m', err.ruleId || 'ERROR', filePath, err.field, err.message, err.suggestion);
6073
} else if (err.severity === 'warning') {
6174
totalWarnings++;
62-
console.log(` \x1b[33m${err.ruleId || 'WARN'}\x1b[0m ${err.message}`);
75+
printViolation('\x1b[33m', err.ruleId || 'WARN', filePath, err.field, err.message, err.suggestion);
6376
} else {
6477
totalInfos++;
65-
console.log(` \x1b[36m${err.ruleId || 'INFO'}\x1b[0m ${err.message}`);
66-
}
67-
68-
if (err.suggestion) {
69-
console.log(` ↳ \x1b[90m${err.suggestion}\x1b[0m`);
78+
printViolation('\x1b[36m', err.ruleId || 'INFO', filePath, err.field, err.message, err.suggestion);
7079
}
7180
}
7281
}
7382

83+
// ---- Tier 2: repository-wide (semantic) ------------------------------------
84+
console.log('\n🧭 Semantic checks (whole content set)\n');
85+
86+
const semanticViolations = validateContentSet(
87+
allLessons.map(({ lesson }) => lesson),
88+
loadIndex()
89+
);
90+
91+
if (semanticViolations.length === 0) {
92+
console.log('✓ IDs unique, prerequisites resolve, curriculum order consistent, index in sync');
93+
} else {
94+
for (const v of semanticViolations) {
95+
totalErrors++;
96+
printViolation('\x1b[31m', v.ruleId, v.filePath, v.location, v.message, v.suggestion);
97+
}
98+
}
99+
74100
console.log('\n--------------------------------');
75101
console.log(`Lessons checked: ${allLessons.length}`);
76102
console.log(`\x1b[31mErrors: ${totalErrors}\x1b[0m`);

tests/contract/semantic-rules.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Repository-wide (semantic) content validation — Tier 2 of the content
3+
* validator (issue #26).
4+
*
5+
* Per-lesson rules (tests/contract/rules/*) can only see one lesson at a
6+
* time; these rules receive the FULL content set and catch what per-file
7+
* validation cannot: duplicate IDs, dangling cross-lesson references, and
8+
* curriculum-ordering inconsistencies.
9+
*
10+
* Reporting contract (issue #26): every violation carries the lesson's
11+
* file path and a schema location, all violations are collected in one
12+
* run, and the caller exits non-zero once at the end.
13+
*/
14+
15+
export const lessonFilePath = (lesson) =>
16+
lesson && typeof lesson.id === 'string' ? `content/lessons/${lesson.id}.json` : '<unknown lesson file>';
17+
18+
/**
19+
* @param {Array<Object>} lessons - every lesson in the content set
20+
* @param {Object} index - parsed content/index.json ({ regularLessons, practiceLessons })
21+
* @returns {Array<{ruleId, severity, filePath, location, message, suggestion}>}
22+
*/
23+
export function validateContentSet(lessons, index) {
24+
const violations = [];
25+
const report = (ruleId, lesson, location, message, suggestion = null) => {
26+
violations.push({
27+
ruleId,
28+
severity: 'error',
29+
filePath: lessonFilePath(lesson),
30+
location,
31+
message,
32+
suggestion
33+
});
34+
};
35+
36+
// S001 — lesson IDs must be unique across the whole content set
37+
const byId = new Map();
38+
for (const lesson of lessons) {
39+
if (typeof lesson.id !== 'string') continue; // per-lesson L001 reports this
40+
if (byId.has(lesson.id)) {
41+
report('S001', lesson, 'id',
42+
`Duplicate lesson ID '${lesson.id}' (also defined by another lesson).`,
43+
'Lesson IDs must be unique across content/lessons/.');
44+
} else {
45+
byId.set(lesson.id, lesson);
46+
}
47+
}
48+
49+
// S002 — cross-lesson references must resolve
50+
for (const lesson of lessons) {
51+
const prerequisites = lesson.metadata?.prerequisites;
52+
if (!Array.isArray(prerequisites)) continue; // per-lesson L012 reports this
53+
for (const ref of prerequisites) {
54+
if (!byId.has(ref)) {
55+
report('S002', lesson, 'metadata.prerequisites',
56+
`Prerequisite '${ref}' does not match any lesson ID.`,
57+
'Reference an existing lesson id from content/lessons/.');
58+
}
59+
}
60+
}
61+
62+
// S003 — curriculum order must be unique within each track
63+
const orderSeen = new Map(); // order number -> first lesson
64+
for (const lesson of lessons) {
65+
const order = lesson.metadata?.order;
66+
if (typeof order !== 'number') continue; // per-lesson L012 reports this
67+
if (orderSeen.has(order)) {
68+
report('S003', lesson, 'metadata.order',
69+
`Curriculum order ${order} is already used by '${orderSeen.get(order).id}'.`,
70+
'Each lesson needs a distinct metadata.order.');
71+
} else {
72+
orderSeen.set(order, lesson);
73+
}
74+
}
75+
76+
// S004 — content/index.json must agree with the lesson set, both ways
77+
if (index && Array.isArray(index.regularLessons) && Array.isArray(index.practiceLessons)) {
78+
const indexed = [...index.regularLessons, ...index.practiceLessons];
79+
for (const id of indexed) {
80+
if (!byId.has(id)) {
81+
violations.push({
82+
ruleId: 'S004',
83+
severity: 'error',
84+
filePath: 'content/index.json',
85+
location: 'regularLessons/practiceLessons',
86+
message: `Index lists '${id}' but no such lesson exists.`,
87+
suggestion: 'Regenerate the index with `npm run build:content`.'
88+
});
89+
}
90+
}
91+
const indexedSet = new Set(indexed);
92+
for (const lesson of lessons) {
93+
if (typeof lesson.id === 'string' && !indexedSet.has(lesson.id)) {
94+
report('S004', lesson, 'id',
95+
`Lesson '${lesson.id}' exists but is missing from content/index.json.`,
96+
'Regenerate the index with `npm run build:content`.');
97+
}
98+
}
99+
}
100+
101+
return violations;
102+
}

0 commit comments

Comments
 (0)