@inklyre/oes-lint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @inklyre/oes-lint
2
+
3
+ Referential-integrity linting for [OES](https://github.com/inklyre/oes) content, built on
4
+ [`@inklyre/oes-core`](../core)'s `resolve()` rather than reimplementing a
5
+ second tree-walker.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -D @inklyre/oes-lint
11
+ ```
12
+
13
+ ## CLI
14
+
15
+ ```bash
16
+ npx oes lint ./my-course
17
+ npx oes lint ./my-set --entry set.json
18
+ npx oes lint ./my-course --json # machine-readable output, e.g. for a GitHub Action
19
+ ```
20
+
21
+ Exits `0` when no `"error"`-severity issue is found, `1` otherwise. The
22
+ entry document (`course.json`/`set.json`/`module.json`/`lesson.json`) is
23
+ auto-detected from the given directory unless `--entry` is passed.
24
+
25
+ ## GitHub Action
26
+
27
+ Since OES content lives in git, lint-on-every-PR is a near-zero-friction
28
+ way to keep a course/set repo trustworthy. Any repo can use this one's
29
+ composite action without installing anything first:
30
+
31
+ ```yaml
32
+ # .github/workflows/lint.yml, in a course/set repo (not this one)
33
+ on: pull_request
34
+ jobs:
35
+ lint:
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: inklyre/oes/.github/actions/lint@main
40
+ with:
41
+ path: ./my-course
42
+ ```
43
+
44
+ ## What it checks
45
+
46
+ Everything `resolve()` already catches while walking the tree:
47
+
48
+ - **Dangling references** — a `path`/`*_url` that doesn't resolve, or a
49
+ document that fails schema validation.
50
+ - **`content_hash` mismatches** — a declared hash that doesn't match the
51
+ referenced document's actual content.
52
+
53
+ Plus checks that need a whole-tree or single-document view `resolve()`
54
+ doesn't attempt on its own:
55
+
56
+ - **Duplicate ids** — within any array addressed by `id`: `course.modules`,
57
+ a module's `lessons`, a lesson's `articles`/`video_lessons`/`practice_sets`,
58
+ a set's `questions`, a pool's `from`, and every question type's own
59
+ id-bearing array (`options`, `left`/`right`, `items`, `blanks`,
60
+ `test_cases`, `labels`).
61
+ - **Pool `select` bounds** — a pool's `select` must be between 1 and
62
+ `from.length`; not expressible in JSON Schema since it relates two
63
+ sibling fields.
64
+ - **Dangling answer cross-references** — an `mcq`/`msq` `answer`(s), a
65
+ `match` pair's `left_id`/`right_id`, or an `order`'s `correct_order`
66
+ pointing at an id that doesn't exist in the corresponding option array.
67
+ Only checked in self-practice mode: a secured `answer_key` means there's
68
+ no inline answer to check.
69
+ - **`answer_key.file` existence** — checked directly by this package, not
70
+ by `resolve()`, which deliberately never fetches `answer_key` content at
71
+ all: a public/student-facing consumer built on
72
+ `resolveCourse`/`resolveSet` must never gain a path to the answer through
73
+ the shared resolver.
74
+
75
+ ## Programmatic API
76
+
77
+ ```ts
78
+ import { fsSource } from "@inklyre/oes-core";
79
+ import { lintCourse } from "@inklyre/oes-lint";
80
+
81
+ const { issues, ok } = await lintCourse(fsSource("./my-course"));
82
+ ```
83
+
84
+ `lintSet`, `lintLesson`, and `lintModule` are the equivalent entry points
85
+ for linting a smaller subtree in isolation (e.g. a set-only repo, or an
86
+ authoring tool previewing one lesson).
87
+
88
+ ## What this doesn't do
89
+
90
+ - **Grading, or anything answer-key-content-aware beyond existence** —
91
+ this package checks the file exists, never what's inside it.
92
+ - **Anything `resolve()` doesn't already cover as a side effect of
93
+ walking the tree** — this package adds checks, it doesn't re-implement
94
+ reference resolution.
package/dist/cli.js ADDED
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { existsSync } from "fs";
5
+ import { join } from "path";
6
+ import { fsSource } from "@inklyre/oes-core";
7
+
8
+ // src/index.ts
9
+ import { resolveCourse, resolveLesson, resolveModule, resolveSet } from "@inklyre/oes-core";
10
+
11
+ // src/types.ts
12
+ function lintResult(issues) {
13
+ return { issues, ok: !issues.some((issue) => issue.severity === "error") };
14
+ }
15
+
16
+ // src/tree.ts
17
+ import { isOpfPoolEntry } from "@inklyre/oes-core";
18
+
19
+ // src/rules/answer-references.ts
20
+ function checkIdExists(id, pool, at, issues) {
21
+ if (!pool.some((item) => item.id === id)) {
22
+ issues.push({
23
+ rule: "dangling-answer-reference",
24
+ severity: "error",
25
+ at,
26
+ message: `References id "${id}", which does not exist among [${pool.map((i) => i.id).join(", ")}]`
27
+ });
28
+ }
29
+ }
30
+ function checkPermutation(order, items, at, issues) {
31
+ const itemIds = items.map((item) => item.id);
32
+ const itemIdSet = new Set(itemIds);
33
+ const orderIdSet = new Set(order);
34
+ const isValid = order.length === itemIds.length && orderIdSet.size === order.length && [...orderIdSet].every((id) => itemIdSet.has(id));
35
+ if (!isValid) {
36
+ issues.push({
37
+ rule: "dangling-answer-reference",
38
+ severity: "error",
39
+ at,
40
+ message: `Must be exactly a permutation of [${itemIds.join(", ")}], got [${order.join(", ")}]`
41
+ });
42
+ }
43
+ }
44
+
45
+ // src/rules/answer-key-file.ts
46
+ async function checkAnswerKeyFile(resolved, at, issues) {
47
+ const file = resolved.question.answer_key?.file;
48
+ if (file === void 0) return;
49
+ try {
50
+ await resolved.source.fetch(file);
51
+ } catch (cause) {
52
+ issues.push({
53
+ rule: "answer-key-file-missing",
54
+ severity: "error",
55
+ at: `${at}.answer_key`,
56
+ message: `answer_key.file "${file}" could not be read: ${cause instanceof Error ? cause.message : String(cause)}`
57
+ });
58
+ }
59
+ }
60
+
61
+ // src/rules/unique-ids.ts
62
+ function checkUniqueIds(items, at, issues) {
63
+ const firstSeenAt = /* @__PURE__ */ new Map();
64
+ items.forEach((item, i) => {
65
+ const firstIndex = firstSeenAt.get(item.id);
66
+ if (firstIndex !== void 0) {
67
+ issues.push({
68
+ rule: "duplicate-id",
69
+ severity: "error",
70
+ at: `${at}[${i}]`,
71
+ message: `Duplicate id "${item.id}" (first seen at ${at}[${firstIndex}])`
72
+ });
73
+ } else {
74
+ firstSeenAt.set(item.id, i);
75
+ }
76
+ });
77
+ }
78
+
79
+ // src/question.ts
80
+ async function lintQuestion(resolved, at, issues) {
81
+ const { question } = resolved;
82
+ const typeConfigAt = `${at}.type_config`;
83
+ const isSecured = question.answer_key !== void 0;
84
+ switch (question.type) {
85
+ case "mcq": {
86
+ const { options, answer } = question.type_config;
87
+ checkUniqueIds(options, `${typeConfigAt}.options`, issues);
88
+ if (!isSecured && answer !== void 0) {
89
+ checkIdExists(answer, options, `${typeConfigAt}.answer`, issues);
90
+ }
91
+ break;
92
+ }
93
+ case "msq": {
94
+ const { options, answers } = question.type_config;
95
+ checkUniqueIds(options, `${typeConfigAt}.options`, issues);
96
+ if (!isSecured) {
97
+ (answers ?? []).forEach((answer, i) => checkIdExists(answer, options, `${typeConfigAt}.answers[${i}]`, issues));
98
+ }
99
+ break;
100
+ }
101
+ case "match": {
102
+ const { left, right, pairs } = question.type_config;
103
+ checkUniqueIds(left, `${typeConfigAt}.left`, issues);
104
+ checkUniqueIds(right, `${typeConfigAt}.right`, issues);
105
+ if (!isSecured) {
106
+ (pairs ?? []).forEach((pair, i) => {
107
+ checkIdExists(pair.left_id, left, `${typeConfigAt}.pairs[${i}].left_id`, issues);
108
+ checkIdExists(pair.right_id, right, `${typeConfigAt}.pairs[${i}].right_id`, issues);
109
+ });
110
+ }
111
+ break;
112
+ }
113
+ case "order": {
114
+ const { items, correct_order } = question.type_config;
115
+ checkUniqueIds(items, `${typeConfigAt}.items`, issues);
116
+ if (!isSecured && correct_order !== void 0) {
117
+ checkPermutation(correct_order, items, `${typeConfigAt}.correct_order`, issues);
118
+ }
119
+ break;
120
+ }
121
+ case "fill_blank":
122
+ checkUniqueIds(question.type_config.blanks, `${typeConfigAt}.blanks`, issues);
123
+ break;
124
+ case "code":
125
+ checkUniqueIds(question.type_config.test_cases, `${typeConfigAt}.test_cases`, issues);
126
+ break;
127
+ case "diagram":
128
+ checkUniqueIds(question.type_config.labels, `${typeConfigAt}.labels`, issues);
129
+ break;
130
+ case "numerical":
131
+ case "short_answer":
132
+ case "essay":
133
+ case "submission":
134
+ break;
135
+ }
136
+ await checkAnswerKeyFile(resolved, at, issues);
137
+ }
138
+
139
+ // src/rules/pool-bounds.ts
140
+ function checkPoolBounds(entry, at, issues) {
141
+ if (entry.select < 1) {
142
+ issues.push({
143
+ rule: "pool-select-bounds",
144
+ severity: "error",
145
+ at,
146
+ message: `select (${entry.select}) must be at least 1`
147
+ });
148
+ } else if (entry.select > entry.from.length) {
149
+ issues.push({
150
+ rule: "pool-select-bounds",
151
+ severity: "error",
152
+ at,
153
+ message: `select (${entry.select}) exceeds from.length (${entry.from.length})`
154
+ });
155
+ }
156
+ checkUniqueIds(entry.from, `${at}.from`, issues);
157
+ }
158
+
159
+ // src/tree.ts
160
+ function resolveErrorsToIssues(errors) {
161
+ return errors.map(
162
+ (error) => ({
163
+ rule: error.message.includes("content_hash mismatch") ? "content-hash-mismatch" : "dangling-reference",
164
+ severity: "error",
165
+ at: error.at,
166
+ message: error.message
167
+ })
168
+ );
169
+ }
170
+ async function lintSetContent(set, questions, at, issues) {
171
+ checkUniqueIds(set.questions, `${at}.questions`, issues);
172
+ set.questions.forEach((entry, i) => {
173
+ if (isOpfPoolEntry(entry)) checkPoolBounds(entry, `${at}.questions[${i}]`, issues);
174
+ });
175
+ for (const question of questions) {
176
+ await lintQuestion(question, `${at}.questions[id=${question.id}]`, issues);
177
+ }
178
+ }
179
+ async function lintPracticeSets(sets, at, issues) {
180
+ for (const [i, set] of sets.entries()) {
181
+ await lintSetContent(set.set, set.questions, `${at}.practice_sets[${i}]`, issues);
182
+ }
183
+ }
184
+ async function lintResolvedLesson(lesson, at, issues) {
185
+ checkUniqueIds(lesson.lesson.articles ?? [], `${at}.articles`, issues);
186
+ checkUniqueIds(lesson.lesson.video_lessons ?? [], `${at}.video_lessons`, issues);
187
+ checkUniqueIds(lesson.lesson.practice_sets ?? [], `${at}.practice_sets`, issues);
188
+ await lintPracticeSets(lesson.practice_sets, at, issues);
189
+ }
190
+ async function lintResolvedModule(module_, at, issues) {
191
+ checkUniqueIds(module_.module.lessons, `${at}.lessons`, issues);
192
+ for (const [i, lesson] of module_.lessons.entries()) {
193
+ await lintResolvedLesson(lesson, `${at}.lessons[${i}]`, issues);
194
+ }
195
+ }
196
+ async function lintResolvedCourse(course, issues) {
197
+ checkUniqueIds(course.course.modules, "course.modules", issues);
198
+ for (const [i, module_] of course.modules.entries()) {
199
+ await lintResolvedModule(module_, `course.modules[${i}]`, issues);
200
+ }
201
+ }
202
+
203
+ // src/index.ts
204
+ async function lintCourse(source, location = "course.json") {
205
+ const { data, errors } = await resolveCourse(source, location);
206
+ const issues = resolveErrorsToIssues(errors);
207
+ if (data) await lintResolvedCourse(data, issues);
208
+ return lintResult(issues);
209
+ }
210
+ async function lintSet(source, location = "set.json") {
211
+ const { data, errors } = await resolveSet(source, location);
212
+ const issues = resolveErrorsToIssues(errors);
213
+ if (data) await lintSetContent(data.set, data.questions, "set", issues);
214
+ return lintResult(issues);
215
+ }
216
+ async function lintLesson(source, location = "lesson.json") {
217
+ const { data, errors } = await resolveLesson(source, location);
218
+ const issues = resolveErrorsToIssues(errors);
219
+ if (data) await lintResolvedLesson(data, "lesson", issues);
220
+ return lintResult(issues);
221
+ }
222
+ async function lintModule(source, location = "module.json") {
223
+ const { data, errors } = await resolveModule(source, location);
224
+ const issues = resolveErrorsToIssues(errors);
225
+ if (data) await lintResolvedModule(data, "module", issues);
226
+ return lintResult(issues);
227
+ }
228
+
229
+ // src/cli.ts
230
+ var ENTRY_FILES = ["course.json", "set.json", "module.json", "lesson.json"];
231
+ function isEntryFile(value) {
232
+ return ENTRY_FILES.includes(value);
233
+ }
234
+ function printUsage() {
235
+ console.error("Usage: oes lint <path> [--entry course.json|set.json|module.json|lesson.json] [--json]");
236
+ }
237
+ function detectEntry(dir) {
238
+ return ENTRY_FILES.find((file) => existsSync(join(dir, file)));
239
+ }
240
+ function runLint(entry, source) {
241
+ switch (entry) {
242
+ case "course.json":
243
+ return lintCourse(source, entry);
244
+ case "set.json":
245
+ return lintSet(source, entry);
246
+ case "module.json":
247
+ return lintModule(source, entry);
248
+ case "lesson.json":
249
+ return lintLesson(source, entry);
250
+ }
251
+ }
252
+ function printHuman(dir, entry, result) {
253
+ if (result.issues.length === 0) {
254
+ console.log(`\u2713 ${dir} (${entry}) \u2014 no issues found`);
255
+ return;
256
+ }
257
+ for (const issue of result.issues) {
258
+ const tag = issue.severity === "error" ? "error" : "warn ";
259
+ console.log(`${tag} [${issue.rule}] ${issue.at}: ${issue.message}`);
260
+ }
261
+ const errorCount = result.issues.filter((issue) => issue.severity === "error").length;
262
+ const warningCount = result.issues.length - errorCount;
263
+ console.log(`
264
+ ${errorCount} error(s), ${warningCount} warning(s)`);
265
+ }
266
+ async function main() {
267
+ const args = process.argv.slice(2);
268
+ if (args[0] !== "lint") {
269
+ printUsage();
270
+ process.exitCode = 1;
271
+ return;
272
+ }
273
+ let dir;
274
+ let entryArg;
275
+ let json = false;
276
+ for (let i = 1; i < args.length; i++) {
277
+ const arg = args[i];
278
+ if (arg === "--entry") {
279
+ entryArg = args[++i];
280
+ } else if (arg === "--json") {
281
+ json = true;
282
+ } else if (dir === void 0) {
283
+ dir = arg;
284
+ } else {
285
+ printUsage();
286
+ process.exitCode = 1;
287
+ return;
288
+ }
289
+ }
290
+ if (dir === void 0) {
291
+ printUsage();
292
+ process.exitCode = 1;
293
+ return;
294
+ }
295
+ if (entryArg !== void 0 && !isEntryFile(entryArg)) {
296
+ console.error(`--entry must be one of: ${ENTRY_FILES.join(", ")}`);
297
+ process.exitCode = 1;
298
+ return;
299
+ }
300
+ const entry = entryArg ?? detectEntry(dir);
301
+ if (entry === void 0) {
302
+ console.error(`No ${ENTRY_FILES.join(", ")} found in ${dir}`);
303
+ process.exitCode = 1;
304
+ return;
305
+ }
306
+ const result = await runLint(entry, fsSource(dir));
307
+ if (json) {
308
+ console.log(JSON.stringify(result, null, 2));
309
+ } else {
310
+ printHuman(dir, entry, result);
311
+ }
312
+ process.exitCode = result.ok ? 0 : 1;
313
+ }
314
+ main().catch((cause) => {
315
+ console.error(cause);
316
+ process.exitCode = 1;
317
+ });
318
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts","../src/index.ts","../src/types.ts","../src/tree.ts","../src/rules/answer-references.ts","../src/rules/answer-key-file.ts","../src/rules/unique-ids.ts","../src/question.ts","../src/rules/pool-bounds.ts"],"sourcesContent":["import { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { fsSource } from \"@inklyre/oes-core\";\nimport type { LintResult } from \"./types.js\";\nimport { lintCourse, lintLesson, lintModule, lintSet } from \"./index.js\";\n\nconst ENTRY_FILES = [\"course.json\", \"set.json\", \"module.json\", \"lesson.json\"] as const;\ntype EntryFile = (typeof ENTRY_FILES)[number];\n\nfunction isEntryFile(value: string): value is EntryFile {\n return (ENTRY_FILES as readonly string[]).includes(value);\n}\n\nfunction printUsage(): void {\n console.error(\"Usage: oes lint <path> [--entry course.json|set.json|module.json|lesson.json] [--json]\");\n}\n\nfunction detectEntry(dir: string): EntryFile | undefined {\n return ENTRY_FILES.find((file) => existsSync(join(dir, file)));\n}\n\nfunction runLint(entry: EntryFile, source: ReturnType<typeof fsSource>): Promise<LintResult> {\n switch (entry) {\n case \"course.json\":\n return lintCourse(source, entry);\n case \"set.json\":\n return lintSet(source, entry);\n case \"module.json\":\n return lintModule(source, entry);\n case \"lesson.json\":\n return lintLesson(source, entry);\n }\n}\n\nfunction printHuman(dir: string, entry: EntryFile, result: LintResult): void {\n if (result.issues.length === 0) {\n console.log(`✓ ${dir} (${entry}) — no issues found`);\n return;\n }\n for (const issue of result.issues) {\n const tag = issue.severity === \"error\" ? \"error\" : \"warn \";\n console.log(`${tag} [${issue.rule}] ${issue.at}: ${issue.message}`);\n }\n const errorCount = result.issues.filter((issue) => issue.severity === \"error\").length;\n const warningCount = result.issues.length - errorCount;\n console.log(`\\n${errorCount} error(s), ${warningCount} warning(s)`);\n}\n\nasync function main(): Promise<void> {\n const args = process.argv.slice(2);\n if (args[0] !== \"lint\") {\n printUsage();\n process.exitCode = 1;\n return;\n }\n\n let dir: string | undefined;\n let entryArg: string | undefined;\n let json = false;\n for (let i = 1; i < args.length; i++) {\n const arg = args[i];\n if (arg === \"--entry\") {\n entryArg = args[++i];\n } else if (arg === \"--json\") {\n json = true;\n } else if (dir === undefined) {\n dir = arg;\n } else {\n printUsage();\n process.exitCode = 1;\n return;\n }\n }\n\n if (dir === undefined) {\n printUsage();\n process.exitCode = 1;\n return;\n }\n if (entryArg !== undefined && !isEntryFile(entryArg)) {\n console.error(`--entry must be one of: ${ENTRY_FILES.join(\", \")}`);\n process.exitCode = 1;\n return;\n }\n\n const entry = entryArg ?? detectEntry(dir);\n if (entry === undefined) {\n console.error(`No ${ENTRY_FILES.join(\", \")} found in ${dir}`);\n process.exitCode = 1;\n return;\n }\n\n const result = await runLint(entry, fsSource(dir));\n\n if (json) {\n console.log(JSON.stringify(result, null, 2));\n } else {\n printHuman(dir, entry, result);\n }\n process.exitCode = result.ok ? 0 : 1;\n}\n\nmain().catch((cause) => {\n console.error(cause);\n process.exitCode = 1;\n});\n","import type { ContentSource } from \"@inklyre/oes-core\";\nimport { resolveCourse, resolveLesson, resolveModule, resolveSet } from \"@inklyre/oes-core\";\nimport type { LintIssue, LintResult } from \"./types.js\";\nimport { lintResult } from \"./types.js\";\nimport { lintResolvedCourse, lintResolvedLesson, lintResolvedModule, lintSetContent, resolveErrorsToIssues } from \"./tree.js\";\n\nexport type { LintIssue, LintResult, LintRule, LintSeverity } from \"./types.js\";\n\n/**\n * Lint a full course tree from a `course.json` entry point: every check\n * `resolve()` already makes (dangling references, `content_hash`\n * mismatches) plus everything only a whole-tree view can catch —\n * duplicate ids, pool `select` bounds, dangling answer cross-references,\n * `answer_key` file existence.\n */\nexport async function lintCourse(source: ContentSource, location = \"course.json\"): Promise<LintResult> {\n const { data, errors } = await resolveCourse(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedCourse(data, issues);\n return lintResult(issues);\n}\n\n/** Lint a single practice set from a `set.json` entry point — for a set-only repo, outside any course. */\nexport async function lintSet(source: ContentSource, location = \"set.json\"): Promise<LintResult> {\n const { data, errors } = await resolveSet(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintSetContent(data.set, data.questions, \"set\", issues);\n return lintResult(issues);\n}\n\n/** Lint a single lesson from a `lesson.json` entry point — for previewing/authoring one lesson in isolation. */\nexport async function lintLesson(source: ContentSource, location = \"lesson.json\"): Promise<LintResult> {\n const { data, errors } = await resolveLesson(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedLesson(data, \"lesson\", issues);\n return lintResult(issues);\n}\n\n/** Lint a single module from a `module.json` entry point — for previewing/authoring one module in isolation. */\nexport async function lintModule(source: ContentSource, location = \"module.json\"): Promise<LintResult> {\n const { data, errors } = await resolveModule(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedModule(data, \"module\", issues);\n return lintResult(issues);\n}\n","export type LintSeverity = \"error\" | \"warning\";\n\n/** Every check this package runs — used as `LintIssue.rule`, and as the rule name printed in CLI output. */\nexport type LintRule =\n | \"dangling-reference\"\n | \"content-hash-mismatch\"\n | \"duplicate-id\"\n | \"pool-select-bounds\"\n | \"dangling-answer-reference\"\n | \"answer-key-file-missing\";\n\nexport interface LintIssue {\n rule: LintRule;\n severity: LintSeverity;\n /** Where in the tree this was found, e.g. `\"course.modules[0].lessons[1].practice_sets[0].questions[id=q1]\"`. */\n at: string;\n message: string;\n}\n\nexport interface LintResult {\n issues: LintIssue[];\n /** `false` iff at least one `\"error\"`-severity issue was found — a `\"warning\"` alone doesn't fail a lint run. */\n ok: boolean;\n}\n\nexport function lintResult(issues: LintIssue[]): LintResult {\n return { issues, ok: !issues.some((issue) => issue.severity === \"error\") };\n}\n","import { isOpfPoolEntry } from \"@inklyre/oes-core\";\nimport type {\n OpfSet,\n ResolvedCourse,\n ResolvedLesson,\n ResolvedModule,\n ResolvedPracticeSet,\n ResolvedQuestion,\n ResolveError,\n} from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"./types.js\";\nimport { lintQuestion } from \"./question.js\";\nimport { checkPoolBounds } from \"./rules/pool-bounds.js\";\nimport { checkUniqueIds } from \"./rules/unique-ids.js\";\n\n/** `resolveCourse`/`resolveSet`'s own errors (dangling references, `content_hash` mismatches) — already-detected issues, just relabeled into this package's shape. */\nexport function resolveErrorsToIssues(errors: ResolveError[]): LintIssue[] {\n return errors.map(\n (error): LintIssue => ({\n rule: error.message.includes(\"content_hash mismatch\") ? \"content-hash-mismatch\" : \"dangling-reference\",\n severity: \"error\",\n at: error.at,\n message: error.message,\n })\n );\n}\n\nexport async function lintSetContent(set: OpfSet, questions: ResolvedQuestion[], at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(set.questions, `${at}.questions`, issues);\n set.questions.forEach((entry, i) => {\n if (isOpfPoolEntry(entry)) checkPoolBounds(entry, `${at}.questions[${i}]`, issues);\n });\n for (const question of questions) {\n await lintQuestion(question, `${at}.questions[id=${question.id}]`, issues);\n }\n}\n\nasync function lintPracticeSets(sets: ResolvedPracticeSet[], at: string, issues: LintIssue[]): Promise<void> {\n for (const [i, set] of sets.entries()) {\n await lintSetContent(set.set, set.questions, `${at}.practice_sets[${i}]`, issues);\n }\n}\n\nexport async function lintResolvedLesson(lesson: ResolvedLesson, at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(lesson.lesson.articles ?? [], `${at}.articles`, issues);\n checkUniqueIds(lesson.lesson.video_lessons ?? [], `${at}.video_lessons`, issues);\n checkUniqueIds(lesson.lesson.practice_sets ?? [], `${at}.practice_sets`, issues);\n await lintPracticeSets(lesson.practice_sets, at, issues);\n}\n\nexport async function lintResolvedModule(module_: ResolvedModule, at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(module_.module.lessons, `${at}.lessons`, issues);\n for (const [i, lesson] of module_.lessons.entries()) {\n await lintResolvedLesson(lesson, `${at}.lessons[${i}]`, issues);\n }\n}\n\nexport async function lintResolvedCourse(course: ResolvedCourse, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(course.course.modules, \"course.modules\", issues);\n for (const [i, module_] of course.modules.entries()) {\n await lintResolvedModule(module_, `course.modules[${i}]`, issues);\n }\n}\n","import type { LintIssue } from \"../types.js\";\n\n/** Flag `id` if it isn't one of `pool`'s ids — e.g. an `mcq`'s `answer` pointing at a nonexistent option. */\nexport function checkIdExists(id: string, pool: { id: string }[], at: string, issues: LintIssue[]): void {\n if (!pool.some((item) => item.id === id)) {\n issues.push({\n rule: \"dangling-answer-reference\",\n severity: \"error\",\n at,\n message: `References id \"${id}\", which does not exist among [${pool.map((i) => i.id).join(\", \")}]`,\n });\n }\n}\n\n/** Flag `order` if it isn't exactly a permutation of `items`' ids — every id present once, none missing, none extra. */\nexport function checkPermutation(order: string[], items: { id: string }[], at: string, issues: LintIssue[]): void {\n const itemIds = items.map((item) => item.id);\n const itemIdSet = new Set(itemIds);\n const orderIdSet = new Set(order);\n const isValid =\n order.length === itemIds.length &&\n orderIdSet.size === order.length &&\n [...orderIdSet].every((id) => itemIdSet.has(id));\n if (!isValid) {\n issues.push({\n rule: \"dangling-answer-reference\",\n severity: \"error\",\n at,\n message: `Must be exactly a permutation of [${itemIds.join(\", \")}], got [${order.join(\", \")}]`,\n });\n }\n}\n","import type { ResolvedQuestion } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"../types.js\";\n\n/**\n * When `answer_key.file` is set, confirm the file actually exists —\n * independent of whether `resolve()` ever fetches it, since it\n * deliberately doesn't (a public/student-facing consumer built on\n * `resolveCourse`/`resolveSet` must never gain a path to the answer\n * through the shared resolver). Uses the question's own re-rooted\n * `source`, exposed on {@link ResolvedQuestion} for exactly this purpose.\n */\nexport async function checkAnswerKeyFile(resolved: ResolvedQuestion, at: string, issues: LintIssue[]): Promise<void> {\n const file = resolved.question.answer_key?.file;\n if (file === undefined) return;\n try {\n await resolved.source.fetch(file);\n } catch (cause) {\n issues.push({\n rule: \"answer-key-file-missing\",\n severity: \"error\",\n at: `${at}.answer_key`,\n message: `answer_key.file \"${file}\" could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,\n });\n }\n}\n","import type { LintIssue } from \"../types.js\";\n\n/**\n * Flag any id repeated within `items` — something no JSON Schema in this\n * repo enforces (schema validation is per-document, not \"unique across\n * this array\"), and a real authoring mistake wherever it happens: two\n * modules/lessons/options/pool candidates/etc. sharing one id makes that\n * id ambiguous to anything addressing by it.\n */\nexport function checkUniqueIds(items: { id: string }[], at: string, issues: LintIssue[]): void {\n const firstSeenAt = new Map<string, number>();\n items.forEach((item, i) => {\n const firstIndex = firstSeenAt.get(item.id);\n if (firstIndex !== undefined) {\n issues.push({\n rule: \"duplicate-id\",\n severity: \"error\",\n at: `${at}[${i}]`,\n message: `Duplicate id \"${item.id}\" (first seen at ${at}[${firstIndex}])`,\n });\n } else {\n firstSeenAt.set(item.id, i);\n }\n });\n}\n","import type { ResolvedQuestion } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"./types.js\";\nimport { checkIdExists, checkPermutation } from \"./rules/answer-references.js\";\nimport { checkAnswerKeyFile } from \"./rules/answer-key-file.js\";\nimport { checkUniqueIds } from \"./rules/unique-ids.js\";\n\n/**\n * Run every question-level rule: duplicate ids within the question's own\n * option/item/etc. arrays, dangling answer cross-references (only\n * meaningful in self-practice mode — a secured `answer_key` means the\n * answer isn't inline to check), and `answer_key.file` existence.\n */\nexport async function lintQuestion(resolved: ResolvedQuestion, at: string, issues: LintIssue[]): Promise<void> {\n const { question } = resolved;\n const typeConfigAt = `${at}.type_config`;\n const isSecured = question.answer_key !== undefined;\n\n switch (question.type) {\n case \"mcq\": {\n const { options, answer } = question.type_config;\n checkUniqueIds(options, `${typeConfigAt}.options`, issues);\n if (!isSecured && answer !== undefined) {\n checkIdExists(answer, options, `${typeConfigAt}.answer`, issues);\n }\n break;\n }\n case \"msq\": {\n const { options, answers } = question.type_config;\n checkUniqueIds(options, `${typeConfigAt}.options`, issues);\n if (!isSecured) {\n (answers ?? []).forEach((answer, i) => checkIdExists(answer, options, `${typeConfigAt}.answers[${i}]`, issues));\n }\n break;\n }\n case \"match\": {\n const { left, right, pairs } = question.type_config;\n checkUniqueIds(left, `${typeConfigAt}.left`, issues);\n checkUniqueIds(right, `${typeConfigAt}.right`, issues);\n if (!isSecured) {\n (pairs ?? []).forEach((pair, i) => {\n checkIdExists(pair.left_id, left, `${typeConfigAt}.pairs[${i}].left_id`, issues);\n checkIdExists(pair.right_id, right, `${typeConfigAt}.pairs[${i}].right_id`, issues);\n });\n }\n break;\n }\n case \"order\": {\n const { items, correct_order } = question.type_config;\n checkUniqueIds(items, `${typeConfigAt}.items`, issues);\n if (!isSecured && correct_order !== undefined) {\n checkPermutation(correct_order, items, `${typeConfigAt}.correct_order`, issues);\n }\n break;\n }\n case \"fill_blank\":\n checkUniqueIds(question.type_config.blanks, `${typeConfigAt}.blanks`, issues);\n break;\n case \"code\":\n checkUniqueIds(question.type_config.test_cases, `${typeConfigAt}.test_cases`, issues);\n break;\n case \"diagram\":\n checkUniqueIds(question.type_config.labels, `${typeConfigAt}.labels`, issues);\n break;\n case \"numerical\":\n case \"short_answer\":\n case \"essay\":\n case \"submission\":\n break;\n }\n\n await checkAnswerKeyFile(resolved, at, issues);\n}\n","import type { OpfPoolQuestionEntry } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"../types.js\";\nimport { checkUniqueIds } from \"./unique-ids.js\";\n\n/**\n * `select` must be between 1 and `from.length` — the schema can't enforce\n * this itself, since it's a relationship between two sibling fields\n * (see `OpfPoolQuestionEntry.select`'s own doc comment).\n */\nexport function checkPoolBounds(entry: OpfPoolQuestionEntry, at: string, issues: LintIssue[]): void {\n if (entry.select < 1) {\n issues.push({\n rule: \"pool-select-bounds\",\n severity: \"error\",\n at,\n message: `select (${entry.select}) must be at least 1`,\n });\n } else if (entry.select > entry.from.length) {\n issues.push({\n rule: \"pool-select-bounds\",\n severity: \"error\",\n at,\n message: `select (${entry.select}) exceeds from.length (${entry.from.length})`,\n });\n }\n checkUniqueIds(entry.from, `${at}.from`, issues);\n}\n"],"mappings":"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","names":[]}
package/dist/index.cjs ADDED
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ lintCourse: () => lintCourse,
24
+ lintLesson: () => lintLesson,
25
+ lintModule: () => lintModule,
26
+ lintSet: () => lintSet
27
+ });
28
+ module.exports = __toCommonJS(src_exports);
29
+ var import_oes_core2 = require("@inklyre/oes-core");
30
+
31
+ // src/types.ts
32
+ function lintResult(issues) {
33
+ return { issues, ok: !issues.some((issue) => issue.severity === "error") };
34
+ }
35
+
36
+ // src/tree.ts
37
+ var import_oes_core = require("@inklyre/oes-core");
38
+
39
+ // src/rules/answer-references.ts
40
+ function checkIdExists(id, pool, at, issues) {
41
+ if (!pool.some((item) => item.id === id)) {
42
+ issues.push({
43
+ rule: "dangling-answer-reference",
44
+ severity: "error",
45
+ at,
46
+ message: `References id "${id}", which does not exist among [${pool.map((i) => i.id).join(", ")}]`
47
+ });
48
+ }
49
+ }
50
+ function checkPermutation(order, items, at, issues) {
51
+ const itemIds = items.map((item) => item.id);
52
+ const itemIdSet = new Set(itemIds);
53
+ const orderIdSet = new Set(order);
54
+ const isValid = order.length === itemIds.length && orderIdSet.size === order.length && [...orderIdSet].every((id) => itemIdSet.has(id));
55
+ if (!isValid) {
56
+ issues.push({
57
+ rule: "dangling-answer-reference",
58
+ severity: "error",
59
+ at,
60
+ message: `Must be exactly a permutation of [${itemIds.join(", ")}], got [${order.join(", ")}]`
61
+ });
62
+ }
63
+ }
64
+
65
+ // src/rules/answer-key-file.ts
66
+ async function checkAnswerKeyFile(resolved, at, issues) {
67
+ const file = resolved.question.answer_key?.file;
68
+ if (file === void 0) return;
69
+ try {
70
+ await resolved.source.fetch(file);
71
+ } catch (cause) {
72
+ issues.push({
73
+ rule: "answer-key-file-missing",
74
+ severity: "error",
75
+ at: `${at}.answer_key`,
76
+ message: `answer_key.file "${file}" could not be read: ${cause instanceof Error ? cause.message : String(cause)}`
77
+ });
78
+ }
79
+ }
80
+
81
+ // src/rules/unique-ids.ts
82
+ function checkUniqueIds(items, at, issues) {
83
+ const firstSeenAt = /* @__PURE__ */ new Map();
84
+ items.forEach((item, i) => {
85
+ const firstIndex = firstSeenAt.get(item.id);
86
+ if (firstIndex !== void 0) {
87
+ issues.push({
88
+ rule: "duplicate-id",
89
+ severity: "error",
90
+ at: `${at}[${i}]`,
91
+ message: `Duplicate id "${item.id}" (first seen at ${at}[${firstIndex}])`
92
+ });
93
+ } else {
94
+ firstSeenAt.set(item.id, i);
95
+ }
96
+ });
97
+ }
98
+
99
+ // src/question.ts
100
+ async function lintQuestion(resolved, at, issues) {
101
+ const { question } = resolved;
102
+ const typeConfigAt = `${at}.type_config`;
103
+ const isSecured = question.answer_key !== void 0;
104
+ switch (question.type) {
105
+ case "mcq": {
106
+ const { options, answer } = question.type_config;
107
+ checkUniqueIds(options, `${typeConfigAt}.options`, issues);
108
+ if (!isSecured && answer !== void 0) {
109
+ checkIdExists(answer, options, `${typeConfigAt}.answer`, issues);
110
+ }
111
+ break;
112
+ }
113
+ case "msq": {
114
+ const { options, answers } = question.type_config;
115
+ checkUniqueIds(options, `${typeConfigAt}.options`, issues);
116
+ if (!isSecured) {
117
+ (answers ?? []).forEach((answer, i) => checkIdExists(answer, options, `${typeConfigAt}.answers[${i}]`, issues));
118
+ }
119
+ break;
120
+ }
121
+ case "match": {
122
+ const { left, right, pairs } = question.type_config;
123
+ checkUniqueIds(left, `${typeConfigAt}.left`, issues);
124
+ checkUniqueIds(right, `${typeConfigAt}.right`, issues);
125
+ if (!isSecured) {
126
+ (pairs ?? []).forEach((pair, i) => {
127
+ checkIdExists(pair.left_id, left, `${typeConfigAt}.pairs[${i}].left_id`, issues);
128
+ checkIdExists(pair.right_id, right, `${typeConfigAt}.pairs[${i}].right_id`, issues);
129
+ });
130
+ }
131
+ break;
132
+ }
133
+ case "order": {
134
+ const { items, correct_order } = question.type_config;
135
+ checkUniqueIds(items, `${typeConfigAt}.items`, issues);
136
+ if (!isSecured && correct_order !== void 0) {
137
+ checkPermutation(correct_order, items, `${typeConfigAt}.correct_order`, issues);
138
+ }
139
+ break;
140
+ }
141
+ case "fill_blank":
142
+ checkUniqueIds(question.type_config.blanks, `${typeConfigAt}.blanks`, issues);
143
+ break;
144
+ case "code":
145
+ checkUniqueIds(question.type_config.test_cases, `${typeConfigAt}.test_cases`, issues);
146
+ break;
147
+ case "diagram":
148
+ checkUniqueIds(question.type_config.labels, `${typeConfigAt}.labels`, issues);
149
+ break;
150
+ case "numerical":
151
+ case "short_answer":
152
+ case "essay":
153
+ case "submission":
154
+ break;
155
+ }
156
+ await checkAnswerKeyFile(resolved, at, issues);
157
+ }
158
+
159
+ // src/rules/pool-bounds.ts
160
+ function checkPoolBounds(entry, at, issues) {
161
+ if (entry.select < 1) {
162
+ issues.push({
163
+ rule: "pool-select-bounds",
164
+ severity: "error",
165
+ at,
166
+ message: `select (${entry.select}) must be at least 1`
167
+ });
168
+ } else if (entry.select > entry.from.length) {
169
+ issues.push({
170
+ rule: "pool-select-bounds",
171
+ severity: "error",
172
+ at,
173
+ message: `select (${entry.select}) exceeds from.length (${entry.from.length})`
174
+ });
175
+ }
176
+ checkUniqueIds(entry.from, `${at}.from`, issues);
177
+ }
178
+
179
+ // src/tree.ts
180
+ function resolveErrorsToIssues(errors) {
181
+ return errors.map(
182
+ (error) => ({
183
+ rule: error.message.includes("content_hash mismatch") ? "content-hash-mismatch" : "dangling-reference",
184
+ severity: "error",
185
+ at: error.at,
186
+ message: error.message
187
+ })
188
+ );
189
+ }
190
+ async function lintSetContent(set, questions, at, issues) {
191
+ checkUniqueIds(set.questions, `${at}.questions`, issues);
192
+ set.questions.forEach((entry, i) => {
193
+ if ((0, import_oes_core.isOpfPoolEntry)(entry)) checkPoolBounds(entry, `${at}.questions[${i}]`, issues);
194
+ });
195
+ for (const question of questions) {
196
+ await lintQuestion(question, `${at}.questions[id=${question.id}]`, issues);
197
+ }
198
+ }
199
+ async function lintPracticeSets(sets, at, issues) {
200
+ for (const [i, set] of sets.entries()) {
201
+ await lintSetContent(set.set, set.questions, `${at}.practice_sets[${i}]`, issues);
202
+ }
203
+ }
204
+ async function lintResolvedLesson(lesson, at, issues) {
205
+ checkUniqueIds(lesson.lesson.articles ?? [], `${at}.articles`, issues);
206
+ checkUniqueIds(lesson.lesson.video_lessons ?? [], `${at}.video_lessons`, issues);
207
+ checkUniqueIds(lesson.lesson.practice_sets ?? [], `${at}.practice_sets`, issues);
208
+ await lintPracticeSets(lesson.practice_sets, at, issues);
209
+ }
210
+ async function lintResolvedModule(module_, at, issues) {
211
+ checkUniqueIds(module_.module.lessons, `${at}.lessons`, issues);
212
+ for (const [i, lesson] of module_.lessons.entries()) {
213
+ await lintResolvedLesson(lesson, `${at}.lessons[${i}]`, issues);
214
+ }
215
+ }
216
+ async function lintResolvedCourse(course, issues) {
217
+ checkUniqueIds(course.course.modules, "course.modules", issues);
218
+ for (const [i, module_] of course.modules.entries()) {
219
+ await lintResolvedModule(module_, `course.modules[${i}]`, issues);
220
+ }
221
+ }
222
+
223
+ // src/index.ts
224
+ async function lintCourse(source, location = "course.json") {
225
+ const { data, errors } = await (0, import_oes_core2.resolveCourse)(source, location);
226
+ const issues = resolveErrorsToIssues(errors);
227
+ if (data) await lintResolvedCourse(data, issues);
228
+ return lintResult(issues);
229
+ }
230
+ async function lintSet(source, location = "set.json") {
231
+ const { data, errors } = await (0, import_oes_core2.resolveSet)(source, location);
232
+ const issues = resolveErrorsToIssues(errors);
233
+ if (data) await lintSetContent(data.set, data.questions, "set", issues);
234
+ return lintResult(issues);
235
+ }
236
+ async function lintLesson(source, location = "lesson.json") {
237
+ const { data, errors } = await (0, import_oes_core2.resolveLesson)(source, location);
238
+ const issues = resolveErrorsToIssues(errors);
239
+ if (data) await lintResolvedLesson(data, "lesson", issues);
240
+ return lintResult(issues);
241
+ }
242
+ async function lintModule(source, location = "module.json") {
243
+ const { data, errors } = await (0, import_oes_core2.resolveModule)(source, location);
244
+ const issues = resolveErrorsToIssues(errors);
245
+ if (data) await lintResolvedModule(data, "module", issues);
246
+ return lintResult(issues);
247
+ }
248
+ // Annotate the CommonJS export names for ESM import in node:
249
+ 0 && (module.exports = {
250
+ lintCourse,
251
+ lintLesson,
252
+ lintModule,
253
+ lintSet
254
+ });
255
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/types.ts","../src/tree.ts","../src/rules/answer-references.ts","../src/rules/answer-key-file.ts","../src/rules/unique-ids.ts","../src/question.ts","../src/rules/pool-bounds.ts"],"sourcesContent":["import type { ContentSource } from \"@inklyre/oes-core\";\nimport { resolveCourse, resolveLesson, resolveModule, resolveSet } from \"@inklyre/oes-core\";\nimport type { LintIssue, LintResult } from \"./types.js\";\nimport { lintResult } from \"./types.js\";\nimport { lintResolvedCourse, lintResolvedLesson, lintResolvedModule, lintSetContent, resolveErrorsToIssues } from \"./tree.js\";\n\nexport type { LintIssue, LintResult, LintRule, LintSeverity } from \"./types.js\";\n\n/**\n * Lint a full course tree from a `course.json` entry point: every check\n * `resolve()` already makes (dangling references, `content_hash`\n * mismatches) plus everything only a whole-tree view can catch —\n * duplicate ids, pool `select` bounds, dangling answer cross-references,\n * `answer_key` file existence.\n */\nexport async function lintCourse(source: ContentSource, location = \"course.json\"): Promise<LintResult> {\n const { data, errors } = await resolveCourse(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedCourse(data, issues);\n return lintResult(issues);\n}\n\n/** Lint a single practice set from a `set.json` entry point — for a set-only repo, outside any course. */\nexport async function lintSet(source: ContentSource, location = \"set.json\"): Promise<LintResult> {\n const { data, errors } = await resolveSet(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintSetContent(data.set, data.questions, \"set\", issues);\n return lintResult(issues);\n}\n\n/** Lint a single lesson from a `lesson.json` entry point — for previewing/authoring one lesson in isolation. */\nexport async function lintLesson(source: ContentSource, location = \"lesson.json\"): Promise<LintResult> {\n const { data, errors } = await resolveLesson(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedLesson(data, \"lesson\", issues);\n return lintResult(issues);\n}\n\n/** Lint a single module from a `module.json` entry point — for previewing/authoring one module in isolation. */\nexport async function lintModule(source: ContentSource, location = \"module.json\"): Promise<LintResult> {\n const { data, errors } = await resolveModule(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedModule(data, \"module\", issues);\n return lintResult(issues);\n}\n","export type LintSeverity = \"error\" | \"warning\";\n\n/** Every check this package runs — used as `LintIssue.rule`, and as the rule name printed in CLI output. */\nexport type LintRule =\n | \"dangling-reference\"\n | \"content-hash-mismatch\"\n | \"duplicate-id\"\n | \"pool-select-bounds\"\n | \"dangling-answer-reference\"\n | \"answer-key-file-missing\";\n\nexport interface LintIssue {\n rule: LintRule;\n severity: LintSeverity;\n /** Where in the tree this was found, e.g. `\"course.modules[0].lessons[1].practice_sets[0].questions[id=q1]\"`. */\n at: string;\n message: string;\n}\n\nexport interface LintResult {\n issues: LintIssue[];\n /** `false` iff at least one `\"error\"`-severity issue was found — a `\"warning\"` alone doesn't fail a lint run. */\n ok: boolean;\n}\n\nexport function lintResult(issues: LintIssue[]): LintResult {\n return { issues, ok: !issues.some((issue) => issue.severity === \"error\") };\n}\n","import { isOpfPoolEntry } from \"@inklyre/oes-core\";\nimport type {\n OpfSet,\n ResolvedCourse,\n ResolvedLesson,\n ResolvedModule,\n ResolvedPracticeSet,\n ResolvedQuestion,\n ResolveError,\n} from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"./types.js\";\nimport { lintQuestion } from \"./question.js\";\nimport { checkPoolBounds } from \"./rules/pool-bounds.js\";\nimport { checkUniqueIds } from \"./rules/unique-ids.js\";\n\n/** `resolveCourse`/`resolveSet`'s own errors (dangling references, `content_hash` mismatches) — already-detected issues, just relabeled into this package's shape. */\nexport function resolveErrorsToIssues(errors: ResolveError[]): LintIssue[] {\n return errors.map(\n (error): LintIssue => ({\n rule: error.message.includes(\"content_hash mismatch\") ? \"content-hash-mismatch\" : \"dangling-reference\",\n severity: \"error\",\n at: error.at,\n message: error.message,\n })\n );\n}\n\nexport async function lintSetContent(set: OpfSet, questions: ResolvedQuestion[], at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(set.questions, `${at}.questions`, issues);\n set.questions.forEach((entry, i) => {\n if (isOpfPoolEntry(entry)) checkPoolBounds(entry, `${at}.questions[${i}]`, issues);\n });\n for (const question of questions) {\n await lintQuestion(question, `${at}.questions[id=${question.id}]`, issues);\n }\n}\n\nasync function lintPracticeSets(sets: ResolvedPracticeSet[], at: string, issues: LintIssue[]): Promise<void> {\n for (const [i, set] of sets.entries()) {\n await lintSetContent(set.set, set.questions, `${at}.practice_sets[${i}]`, issues);\n }\n}\n\nexport async function lintResolvedLesson(lesson: ResolvedLesson, at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(lesson.lesson.articles ?? [], `${at}.articles`, issues);\n checkUniqueIds(lesson.lesson.video_lessons ?? [], `${at}.video_lessons`, issues);\n checkUniqueIds(lesson.lesson.practice_sets ?? [], `${at}.practice_sets`, issues);\n await lintPracticeSets(lesson.practice_sets, at, issues);\n}\n\nexport async function lintResolvedModule(module_: ResolvedModule, at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(module_.module.lessons, `${at}.lessons`, issues);\n for (const [i, lesson] of module_.lessons.entries()) {\n await lintResolvedLesson(lesson, `${at}.lessons[${i}]`, issues);\n }\n}\n\nexport async function lintResolvedCourse(course: ResolvedCourse, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(course.course.modules, \"course.modules\", issues);\n for (const [i, module_] of course.modules.entries()) {\n await lintResolvedModule(module_, `course.modules[${i}]`, issues);\n }\n}\n","import type { LintIssue } from \"../types.js\";\n\n/** Flag `id` if it isn't one of `pool`'s ids — e.g. an `mcq`'s `answer` pointing at a nonexistent option. */\nexport function checkIdExists(id: string, pool: { id: string }[], at: string, issues: LintIssue[]): void {\n if (!pool.some((item) => item.id === id)) {\n issues.push({\n rule: \"dangling-answer-reference\",\n severity: \"error\",\n at,\n message: `References id \"${id}\", which does not exist among [${pool.map((i) => i.id).join(\", \")}]`,\n });\n }\n}\n\n/** Flag `order` if it isn't exactly a permutation of `items`' ids — every id present once, none missing, none extra. */\nexport function checkPermutation(order: string[], items: { id: string }[], at: string, issues: LintIssue[]): void {\n const itemIds = items.map((item) => item.id);\n const itemIdSet = new Set(itemIds);\n const orderIdSet = new Set(order);\n const isValid =\n order.length === itemIds.length &&\n orderIdSet.size === order.length &&\n [...orderIdSet].every((id) => itemIdSet.has(id));\n if (!isValid) {\n issues.push({\n rule: \"dangling-answer-reference\",\n severity: \"error\",\n at,\n message: `Must be exactly a permutation of [${itemIds.join(\", \")}], got [${order.join(\", \")}]`,\n });\n }\n}\n","import type { ResolvedQuestion } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"../types.js\";\n\n/**\n * When `answer_key.file` is set, confirm the file actually exists —\n * independent of whether `resolve()` ever fetches it, since it\n * deliberately doesn't (a public/student-facing consumer built on\n * `resolveCourse`/`resolveSet` must never gain a path to the answer\n * through the shared resolver). Uses the question's own re-rooted\n * `source`, exposed on {@link ResolvedQuestion} for exactly this purpose.\n */\nexport async function checkAnswerKeyFile(resolved: ResolvedQuestion, at: string, issues: LintIssue[]): Promise<void> {\n const file = resolved.question.answer_key?.file;\n if (file === undefined) return;\n try {\n await resolved.source.fetch(file);\n } catch (cause) {\n issues.push({\n rule: \"answer-key-file-missing\",\n severity: \"error\",\n at: `${at}.answer_key`,\n message: `answer_key.file \"${file}\" could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,\n });\n }\n}\n","import type { LintIssue } from \"../types.js\";\n\n/**\n * Flag any id repeated within `items` — something no JSON Schema in this\n * repo enforces (schema validation is per-document, not \"unique across\n * this array\"), and a real authoring mistake wherever it happens: two\n * modules/lessons/options/pool candidates/etc. sharing one id makes that\n * id ambiguous to anything addressing by it.\n */\nexport function checkUniqueIds(items: { id: string }[], at: string, issues: LintIssue[]): void {\n const firstSeenAt = new Map<string, number>();\n items.forEach((item, i) => {\n const firstIndex = firstSeenAt.get(item.id);\n if (firstIndex !== undefined) {\n issues.push({\n rule: \"duplicate-id\",\n severity: \"error\",\n at: `${at}[${i}]`,\n message: `Duplicate id \"${item.id}\" (first seen at ${at}[${firstIndex}])`,\n });\n } else {\n firstSeenAt.set(item.id, i);\n }\n });\n}\n","import type { ResolvedQuestion } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"./types.js\";\nimport { checkIdExists, checkPermutation } from \"./rules/answer-references.js\";\nimport { checkAnswerKeyFile } from \"./rules/answer-key-file.js\";\nimport { checkUniqueIds } from \"./rules/unique-ids.js\";\n\n/**\n * Run every question-level rule: duplicate ids within the question's own\n * option/item/etc. arrays, dangling answer cross-references (only\n * meaningful in self-practice mode — a secured `answer_key` means the\n * answer isn't inline to check), and `answer_key.file` existence.\n */\nexport async function lintQuestion(resolved: ResolvedQuestion, at: string, issues: LintIssue[]): Promise<void> {\n const { question } = resolved;\n const typeConfigAt = `${at}.type_config`;\n const isSecured = question.answer_key !== undefined;\n\n switch (question.type) {\n case \"mcq\": {\n const { options, answer } = question.type_config;\n checkUniqueIds(options, `${typeConfigAt}.options`, issues);\n if (!isSecured && answer !== undefined) {\n checkIdExists(answer, options, `${typeConfigAt}.answer`, issues);\n }\n break;\n }\n case \"msq\": {\n const { options, answers } = question.type_config;\n checkUniqueIds(options, `${typeConfigAt}.options`, issues);\n if (!isSecured) {\n (answers ?? []).forEach((answer, i) => checkIdExists(answer, options, `${typeConfigAt}.answers[${i}]`, issues));\n }\n break;\n }\n case \"match\": {\n const { left, right, pairs } = question.type_config;\n checkUniqueIds(left, `${typeConfigAt}.left`, issues);\n checkUniqueIds(right, `${typeConfigAt}.right`, issues);\n if (!isSecured) {\n (pairs ?? []).forEach((pair, i) => {\n checkIdExists(pair.left_id, left, `${typeConfigAt}.pairs[${i}].left_id`, issues);\n checkIdExists(pair.right_id, right, `${typeConfigAt}.pairs[${i}].right_id`, issues);\n });\n }\n break;\n }\n case \"order\": {\n const { items, correct_order } = question.type_config;\n checkUniqueIds(items, `${typeConfigAt}.items`, issues);\n if (!isSecured && correct_order !== undefined) {\n checkPermutation(correct_order, items, `${typeConfigAt}.correct_order`, issues);\n }\n break;\n }\n case \"fill_blank\":\n checkUniqueIds(question.type_config.blanks, `${typeConfigAt}.blanks`, issues);\n break;\n case \"code\":\n checkUniqueIds(question.type_config.test_cases, `${typeConfigAt}.test_cases`, issues);\n break;\n case \"diagram\":\n checkUniqueIds(question.type_config.labels, `${typeConfigAt}.labels`, issues);\n break;\n case \"numerical\":\n case \"short_answer\":\n case \"essay\":\n case \"submission\":\n break;\n }\n\n await checkAnswerKeyFile(resolved, at, issues);\n}\n","import type { OpfPoolQuestionEntry } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"../types.js\";\nimport { checkUniqueIds } from \"./unique-ids.js\";\n\n/**\n * `select` must be between 1 and `from.length` — the schema can't enforce\n * this itself, since it's a relationship between two sibling fields\n * (see `OpfPoolQuestionEntry.select`'s own doc comment).\n */\nexport function checkPoolBounds(entry: OpfPoolQuestionEntry, at: string, issues: LintIssue[]): void {\n if (entry.select < 1) {\n issues.push({\n rule: \"pool-select-bounds\",\n severity: \"error\",\n at,\n message: `select (${entry.select}) must be at least 1`,\n });\n } else if (entry.select > entry.from.length) {\n issues.push({\n rule: \"pool-select-bounds\",\n severity: \"error\",\n at,\n message: `select (${entry.select}) exceeds from.length (${entry.from.length})`,\n });\n }\n checkUniqueIds(entry.from, `${at}.from`, issues);\n}\n"],"mappings":"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","names":["import_oes_core"]}
@@ -0,0 +1,34 @@
1
+ import { ContentSource } from '@inklyre/oes-core';
2
+
3
+ type LintSeverity = "error" | "warning";
4
+ /** Every check this package runs — used as `LintIssue.rule`, and as the rule name printed in CLI output. */
5
+ type LintRule = "dangling-reference" | "content-hash-mismatch" | "duplicate-id" | "pool-select-bounds" | "dangling-answer-reference" | "answer-key-file-missing";
6
+ interface LintIssue {
7
+ rule: LintRule;
8
+ severity: LintSeverity;
9
+ /** Where in the tree this was found, e.g. `"course.modules[0].lessons[1].practice_sets[0].questions[id=q1]"`. */
10
+ at: string;
11
+ message: string;
12
+ }
13
+ interface LintResult {
14
+ issues: LintIssue[];
15
+ /** `false` iff at least one `"error"`-severity issue was found — a `"warning"` alone doesn't fail a lint run. */
16
+ ok: boolean;
17
+ }
18
+
19
+ /**
20
+ * Lint a full course tree from a `course.json` entry point: every check
21
+ * `resolve()` already makes (dangling references, `content_hash`
22
+ * mismatches) plus everything only a whole-tree view can catch —
23
+ * duplicate ids, pool `select` bounds, dangling answer cross-references,
24
+ * `answer_key` file existence.
25
+ */
26
+ declare function lintCourse(source: ContentSource, location?: string): Promise<LintResult>;
27
+ /** Lint a single practice set from a `set.json` entry point — for a set-only repo, outside any course. */
28
+ declare function lintSet(source: ContentSource, location?: string): Promise<LintResult>;
29
+ /** Lint a single lesson from a `lesson.json` entry point — for previewing/authoring one lesson in isolation. */
30
+ declare function lintLesson(source: ContentSource, location?: string): Promise<LintResult>;
31
+ /** Lint a single module from a `module.json` entry point — for previewing/authoring one module in isolation. */
32
+ declare function lintModule(source: ContentSource, location?: string): Promise<LintResult>;
33
+
34
+ export { type LintIssue, type LintResult, type LintRule, type LintSeverity, lintCourse, lintLesson, lintModule, lintSet };
@@ -0,0 +1,34 @@
1
+ import { ContentSource } from '@inklyre/oes-core';
2
+
3
+ type LintSeverity = "error" | "warning";
4
+ /** Every check this package runs — used as `LintIssue.rule`, and as the rule name printed in CLI output. */
5
+ type LintRule = "dangling-reference" | "content-hash-mismatch" | "duplicate-id" | "pool-select-bounds" | "dangling-answer-reference" | "answer-key-file-missing";
6
+ interface LintIssue {
7
+ rule: LintRule;
8
+ severity: LintSeverity;
9
+ /** Where in the tree this was found, e.g. `"course.modules[0].lessons[1].practice_sets[0].questions[id=q1]"`. */
10
+ at: string;
11
+ message: string;
12
+ }
13
+ interface LintResult {
14
+ issues: LintIssue[];
15
+ /** `false` iff at least one `"error"`-severity issue was found — a `"warning"` alone doesn't fail a lint run. */
16
+ ok: boolean;
17
+ }
18
+
19
+ /**
20
+ * Lint a full course tree from a `course.json` entry point: every check
21
+ * `resolve()` already makes (dangling references, `content_hash`
22
+ * mismatches) plus everything only a whole-tree view can catch —
23
+ * duplicate ids, pool `select` bounds, dangling answer cross-references,
24
+ * `answer_key` file existence.
25
+ */
26
+ declare function lintCourse(source: ContentSource, location?: string): Promise<LintResult>;
27
+ /** Lint a single practice set from a `set.json` entry point — for a set-only repo, outside any course. */
28
+ declare function lintSet(source: ContentSource, location?: string): Promise<LintResult>;
29
+ /** Lint a single lesson from a `lesson.json` entry point — for previewing/authoring one lesson in isolation. */
30
+ declare function lintLesson(source: ContentSource, location?: string): Promise<LintResult>;
31
+ /** Lint a single module from a `module.json` entry point — for previewing/authoring one module in isolation. */
32
+ declare function lintModule(source: ContentSource, location?: string): Promise<LintResult>;
33
+
34
+ export { type LintIssue, type LintResult, type LintRule, type LintSeverity, lintCourse, lintLesson, lintModule, lintSet };
package/dist/index.js ADDED
@@ -0,0 +1,227 @@
1
+ // src/index.ts
2
+ import { resolveCourse, resolveLesson, resolveModule, resolveSet } from "@inklyre/oes-core";
3
+
4
+ // src/types.ts
5
+ function lintResult(issues) {
6
+ return { issues, ok: !issues.some((issue) => issue.severity === "error") };
7
+ }
8
+
9
+ // src/tree.ts
10
+ import { isOpfPoolEntry } from "@inklyre/oes-core";
11
+
12
+ // src/rules/answer-references.ts
13
+ function checkIdExists(id, pool, at, issues) {
14
+ if (!pool.some((item) => item.id === id)) {
15
+ issues.push({
16
+ rule: "dangling-answer-reference",
17
+ severity: "error",
18
+ at,
19
+ message: `References id "${id}", which does not exist among [${pool.map((i) => i.id).join(", ")}]`
20
+ });
21
+ }
22
+ }
23
+ function checkPermutation(order, items, at, issues) {
24
+ const itemIds = items.map((item) => item.id);
25
+ const itemIdSet = new Set(itemIds);
26
+ const orderIdSet = new Set(order);
27
+ const isValid = order.length === itemIds.length && orderIdSet.size === order.length && [...orderIdSet].every((id) => itemIdSet.has(id));
28
+ if (!isValid) {
29
+ issues.push({
30
+ rule: "dangling-answer-reference",
31
+ severity: "error",
32
+ at,
33
+ message: `Must be exactly a permutation of [${itemIds.join(", ")}], got [${order.join(", ")}]`
34
+ });
35
+ }
36
+ }
37
+
38
+ // src/rules/answer-key-file.ts
39
+ async function checkAnswerKeyFile(resolved, at, issues) {
40
+ const file = resolved.question.answer_key?.file;
41
+ if (file === void 0) return;
42
+ try {
43
+ await resolved.source.fetch(file);
44
+ } catch (cause) {
45
+ issues.push({
46
+ rule: "answer-key-file-missing",
47
+ severity: "error",
48
+ at: `${at}.answer_key`,
49
+ message: `answer_key.file "${file}" could not be read: ${cause instanceof Error ? cause.message : String(cause)}`
50
+ });
51
+ }
52
+ }
53
+
54
+ // src/rules/unique-ids.ts
55
+ function checkUniqueIds(items, at, issues) {
56
+ const firstSeenAt = /* @__PURE__ */ new Map();
57
+ items.forEach((item, i) => {
58
+ const firstIndex = firstSeenAt.get(item.id);
59
+ if (firstIndex !== void 0) {
60
+ issues.push({
61
+ rule: "duplicate-id",
62
+ severity: "error",
63
+ at: `${at}[${i}]`,
64
+ message: `Duplicate id "${item.id}" (first seen at ${at}[${firstIndex}])`
65
+ });
66
+ } else {
67
+ firstSeenAt.set(item.id, i);
68
+ }
69
+ });
70
+ }
71
+
72
+ // src/question.ts
73
+ async function lintQuestion(resolved, at, issues) {
74
+ const { question } = resolved;
75
+ const typeConfigAt = `${at}.type_config`;
76
+ const isSecured = question.answer_key !== void 0;
77
+ switch (question.type) {
78
+ case "mcq": {
79
+ const { options, answer } = question.type_config;
80
+ checkUniqueIds(options, `${typeConfigAt}.options`, issues);
81
+ if (!isSecured && answer !== void 0) {
82
+ checkIdExists(answer, options, `${typeConfigAt}.answer`, issues);
83
+ }
84
+ break;
85
+ }
86
+ case "msq": {
87
+ const { options, answers } = question.type_config;
88
+ checkUniqueIds(options, `${typeConfigAt}.options`, issues);
89
+ if (!isSecured) {
90
+ (answers ?? []).forEach((answer, i) => checkIdExists(answer, options, `${typeConfigAt}.answers[${i}]`, issues));
91
+ }
92
+ break;
93
+ }
94
+ case "match": {
95
+ const { left, right, pairs } = question.type_config;
96
+ checkUniqueIds(left, `${typeConfigAt}.left`, issues);
97
+ checkUniqueIds(right, `${typeConfigAt}.right`, issues);
98
+ if (!isSecured) {
99
+ (pairs ?? []).forEach((pair, i) => {
100
+ checkIdExists(pair.left_id, left, `${typeConfigAt}.pairs[${i}].left_id`, issues);
101
+ checkIdExists(pair.right_id, right, `${typeConfigAt}.pairs[${i}].right_id`, issues);
102
+ });
103
+ }
104
+ break;
105
+ }
106
+ case "order": {
107
+ const { items, correct_order } = question.type_config;
108
+ checkUniqueIds(items, `${typeConfigAt}.items`, issues);
109
+ if (!isSecured && correct_order !== void 0) {
110
+ checkPermutation(correct_order, items, `${typeConfigAt}.correct_order`, issues);
111
+ }
112
+ break;
113
+ }
114
+ case "fill_blank":
115
+ checkUniqueIds(question.type_config.blanks, `${typeConfigAt}.blanks`, issues);
116
+ break;
117
+ case "code":
118
+ checkUniqueIds(question.type_config.test_cases, `${typeConfigAt}.test_cases`, issues);
119
+ break;
120
+ case "diagram":
121
+ checkUniqueIds(question.type_config.labels, `${typeConfigAt}.labels`, issues);
122
+ break;
123
+ case "numerical":
124
+ case "short_answer":
125
+ case "essay":
126
+ case "submission":
127
+ break;
128
+ }
129
+ await checkAnswerKeyFile(resolved, at, issues);
130
+ }
131
+
132
+ // src/rules/pool-bounds.ts
133
+ function checkPoolBounds(entry, at, issues) {
134
+ if (entry.select < 1) {
135
+ issues.push({
136
+ rule: "pool-select-bounds",
137
+ severity: "error",
138
+ at,
139
+ message: `select (${entry.select}) must be at least 1`
140
+ });
141
+ } else if (entry.select > entry.from.length) {
142
+ issues.push({
143
+ rule: "pool-select-bounds",
144
+ severity: "error",
145
+ at,
146
+ message: `select (${entry.select}) exceeds from.length (${entry.from.length})`
147
+ });
148
+ }
149
+ checkUniqueIds(entry.from, `${at}.from`, issues);
150
+ }
151
+
152
+ // src/tree.ts
153
+ function resolveErrorsToIssues(errors) {
154
+ return errors.map(
155
+ (error) => ({
156
+ rule: error.message.includes("content_hash mismatch") ? "content-hash-mismatch" : "dangling-reference",
157
+ severity: "error",
158
+ at: error.at,
159
+ message: error.message
160
+ })
161
+ );
162
+ }
163
+ async function lintSetContent(set, questions, at, issues) {
164
+ checkUniqueIds(set.questions, `${at}.questions`, issues);
165
+ set.questions.forEach((entry, i) => {
166
+ if (isOpfPoolEntry(entry)) checkPoolBounds(entry, `${at}.questions[${i}]`, issues);
167
+ });
168
+ for (const question of questions) {
169
+ await lintQuestion(question, `${at}.questions[id=${question.id}]`, issues);
170
+ }
171
+ }
172
+ async function lintPracticeSets(sets, at, issues) {
173
+ for (const [i, set] of sets.entries()) {
174
+ await lintSetContent(set.set, set.questions, `${at}.practice_sets[${i}]`, issues);
175
+ }
176
+ }
177
+ async function lintResolvedLesson(lesson, at, issues) {
178
+ checkUniqueIds(lesson.lesson.articles ?? [], `${at}.articles`, issues);
179
+ checkUniqueIds(lesson.lesson.video_lessons ?? [], `${at}.video_lessons`, issues);
180
+ checkUniqueIds(lesson.lesson.practice_sets ?? [], `${at}.practice_sets`, issues);
181
+ await lintPracticeSets(lesson.practice_sets, at, issues);
182
+ }
183
+ async function lintResolvedModule(module_, at, issues) {
184
+ checkUniqueIds(module_.module.lessons, `${at}.lessons`, issues);
185
+ for (const [i, lesson] of module_.lessons.entries()) {
186
+ await lintResolvedLesson(lesson, `${at}.lessons[${i}]`, issues);
187
+ }
188
+ }
189
+ async function lintResolvedCourse(course, issues) {
190
+ checkUniqueIds(course.course.modules, "course.modules", issues);
191
+ for (const [i, module_] of course.modules.entries()) {
192
+ await lintResolvedModule(module_, `course.modules[${i}]`, issues);
193
+ }
194
+ }
195
+
196
+ // src/index.ts
197
+ async function lintCourse(source, location = "course.json") {
198
+ const { data, errors } = await resolveCourse(source, location);
199
+ const issues = resolveErrorsToIssues(errors);
200
+ if (data) await lintResolvedCourse(data, issues);
201
+ return lintResult(issues);
202
+ }
203
+ async function lintSet(source, location = "set.json") {
204
+ const { data, errors } = await resolveSet(source, location);
205
+ const issues = resolveErrorsToIssues(errors);
206
+ if (data) await lintSetContent(data.set, data.questions, "set", issues);
207
+ return lintResult(issues);
208
+ }
209
+ async function lintLesson(source, location = "lesson.json") {
210
+ const { data, errors } = await resolveLesson(source, location);
211
+ const issues = resolveErrorsToIssues(errors);
212
+ if (data) await lintResolvedLesson(data, "lesson", issues);
213
+ return lintResult(issues);
214
+ }
215
+ async function lintModule(source, location = "module.json") {
216
+ const { data, errors } = await resolveModule(source, location);
217
+ const issues = resolveErrorsToIssues(errors);
218
+ if (data) await lintResolvedModule(data, "module", issues);
219
+ return lintResult(issues);
220
+ }
221
+ export {
222
+ lintCourse,
223
+ lintLesson,
224
+ lintModule,
225
+ lintSet
226
+ };
227
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/types.ts","../src/tree.ts","../src/rules/answer-references.ts","../src/rules/answer-key-file.ts","../src/rules/unique-ids.ts","../src/question.ts","../src/rules/pool-bounds.ts"],"sourcesContent":["import type { ContentSource } from \"@inklyre/oes-core\";\nimport { resolveCourse, resolveLesson, resolveModule, resolveSet } from \"@inklyre/oes-core\";\nimport type { LintIssue, LintResult } from \"./types.js\";\nimport { lintResult } from \"./types.js\";\nimport { lintResolvedCourse, lintResolvedLesson, lintResolvedModule, lintSetContent, resolveErrorsToIssues } from \"./tree.js\";\n\nexport type { LintIssue, LintResult, LintRule, LintSeverity } from \"./types.js\";\n\n/**\n * Lint a full course tree from a `course.json` entry point: every check\n * `resolve()` already makes (dangling references, `content_hash`\n * mismatches) plus everything only a whole-tree view can catch —\n * duplicate ids, pool `select` bounds, dangling answer cross-references,\n * `answer_key` file existence.\n */\nexport async function lintCourse(source: ContentSource, location = \"course.json\"): Promise<LintResult> {\n const { data, errors } = await resolveCourse(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedCourse(data, issues);\n return lintResult(issues);\n}\n\n/** Lint a single practice set from a `set.json` entry point — for a set-only repo, outside any course. */\nexport async function lintSet(source: ContentSource, location = \"set.json\"): Promise<LintResult> {\n const { data, errors } = await resolveSet(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintSetContent(data.set, data.questions, \"set\", issues);\n return lintResult(issues);\n}\n\n/** Lint a single lesson from a `lesson.json` entry point — for previewing/authoring one lesson in isolation. */\nexport async function lintLesson(source: ContentSource, location = \"lesson.json\"): Promise<LintResult> {\n const { data, errors } = await resolveLesson(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedLesson(data, \"lesson\", issues);\n return lintResult(issues);\n}\n\n/** Lint a single module from a `module.json` entry point — for previewing/authoring one module in isolation. */\nexport async function lintModule(source: ContentSource, location = \"module.json\"): Promise<LintResult> {\n const { data, errors } = await resolveModule(source, location);\n const issues: LintIssue[] = resolveErrorsToIssues(errors);\n if (data) await lintResolvedModule(data, \"module\", issues);\n return lintResult(issues);\n}\n","export type LintSeverity = \"error\" | \"warning\";\n\n/** Every check this package runs — used as `LintIssue.rule`, and as the rule name printed in CLI output. */\nexport type LintRule =\n | \"dangling-reference\"\n | \"content-hash-mismatch\"\n | \"duplicate-id\"\n | \"pool-select-bounds\"\n | \"dangling-answer-reference\"\n | \"answer-key-file-missing\";\n\nexport interface LintIssue {\n rule: LintRule;\n severity: LintSeverity;\n /** Where in the tree this was found, e.g. `\"course.modules[0].lessons[1].practice_sets[0].questions[id=q1]\"`. */\n at: string;\n message: string;\n}\n\nexport interface LintResult {\n issues: LintIssue[];\n /** `false` iff at least one `\"error\"`-severity issue was found — a `\"warning\"` alone doesn't fail a lint run. */\n ok: boolean;\n}\n\nexport function lintResult(issues: LintIssue[]): LintResult {\n return { issues, ok: !issues.some((issue) => issue.severity === \"error\") };\n}\n","import { isOpfPoolEntry } from \"@inklyre/oes-core\";\nimport type {\n OpfSet,\n ResolvedCourse,\n ResolvedLesson,\n ResolvedModule,\n ResolvedPracticeSet,\n ResolvedQuestion,\n ResolveError,\n} from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"./types.js\";\nimport { lintQuestion } from \"./question.js\";\nimport { checkPoolBounds } from \"./rules/pool-bounds.js\";\nimport { checkUniqueIds } from \"./rules/unique-ids.js\";\n\n/** `resolveCourse`/`resolveSet`'s own errors (dangling references, `content_hash` mismatches) — already-detected issues, just relabeled into this package's shape. */\nexport function resolveErrorsToIssues(errors: ResolveError[]): LintIssue[] {\n return errors.map(\n (error): LintIssue => ({\n rule: error.message.includes(\"content_hash mismatch\") ? \"content-hash-mismatch\" : \"dangling-reference\",\n severity: \"error\",\n at: error.at,\n message: error.message,\n })\n );\n}\n\nexport async function lintSetContent(set: OpfSet, questions: ResolvedQuestion[], at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(set.questions, `${at}.questions`, issues);\n set.questions.forEach((entry, i) => {\n if (isOpfPoolEntry(entry)) checkPoolBounds(entry, `${at}.questions[${i}]`, issues);\n });\n for (const question of questions) {\n await lintQuestion(question, `${at}.questions[id=${question.id}]`, issues);\n }\n}\n\nasync function lintPracticeSets(sets: ResolvedPracticeSet[], at: string, issues: LintIssue[]): Promise<void> {\n for (const [i, set] of sets.entries()) {\n await lintSetContent(set.set, set.questions, `${at}.practice_sets[${i}]`, issues);\n }\n}\n\nexport async function lintResolvedLesson(lesson: ResolvedLesson, at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(lesson.lesson.articles ?? [], `${at}.articles`, issues);\n checkUniqueIds(lesson.lesson.video_lessons ?? [], `${at}.video_lessons`, issues);\n checkUniqueIds(lesson.lesson.practice_sets ?? [], `${at}.practice_sets`, issues);\n await lintPracticeSets(lesson.practice_sets, at, issues);\n}\n\nexport async function lintResolvedModule(module_: ResolvedModule, at: string, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(module_.module.lessons, `${at}.lessons`, issues);\n for (const [i, lesson] of module_.lessons.entries()) {\n await lintResolvedLesson(lesson, `${at}.lessons[${i}]`, issues);\n }\n}\n\nexport async function lintResolvedCourse(course: ResolvedCourse, issues: LintIssue[]): Promise<void> {\n checkUniqueIds(course.course.modules, \"course.modules\", issues);\n for (const [i, module_] of course.modules.entries()) {\n await lintResolvedModule(module_, `course.modules[${i}]`, issues);\n }\n}\n","import type { LintIssue } from \"../types.js\";\n\n/** Flag `id` if it isn't one of `pool`'s ids — e.g. an `mcq`'s `answer` pointing at a nonexistent option. */\nexport function checkIdExists(id: string, pool: { id: string }[], at: string, issues: LintIssue[]): void {\n if (!pool.some((item) => item.id === id)) {\n issues.push({\n rule: \"dangling-answer-reference\",\n severity: \"error\",\n at,\n message: `References id \"${id}\", which does not exist among [${pool.map((i) => i.id).join(\", \")}]`,\n });\n }\n}\n\n/** Flag `order` if it isn't exactly a permutation of `items`' ids — every id present once, none missing, none extra. */\nexport function checkPermutation(order: string[], items: { id: string }[], at: string, issues: LintIssue[]): void {\n const itemIds = items.map((item) => item.id);\n const itemIdSet = new Set(itemIds);\n const orderIdSet = new Set(order);\n const isValid =\n order.length === itemIds.length &&\n orderIdSet.size === order.length &&\n [...orderIdSet].every((id) => itemIdSet.has(id));\n if (!isValid) {\n issues.push({\n rule: \"dangling-answer-reference\",\n severity: \"error\",\n at,\n message: `Must be exactly a permutation of [${itemIds.join(\", \")}], got [${order.join(\", \")}]`,\n });\n }\n}\n","import type { ResolvedQuestion } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"../types.js\";\n\n/**\n * When `answer_key.file` is set, confirm the file actually exists —\n * independent of whether `resolve()` ever fetches it, since it\n * deliberately doesn't (a public/student-facing consumer built on\n * `resolveCourse`/`resolveSet` must never gain a path to the answer\n * through the shared resolver). Uses the question's own re-rooted\n * `source`, exposed on {@link ResolvedQuestion} for exactly this purpose.\n */\nexport async function checkAnswerKeyFile(resolved: ResolvedQuestion, at: string, issues: LintIssue[]): Promise<void> {\n const file = resolved.question.answer_key?.file;\n if (file === undefined) return;\n try {\n await resolved.source.fetch(file);\n } catch (cause) {\n issues.push({\n rule: \"answer-key-file-missing\",\n severity: \"error\",\n at: `${at}.answer_key`,\n message: `answer_key.file \"${file}\" could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,\n });\n }\n}\n","import type { LintIssue } from \"../types.js\";\n\n/**\n * Flag any id repeated within `items` — something no JSON Schema in this\n * repo enforces (schema validation is per-document, not \"unique across\n * this array\"), and a real authoring mistake wherever it happens: two\n * modules/lessons/options/pool candidates/etc. sharing one id makes that\n * id ambiguous to anything addressing by it.\n */\nexport function checkUniqueIds(items: { id: string }[], at: string, issues: LintIssue[]): void {\n const firstSeenAt = new Map<string, number>();\n items.forEach((item, i) => {\n const firstIndex = firstSeenAt.get(item.id);\n if (firstIndex !== undefined) {\n issues.push({\n rule: \"duplicate-id\",\n severity: \"error\",\n at: `${at}[${i}]`,\n message: `Duplicate id \"${item.id}\" (first seen at ${at}[${firstIndex}])`,\n });\n } else {\n firstSeenAt.set(item.id, i);\n }\n });\n}\n","import type { ResolvedQuestion } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"./types.js\";\nimport { checkIdExists, checkPermutation } from \"./rules/answer-references.js\";\nimport { checkAnswerKeyFile } from \"./rules/answer-key-file.js\";\nimport { checkUniqueIds } from \"./rules/unique-ids.js\";\n\n/**\n * Run every question-level rule: duplicate ids within the question's own\n * option/item/etc. arrays, dangling answer cross-references (only\n * meaningful in self-practice mode — a secured `answer_key` means the\n * answer isn't inline to check), and `answer_key.file` existence.\n */\nexport async function lintQuestion(resolved: ResolvedQuestion, at: string, issues: LintIssue[]): Promise<void> {\n const { question } = resolved;\n const typeConfigAt = `${at}.type_config`;\n const isSecured = question.answer_key !== undefined;\n\n switch (question.type) {\n case \"mcq\": {\n const { options, answer } = question.type_config;\n checkUniqueIds(options, `${typeConfigAt}.options`, issues);\n if (!isSecured && answer !== undefined) {\n checkIdExists(answer, options, `${typeConfigAt}.answer`, issues);\n }\n break;\n }\n case \"msq\": {\n const { options, answers } = question.type_config;\n checkUniqueIds(options, `${typeConfigAt}.options`, issues);\n if (!isSecured) {\n (answers ?? []).forEach((answer, i) => checkIdExists(answer, options, `${typeConfigAt}.answers[${i}]`, issues));\n }\n break;\n }\n case \"match\": {\n const { left, right, pairs } = question.type_config;\n checkUniqueIds(left, `${typeConfigAt}.left`, issues);\n checkUniqueIds(right, `${typeConfigAt}.right`, issues);\n if (!isSecured) {\n (pairs ?? []).forEach((pair, i) => {\n checkIdExists(pair.left_id, left, `${typeConfigAt}.pairs[${i}].left_id`, issues);\n checkIdExists(pair.right_id, right, `${typeConfigAt}.pairs[${i}].right_id`, issues);\n });\n }\n break;\n }\n case \"order\": {\n const { items, correct_order } = question.type_config;\n checkUniqueIds(items, `${typeConfigAt}.items`, issues);\n if (!isSecured && correct_order !== undefined) {\n checkPermutation(correct_order, items, `${typeConfigAt}.correct_order`, issues);\n }\n break;\n }\n case \"fill_blank\":\n checkUniqueIds(question.type_config.blanks, `${typeConfigAt}.blanks`, issues);\n break;\n case \"code\":\n checkUniqueIds(question.type_config.test_cases, `${typeConfigAt}.test_cases`, issues);\n break;\n case \"diagram\":\n checkUniqueIds(question.type_config.labels, `${typeConfigAt}.labels`, issues);\n break;\n case \"numerical\":\n case \"short_answer\":\n case \"essay\":\n case \"submission\":\n break;\n }\n\n await checkAnswerKeyFile(resolved, at, issues);\n}\n","import type { OpfPoolQuestionEntry } from \"@inklyre/oes-core\";\nimport type { LintIssue } from \"../types.js\";\nimport { checkUniqueIds } from \"./unique-ids.js\";\n\n/**\n * `select` must be between 1 and `from.length` — the schema can't enforce\n * this itself, since it's a relationship between two sibling fields\n * (see `OpfPoolQuestionEntry.select`'s own doc comment).\n */\nexport function checkPoolBounds(entry: OpfPoolQuestionEntry, at: string, issues: LintIssue[]): void {\n if (entry.select < 1) {\n issues.push({\n rule: \"pool-select-bounds\",\n severity: \"error\",\n at,\n message: `select (${entry.select}) must be at least 1`,\n });\n } else if (entry.select > entry.from.length) {\n issues.push({\n rule: \"pool-select-bounds\",\n severity: \"error\",\n at,\n message: `select (${entry.select}) exceeds from.length (${entry.from.length})`,\n });\n }\n checkUniqueIds(entry.from, `${at}.from`, issues);\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@inklyre/oes-lint",
3
+ "version": "0.1.0",
4
+ "description": "Referential-integrity linting for OES content: dangling references, duplicate ids, pool select bounds, answer cross-references, answer_key file existence, and content_hash mismatches.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "bin": {
11
+ "oes": "dist/cli.js"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js",
17
+ "require": "./dist/index.cjs"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "sideEffects": false,
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "dev": "tsup --watch",
27
+ "typecheck": "tsc --noEmit",
28
+ "lint": "eslint src test",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest"
31
+ },
32
+ "dependencies": {
33
+ "@inklyre/oes-core": "^0.2.1"
34
+ },
35
+ "devDependencies": {
36
+ "@eslint/js": "^9.18.0",
37
+ "@types/node": "^22.10.5",
38
+ "eslint": "^9.18.0",
39
+ "tsup": "^8.3.5",
40
+ "typescript": "^5.8.3",
41
+ "typescript-eslint": "^8.20.0",
42
+ "vitest": "^3.0.5"
43
+ }
44
+ }