@jpeakai/prose-gates 0.2.0 → 0.3.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 +16 -10
- package/dist/bin.js +21370 -722
- package/dist/index.js +21379 -724
- package/dist/testing.js +21058 -555
- package/package.json +3 -1
- package/src/check.ts +19 -7
- package/src/cli.ts +80 -50
- package/src/cohesion.ts +70 -0
- package/src/concepts.ts +209 -0
- package/src/errors.ts +21 -0
- package/src/fix/engine.ts +77 -59
- package/src/fix/verify.ts +39 -27
- package/src/index.ts +13 -0
- package/src/language.ts +107 -0
- package/src/model.ts +176 -102
- package/src/plugins/discover.ts +19 -13
- package/src/plugins/index.ts +66 -33
- package/src/plugins/load.ts +73 -33
- package/src/project.ts +5 -2
- package/src/rules/index.ts +2 -0
- package/src/rules/list-comma-labelled-run.ts +50 -34
- package/src/rules/list-inline-enumeration-markers.ts +54 -43
- package/src/rules/list-interpunct-joined-run.ts +31 -28
- package/src/rules/paragraph-sentence-budget-exceeded.ts +64 -0
- package/src/rules/registry.ts +12 -2
- package/src/rules/sentence-one-per-line.ts +41 -14
- package/src/rules/sentence-word-budget-exceeded.ts +27 -16
- package/src/rules/types.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jpeakai/prose-gates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic prose gates for markdown documentation",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"examples"
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
|
+
"compromise": "14.18.0",
|
|
51
52
|
"mdast-util-from-markdown": "^2.0.3",
|
|
52
53
|
"mdast-util-frontmatter": "^2.0.1",
|
|
53
54
|
"mdast-util-gfm": "^3.1.0",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@biomejs/biome": "^2",
|
|
62
|
+
"@jpeakai/mermaidjs-lint": "^0.7.2",
|
|
61
63
|
"@types/bun": "latest",
|
|
62
64
|
"@types/mdast": "^4",
|
|
63
65
|
"fast-check": "^4.10.0",
|
package/src/check.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Checking: build the model once, run every active rule over it, and audit
|
|
2
2
|
// embedded markdown fences as documents of their own.
|
|
3
3
|
//
|
|
4
|
-
// Nothing is modified in check mode
|
|
5
|
-
// rule
|
|
4
|
+
// Nothing is modified in check mode. Every rule is started at once, except a
|
|
5
|
+
// rule that names categories in `after`, which waits for those findings. A rule may be
|
|
6
6
|
// synchronous or return a promise, so concurrency is as good as parallelism
|
|
7
7
|
// here: a rule that waits on a tool or a service overlaps with the others.
|
|
8
8
|
|
|
9
9
|
import { buildDocModel, type DocModel } from "./model.ts";
|
|
10
10
|
import { BUILTIN, type Registry, ruleContext, TEXT_HELPERS } from "./rules/registry.ts";
|
|
11
|
-
import type { Finding } from "./rules/types.ts";
|
|
11
|
+
import type { Finding, Rule } from "./rules/types.ts";
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_MAX_WORDS = 25;
|
|
14
14
|
|
|
@@ -24,19 +24,31 @@ export const checkModel = async (
|
|
|
24
24
|
registry: Registry = BUILTIN,
|
|
25
25
|
): Promise<Finding[]> => {
|
|
26
26
|
const shared = ruleContext(doc, registry);
|
|
27
|
-
const
|
|
27
|
+
const run = async (rule: Rule, earlier: readonly Finding[]): Promise<Finding[]> => {
|
|
28
28
|
const options = registry.options.get(rule.id) ?? {};
|
|
29
29
|
return rule.check({
|
|
30
30
|
...shared,
|
|
31
31
|
file,
|
|
32
32
|
maxWords: budgetFor(maxWords, options.maxWords),
|
|
33
|
+
earlier,
|
|
33
34
|
options,
|
|
34
35
|
helpers: TEXT_HELPERS,
|
|
35
36
|
});
|
|
36
|
-
}
|
|
37
|
+
};
|
|
38
|
+
// Rules that read no other rule's findings start at once. A rule with
|
|
39
|
+
// `after` waits for them and reads what its named categories reported.
|
|
40
|
+
const categoryOf = new Map(registry.rules.map((r) => [r.id, r.category]));
|
|
41
|
+
const first = Promise.all(registry.rules.filter((r) => !r.after).map((r) => run(r, [])));
|
|
42
|
+
const later = registry.rules
|
|
43
|
+
.filter((r) => r.after)
|
|
44
|
+
.map(async (rule) => {
|
|
45
|
+
const earlier = (await first).flat().filter((f) => rule.after?.includes(categoryOf.get(f.rule) ?? ""));
|
|
46
|
+
return run(rule, earlier);
|
|
47
|
+
});
|
|
48
|
+
const perRule = [first.then((found) => found.flat()), ...later];
|
|
37
49
|
// The fence body starts one line below the opening fence.
|
|
38
50
|
const perFence = doc.fences.map(async (fence) => {
|
|
39
|
-
const inner = await checkModel(buildDocModel(fence.value), file, maxWords, registry);
|
|
51
|
+
const inner = await checkModel(buildDocModel(fence.value, registry.concepts), file, maxWords, registry);
|
|
40
52
|
return inner.map((f) => ({ ...f, line: fence.line + f.line }));
|
|
41
53
|
});
|
|
42
54
|
const findings = (await Promise.all([...perRule, ...perFence])).flat();
|
|
@@ -48,4 +60,4 @@ export const checkMarkdown = (
|
|
|
48
60
|
file: string,
|
|
49
61
|
maxWords?: number,
|
|
50
62
|
registry: Registry = BUILTIN,
|
|
51
|
-
): Promise<Finding[]> => checkModel(buildDocModel(src), file, maxWords, registry);
|
|
63
|
+
): Promise<Finding[]> => checkModel(buildDocModel(src, registry.concepts), file, maxWords, registry);
|
package/src/cli.ts
CHANGED
|
@@ -97,6 +97,14 @@ export const skippedLines = (failures: PluginFailure[], ignored: string[]): stri
|
|
|
97
97
|
// The reason without the "plugin <source>:" the error message begins with.
|
|
98
98
|
const reasonOf = (f: PluginFailure): string => f.error.message.replace(`plugin ${f.source}: `, "");
|
|
99
99
|
|
|
100
|
+
// A plugin failure as the JSON output and --validate-plugins report it.
|
|
101
|
+
const failureRecord = (f: PluginFailure) => ({
|
|
102
|
+
source: f.source,
|
|
103
|
+
kind: f.kind,
|
|
104
|
+
code: f.error.code,
|
|
105
|
+
message: reasonOf(f),
|
|
106
|
+
});
|
|
107
|
+
|
|
100
108
|
// Every active rule under its category, then where each plugin came from.
|
|
101
109
|
// Built-in categories come first, in their fixed order, then plugin ones.
|
|
102
110
|
export const listRules = (registry: Registry): string => {
|
|
@@ -122,7 +130,7 @@ const validate = (
|
|
|
122
130
|
json: boolean,
|
|
123
131
|
): { code: number; stdout: string | null; stderr: string[] } => {
|
|
124
132
|
const sources = registry.sources.map((s) => ({ ...s }));
|
|
125
|
-
const failed = failures.map(
|
|
133
|
+
const failed = failures.map(failureRecord);
|
|
126
134
|
const code = failures.length > 0 ? 1 : 0;
|
|
127
135
|
if (json)
|
|
128
136
|
return {
|
|
@@ -146,26 +154,29 @@ const validate = (
|
|
|
146
154
|
return { code, stdout: line, stderr: [] };
|
|
147
155
|
};
|
|
148
156
|
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
// The flags, or a usage error naming what parseArgs rejected.
|
|
158
|
+
const parseFlags = (argv: string[]): Parsed => {
|
|
151
159
|
try {
|
|
152
|
-
|
|
160
|
+
return parse(argv);
|
|
153
161
|
} catch (err) {
|
|
154
162
|
throw new FlagError("argv", err instanceof Error ? err.message : String(err));
|
|
155
163
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// Absent means the rule's own option, then the default. Present is a flag
|
|
162
|
-
// that wins for this run.
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Absent means the rule's own option, then the default. Present is a flag
|
|
167
|
+
// that wins for this run.
|
|
168
|
+
const maxWordsOf = (values: Parsed["values"]): number | undefined => {
|
|
163
169
|
const maxWords = values["max-words"] === undefined ? undefined : Number(values["max-words"]);
|
|
164
|
-
const listing = values["list-rules"];
|
|
165
|
-
const validating = values["validate-plugins"];
|
|
166
170
|
if (maxWords !== undefined && (Number.isNaN(maxWords) || maxWords < 1)) {
|
|
167
171
|
throw new FlagError("--max-words", `--max-words must be a number of at least 1, not "${values["max-words"]}"`);
|
|
168
172
|
}
|
|
173
|
+
return maxWords;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// The flag combinations that contradict each other, or leave nothing to do.
|
|
177
|
+
const checkFlags = ({ values, positionals }: Parsed): void => {
|
|
178
|
+
const listing = values["list-rules"];
|
|
179
|
+
const validating = values["validate-plugins"];
|
|
169
180
|
if (values["no-plugins"] && values["lenient-plugins"]) {
|
|
170
181
|
throw new FlagError("--lenient-plugins", "--no-plugins and --lenient-plugins contradict each other");
|
|
171
182
|
}
|
|
@@ -176,59 +187,78 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
|
|
|
176
187
|
throw new FlagError("files", "--validate-plugins runs no check, so it takes no files");
|
|
177
188
|
}
|
|
178
189
|
if (positionals.length === 0 && !listing && !validating) throw new FlagError("files", "no files given");
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Prints the --validate-plugins result and returns its exit code.
|
|
193
|
+
const printValidation = (registry: Registry, failures: PluginFailure[], json: boolean): number => {
|
|
194
|
+
const result = validate(registry, failures, json);
|
|
195
|
+
for (const line of result.stderr) console.error(line);
|
|
196
|
+
if (result.stdout) console.log(result.stdout);
|
|
197
|
+
return result.code;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// One file's findings, fixed first when asked and written back only if changed.
|
|
201
|
+
const checkFile = async (file: string, fix: boolean, maxWords: number | undefined, registry: Registry) => {
|
|
202
|
+
let src = await readFile(file, "utf8");
|
|
203
|
+
if (fix) {
|
|
204
|
+
const fixed = (await fixMarkdownReport(src, registry)).output;
|
|
205
|
+
if (fixed !== src) {
|
|
206
|
+
await writeFile(file, fixed);
|
|
207
|
+
src = fixed;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return await checkModel(buildDocModel(src, registry.concepts), file, maxWords, registry);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
type Finding = Awaited<ReturnType<typeof checkFile>>[number];
|
|
214
|
+
type SetUp = Awaited<ReturnType<typeof setUp>>;
|
|
215
|
+
|
|
216
|
+
// The findings as JSON or one line each, then the exit code.
|
|
217
|
+
const report = (all: Finding[], files: string[], setup: SetUp, json: boolean): number => {
|
|
218
|
+
if (json) {
|
|
219
|
+
const skippedPlugins = setup.failures.map(failureRecord);
|
|
220
|
+
console.log(
|
|
221
|
+
JSON.stringify({ findings: all, files: files.length, plugins: setup.registry.sources, skippedPlugins }, null, 2),
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
for (const f of all) console.log(`${f.file}:${f.line} ${f.rule} ${f.message}`);
|
|
225
|
+
console.log(`${all.length} finding(s) in ${files.length} file(s)`);
|
|
226
|
+
}
|
|
227
|
+
return all.length > 0 ? 1 : 0;
|
|
228
|
+
};
|
|
179
229
|
|
|
180
|
-
|
|
230
|
+
const run = async (argv: string[], cwd: string): Promise<number> => {
|
|
231
|
+
const parsed = parseFlags(argv);
|
|
232
|
+
const { values, positionals } = parsed;
|
|
233
|
+
if (values.help) {
|
|
234
|
+
console.log(USAGE);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
const maxWords = maxWordsOf(values);
|
|
238
|
+
checkFlags(parsed);
|
|
239
|
+
const validating = values["validate-plugins"];
|
|
240
|
+
|
|
241
|
+
const setup = await setUp({
|
|
181
242
|
cwd,
|
|
182
243
|
configPath: values.config,
|
|
183
244
|
noPlugins: values["no-plugins"],
|
|
184
245
|
lenient: values["lenient-plugins"],
|
|
185
246
|
collect: validating,
|
|
186
247
|
});
|
|
187
|
-
|
|
188
|
-
if (validating)
|
|
189
|
-
const result = validate(registry, failures, Boolean(values.json));
|
|
190
|
-
for (const line of result.stderr) console.error(line);
|
|
191
|
-
if (result.stdout) console.log(result.stdout);
|
|
192
|
-
return result.code;
|
|
193
|
-
}
|
|
248
|
+
const { registry, skipped, failures } = setup;
|
|
249
|
+
if (validating) return printValidation(registry, failures, Boolean(values.json));
|
|
194
250
|
|
|
195
251
|
const note = loadedLine(registry.sources, skipped);
|
|
196
252
|
if (note) console.error(note);
|
|
197
253
|
for (const line of skippedLines(failures, registry.ignoredSettings)) console.error(line);
|
|
198
|
-
if (
|
|
254
|
+
if (values["list-rules"]) {
|
|
199
255
|
console.log(listRules(registry));
|
|
200
256
|
return 0;
|
|
201
257
|
}
|
|
202
258
|
const perFile = await Promise.all(
|
|
203
|
-
positionals.map(
|
|
204
|
-
let src = await readFile(file, "utf8");
|
|
205
|
-
if (values.fix) {
|
|
206
|
-
const fixed = (await fixMarkdownReport(src, registry)).output;
|
|
207
|
-
if (fixed !== src) {
|
|
208
|
-
await writeFile(file, fixed);
|
|
209
|
-
src = fixed;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return await checkModel(buildDocModel(src), file, maxWords, registry);
|
|
213
|
-
}),
|
|
259
|
+
positionals.map((file) => checkFile(file, Boolean(values.fix), maxWords, registry)),
|
|
214
260
|
);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (values.json) {
|
|
218
|
-
const skippedPlugins = failures.map((f) => ({
|
|
219
|
-
source: f.source,
|
|
220
|
-
kind: f.kind,
|
|
221
|
-
code: f.error.code,
|
|
222
|
-
message: reasonOf(f),
|
|
223
|
-
}));
|
|
224
|
-
console.log(
|
|
225
|
-
JSON.stringify({ findings: all, files: positionals.length, plugins: registry.sources, skippedPlugins }, null, 2),
|
|
226
|
-
);
|
|
227
|
-
} else {
|
|
228
|
-
for (const f of all) console.log(`${f.file}:${f.line} ${f.rule} ${f.message}`);
|
|
229
|
-
console.log(`${all.length} finding(s) in ${positionals.length} file(s)`);
|
|
230
|
-
}
|
|
231
|
-
return all.length > 0 ? 1 : 0;
|
|
261
|
+
return report(perFile.flat(), positionals, setup, Boolean(values.json));
|
|
232
262
|
};
|
|
233
263
|
|
|
234
264
|
// The line printed for a failure: its stable code, then the reason.
|
package/src/cohesion.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Lexical cohesion between the sentences of one paragraph: which sentences
|
|
2
|
+
// talk about the same thing, and where the topic turns. It reads the language
|
|
3
|
+
// model a paragraph exposes, so the words, tags and lemmas come from
|
|
4
|
+
// src/language.ts. The scoring and the break plan are a guide, not a parse:
|
|
5
|
+
// no JavaScript topic segmenter exists to use instead.
|
|
6
|
+
|
|
7
|
+
import type { LanguageSentence } from "./language.ts";
|
|
8
|
+
|
|
9
|
+
const shared = (a: ReadonlySet<string>, b: ReadonlySet<string>): number => [...a].filter((t) => b.has(t)).length;
|
|
10
|
+
|
|
11
|
+
// How strongly the sentences either side of each gap hold together. Gap i
|
|
12
|
+
// sits between sentence i and sentence i + 1, counted from zero here. A term
|
|
13
|
+
// the two neighbours share counts double. A term shared by sentences two
|
|
14
|
+
// apart across the gap counts once, so a sentence that strays for a moment
|
|
15
|
+
// does not split a topic. A sentence that opens by referring back counts
|
|
16
|
+
// double, and one that opens like its neighbour, in parallel, counts once.
|
|
17
|
+
// Zero means nothing ties the two sides together: the topic turns.
|
|
18
|
+
export const gapCohesion = (sentences: readonly LanguageSentence[]): number[] => {
|
|
19
|
+
const terms = sentences.map((s) => s.terms);
|
|
20
|
+
const openers = sentences.map((s) => s.opener);
|
|
21
|
+
const none: ReadonlySet<string> = new Set();
|
|
22
|
+
const at = (i: number): ReadonlySet<string> => terms[i] ?? none;
|
|
23
|
+
return sentences.slice(1).map((next, i) => {
|
|
24
|
+
const adjacent = shared(at(i), at(i + 1));
|
|
25
|
+
// A gap at either end has one pair two apart instead of two, so the pairs
|
|
26
|
+
// it has are scaled up. Otherwise an edge would always look weakest.
|
|
27
|
+
const pairs = [
|
|
28
|
+
i - 1 >= 0 ? shared(at(i - 1), at(i + 1)) : null,
|
|
29
|
+
i + 2 < terms.length ? shared(at(i), at(i + 2)) : null,
|
|
30
|
+
];
|
|
31
|
+
const present = pairs.filter((p): p is number => p !== null);
|
|
32
|
+
const skips = present.length === 0 ? 0 : (2 * present.reduce((a, b) => a + b, 0)) / present.length;
|
|
33
|
+
const parallel = openers[i] !== null && openers[i] === openers[i + 1] ? 1 : 0;
|
|
34
|
+
return 2 * adjacent + skips + (next.refersBack ? 2 : 0) + parallel;
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Where to break a paragraph: after every sentence whose gap has no cohesion,
|
|
39
|
+
// then inside any group still over the budget, at its weakest gap. A tie goes
|
|
40
|
+
// to the gap nearest the middle of the group, so the halves come out even.
|
|
41
|
+
// Breaks are sentence numbers counted from one: a break of 3 means a new
|
|
42
|
+
// paragraph starts with sentence 4.
|
|
43
|
+
export interface BreakPlan {
|
|
44
|
+
topics: number;
|
|
45
|
+
breaks: number[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const planBreaks = (cohesion: number[], maxSentences: number): BreakPlan => {
|
|
49
|
+
const count = cohesion.length + 1;
|
|
50
|
+
const topicBreaks = cohesion.flatMap((score, i) => (score === 0 ? [i + 1] : []));
|
|
51
|
+
const breaks = new Set(topicBreaks);
|
|
52
|
+
const split = (from: number, to: number): void => {
|
|
53
|
+
// The group holds sentences from + 1 through to.
|
|
54
|
+
if (to - from <= maxSentences) return;
|
|
55
|
+
let best = -1;
|
|
56
|
+
for (let at = from + 1; at < to; at++) {
|
|
57
|
+
const better =
|
|
58
|
+
best === -1 ||
|
|
59
|
+
(cohesion[at - 1] as number) < (cohesion[best - 1] as number) ||
|
|
60
|
+
(cohesion[at - 1] === cohesion[best - 1] && Math.abs(2 * at - from - to) < Math.abs(2 * best - from - to));
|
|
61
|
+
if (better) best = at;
|
|
62
|
+
}
|
|
63
|
+
breaks.add(best);
|
|
64
|
+
split(from, best);
|
|
65
|
+
split(best, to);
|
|
66
|
+
};
|
|
67
|
+
const edges = [0, ...topicBreaks, count];
|
|
68
|
+
for (let i = 0; i + 1 < edges.length; i++) split(edges[i] as number, edges[i + 1] as number);
|
|
69
|
+
return { topics: topicBreaks.length + 1, breaks: [...breaks].sort((a, b) => a - b) };
|
|
70
|
+
};
|
package/src/concepts.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The concept model: a project's own vocabulary, read from the GLOSSARY.md or
|
|
2
|
+
// CONCEPTS.md at its root. Each H2 heading is a concept, and each Mermaid
|
|
3
|
+
// flowchart in the file relates them: a labelled edge is a relation, a dotted
|
|
4
|
+
// one is a prohibition, and "is a kind of" makes one concept a kind of another.
|
|
5
|
+
// The file is optional, but one that is there must parse, because a rule that
|
|
6
|
+
// reads concepts would otherwise run against half a vocabulary.
|
|
7
|
+
|
|
8
|
+
import { access, readFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import nlp from "compromise";
|
|
11
|
+
import type { Code, Heading, Node, Text } from "mdast";
|
|
12
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
13
|
+
import { AmbiguousConceptsError, ConceptsFileError } from "./errors.ts";
|
|
14
|
+
|
|
15
|
+
export const CONCEPT_FILES = ["GLOSSARY.md", "CONCEPTS.md"] as const;
|
|
16
|
+
|
|
17
|
+
// The edge label that makes its source a kind of its target.
|
|
18
|
+
export const KIND_OF = "is a kind of";
|
|
19
|
+
|
|
20
|
+
// One concept. `kinds` is every concept it is a kind of, nearest first, so a
|
|
21
|
+
// Local Rule that is a kind of Plugin has kinds ["Plugin"].
|
|
22
|
+
export interface Concept {
|
|
23
|
+
name: string;
|
|
24
|
+
kinds: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One edge of a diagram, by concept name. `verb` is the edge label, empty for
|
|
28
|
+
// an unlabelled edge. `never` is true for a dotted edge: the source must never
|
|
29
|
+
// do what the label says to the target.
|
|
30
|
+
export interface ConceptRelation {
|
|
31
|
+
from: string;
|
|
32
|
+
verb: string;
|
|
33
|
+
to: string;
|
|
34
|
+
never: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ConceptModel {
|
|
38
|
+
// The file it came from, relative to the project root. Null when there is none.
|
|
39
|
+
file: string | null;
|
|
40
|
+
concepts: readonly Concept[];
|
|
41
|
+
relations: readonly ConceptRelation[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const NO_CONCEPTS: ConceptModel = { file: null, concepts: [], relations: [] };
|
|
45
|
+
|
|
46
|
+
// --- reading the file ------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const textOf = (node: Node): string =>
|
|
49
|
+
node.type === "text" || node.type === "inlineCode"
|
|
50
|
+
? (node as Text).value
|
|
51
|
+
: ((node as { children?: Node[] }).children ?? []).map(textOf).join("");
|
|
52
|
+
|
|
53
|
+
const NODE = /^\s*([A-Za-z][\w-]*)\["([^"]+)"\]/;
|
|
54
|
+
const EDGE = /^\s*([A-Za-z][\w-]*)\s+(?:(--|-\.)\s+(.+?)\s+(-->|\.->)|(-->|-\.->))\s+([A-Za-z][\w-]*)\s*$/;
|
|
55
|
+
|
|
56
|
+
// One diagram's relations. Node ids are local to their diagram, so two
|
|
57
|
+
// diagrams may reuse an id for different concepts.
|
|
58
|
+
const relationsOf = (file: string, diagram: Code, names: ReadonlySet<string>): ConceptRelation[] => {
|
|
59
|
+
const label = new Map<string, string>();
|
|
60
|
+
const relations: ConceptRelation[] = [];
|
|
61
|
+
for (const line of diagram.value.split("\n")) {
|
|
62
|
+
const node = NODE.exec(line);
|
|
63
|
+
if (node) {
|
|
64
|
+
const [, id = "", name = ""] = node;
|
|
65
|
+
if (!names.has(name)) throw new ConceptsFileError(file, `diagram node "${name}" is not a term heading`);
|
|
66
|
+
label.set(id, name);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const edge = EDGE.exec(line);
|
|
70
|
+
if (!edge) continue;
|
|
71
|
+
const [, from = "", solidOrDotted, verb = "", , bare, to = ""] = edge;
|
|
72
|
+
const ends = [from, to].map((id) => {
|
|
73
|
+
const name = label.get(id);
|
|
74
|
+
if (!name) throw new ConceptsFileError(file, `edge "${line.trim()}" names "${id}", which no node declares`);
|
|
75
|
+
return name;
|
|
76
|
+
});
|
|
77
|
+
const never = solidOrDotted === "-." || bare === "-.->";
|
|
78
|
+
relations.push({ from: ends[0] as string, verb, to: ends[1] as string, never });
|
|
79
|
+
}
|
|
80
|
+
return relations;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Every concept a concept is a kind of, nearest first. A cycle is an error,
|
|
84
|
+
// because it leaves a concept a kind of itself.
|
|
85
|
+
const kindsOf = (file: string, name: string, parents: ReadonlyMap<string, string[]>): string[] => {
|
|
86
|
+
const kinds: string[] = [];
|
|
87
|
+
const queue = [...(parents.get(name) ?? [])];
|
|
88
|
+
while (queue.length > 0) {
|
|
89
|
+
const next = queue.shift() as string;
|
|
90
|
+
if (next === name) throw new ConceptsFileError(file, `"${name}" is a kind of itself`);
|
|
91
|
+
if (kinds.includes(next)) continue;
|
|
92
|
+
kinds.push(next);
|
|
93
|
+
queue.push(...(parents.get(next) ?? []));
|
|
94
|
+
}
|
|
95
|
+
return kinds;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const parseConcepts = (file: string, src: string): ConceptModel => {
|
|
99
|
+
const root = fromMarkdown(src);
|
|
100
|
+
const headings = root.children.filter((n): n is Heading => n.type === "heading" && n.depth === 2);
|
|
101
|
+
const names = headings.map((h) => textOf(h).trim());
|
|
102
|
+
if (names.length === 0) throw new ConceptsFileError(file, "it has no H2 headings, and each H2 is a term");
|
|
103
|
+
const repeated = names.find((n, i) => names.indexOf(n) !== i);
|
|
104
|
+
if (repeated) throw new ConceptsFileError(file, `the term "${repeated}" has two headings`);
|
|
105
|
+
const known = new Set(names);
|
|
106
|
+
const diagrams = root.children.filter((n): n is Code => n.type === "code" && n.lang === "mermaid");
|
|
107
|
+
const relations = diagrams.flatMap((d) => relationsOf(file, d, known));
|
|
108
|
+
const parents = new Map<string, string[]>();
|
|
109
|
+
for (const r of relations.filter((x) => x.verb === KIND_OF && !x.never)) {
|
|
110
|
+
parents.set(r.from, [...(parents.get(r.from) ?? []), r.to]);
|
|
111
|
+
}
|
|
112
|
+
return { file, concepts: names.map((name) => ({ name, kinds: kindsOf(file, name, parents) })), relations };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const exists = (path: string): Promise<boolean> =>
|
|
116
|
+
access(path).then(
|
|
117
|
+
() => true,
|
|
118
|
+
() => false,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// The concept file at the root, or null. Two at once is ambiguous, so it is
|
|
122
|
+
// an error rather than a pick.
|
|
123
|
+
export const findConcepts = async (root: string): Promise<string | null> => {
|
|
124
|
+
const found: string[] = [];
|
|
125
|
+
for (const name of CONCEPT_FILES) if (await exists(join(root, name))) found.push(name);
|
|
126
|
+
if (found.length > 1) throw new AmbiguousConceptsError(root, found);
|
|
127
|
+
return found[0] ?? null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const loadConcepts = async (root: string): Promise<ConceptModel> => {
|
|
131
|
+
const name = await findConcepts(root);
|
|
132
|
+
return name ? parseConcepts(name, await readFile(join(root, name), "utf8")) : NO_CONCEPTS;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// --- finding concepts in a sentence ----------------------------------------
|
|
136
|
+
|
|
137
|
+
// One place a sentence names a concept. `start` and `length` count words of
|
|
138
|
+
// the sentence. `text` is the words as written, so "prose gates" names Prose Gate.
|
|
139
|
+
export interface ConceptMention {
|
|
140
|
+
concept: string;
|
|
141
|
+
text: string;
|
|
142
|
+
start: number;
|
|
143
|
+
length: number;
|
|
144
|
+
kinds: readonly string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface Form {
|
|
148
|
+
concept: Concept;
|
|
149
|
+
words: readonly string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The word sequences that name each concept, its own and its plural, longest
|
|
153
|
+
// first, keyed by their first word. compromise tokenises each so they line up
|
|
154
|
+
// with the sentence's words, which it tokenised the same way.
|
|
155
|
+
const formsCache = new WeakMap<ConceptModel, Map<string, Form[]>>();
|
|
156
|
+
|
|
157
|
+
const tokens = (text: string): string[] =>
|
|
158
|
+
(nlp(text).json() as { terms: { text: string }[] }[]).flatMap((s) => s.terms.map((t) => t.text.toLowerCase()));
|
|
159
|
+
|
|
160
|
+
const formsOf = (model: ConceptModel): Map<string, Form[]> => {
|
|
161
|
+
const cached = formsCache.get(model);
|
|
162
|
+
if (cached) return cached;
|
|
163
|
+
const byFirst = new Map<string, Form[]>();
|
|
164
|
+
for (const concept of model.concepts) {
|
|
165
|
+
const lower = concept.name.toLowerCase();
|
|
166
|
+
const plural = nlp(lower).nouns().toPlural().text();
|
|
167
|
+
for (const form of new Set([lower, plural].filter((f) => f.length > 0))) {
|
|
168
|
+
const words = tokens(form);
|
|
169
|
+
const first = words[0] as string;
|
|
170
|
+
byFirst.set(first, [...(byFirst.get(first) ?? []), { concept, words }]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const forms of byFirst.values()) forms.sort((a, b) => b.words.length - a.words.length);
|
|
174
|
+
formsCache.set(model, byFirst);
|
|
175
|
+
return byFirst;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
interface MatchWord {
|
|
179
|
+
text: string;
|
|
180
|
+
tags: readonly string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A one-word concept must be read as a noun there, so "release the package"
|
|
184
|
+
// names no Release. A longer one is specific enough as it stands.
|
|
185
|
+
const matchAt = (words: readonly MatchWord[], at: number, forms: readonly Form[]): Form | undefined =>
|
|
186
|
+
forms.find(
|
|
187
|
+
(f) =>
|
|
188
|
+
f.words.every((w, i) => words[at + i]?.text.toLowerCase() === w) &&
|
|
189
|
+
(f.words.length > 1 || (words[at]?.tags.includes("Noun") ?? false)),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Every concept the words name, longest match first, never overlapping.
|
|
193
|
+
export const mentionsIn = (words: readonly MatchWord[], model: ConceptModel): ConceptMention[] => {
|
|
194
|
+
if (model.concepts.length === 0) return [];
|
|
195
|
+
const byFirst = formsOf(model);
|
|
196
|
+
const mentions: ConceptMention[] = [];
|
|
197
|
+
for (let at = 0; at < words.length; at++) {
|
|
198
|
+
const form = matchAt(words, at, byFirst.get(words[at]?.text.toLowerCase() ?? "") ?? []);
|
|
199
|
+
if (!form) continue;
|
|
200
|
+
const length = form.words.length;
|
|
201
|
+
const text = words
|
|
202
|
+
.slice(at, at + length)
|
|
203
|
+
.map((w) => w.text)
|
|
204
|
+
.join(" ");
|
|
205
|
+
mentions.push({ concept: form.concept.name, text, start: at, length, kinds: form.concept.kinds });
|
|
206
|
+
at += length - 1;
|
|
207
|
+
}
|
|
208
|
+
return mentions;
|
|
209
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -61,6 +61,27 @@ export class AmbiguousConfigError extends UsageError {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// A concept file at the project root that does not parse as one: no term
|
|
65
|
+
// headings, a repeated term, or a diagram that names something undefined.
|
|
66
|
+
export class ConceptsFileError extends UsageError {
|
|
67
|
+
readonly file: string;
|
|
68
|
+
|
|
69
|
+
constructor(file: string, message: string) {
|
|
70
|
+
super(`${file}: not a concept file, because ${message}`, "concepts-file");
|
|
71
|
+
this.file = file;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Both GLOSSARY.md and CONCEPTS.md at one root, so neither can be picked.
|
|
76
|
+
export class AmbiguousConceptsError extends UsageError {
|
|
77
|
+
readonly files: string[];
|
|
78
|
+
|
|
79
|
+
constructor(root: string, files: string[]) {
|
|
80
|
+
super(`more than one concept file at ${root}: ${files.join(", ")}`, "concepts-ambiguous");
|
|
81
|
+
this.files = files;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
// The config parsed, but its top-level shape is wrong.
|
|
65
86
|
export class ConfigShapeError extends UsageError {
|
|
66
87
|
readonly file: string;
|