@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 +94 -0
- package/dist/cli.js +318 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +255 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +34 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +227 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
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
|
package/dist/cli.js.map
ADDED
|
@@ -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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|