@jpeakai/prose-gates 0.1.2 → 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.
Files changed (52) hide show
  1. package/README.md +45 -7
  2. package/dist/bin.js +35469 -6876
  3. package/dist/index.js +35566 -6939
  4. package/dist/testing.js +38824 -0
  5. package/examples/plugin-skills/README.md +60 -0
  6. package/examples/plugin-skills/index.mjs +14 -0
  7. package/examples/plugin-skills/package.json +12 -0
  8. package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
  9. package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
  10. package/examples/plugin-skills-typescript/README.md +42 -0
  11. package/examples/plugin-skills-typescript/index.ts +21 -0
  12. package/examples/plugin-skills-typescript/package.json +12 -0
  13. package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
  14. package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
  15. package/examples/plugin-skills-typescript/tsconfig.json +14 -0
  16. package/package.json +12 -3
  17. package/src/bin.ts +2 -2
  18. package/src/check.ts +53 -13
  19. package/src/cli.ts +243 -44
  20. package/src/cohesion.ts +70 -0
  21. package/src/concepts.ts +209 -0
  22. package/src/config.ts +119 -0
  23. package/src/errors.ts +212 -0
  24. package/src/fix/engine.ts +90 -50
  25. package/src/fix/verify.ts +60 -19
  26. package/src/index.ts +51 -2
  27. package/src/interpunct.ts +7 -7
  28. package/src/language.ts +107 -0
  29. package/src/model.ts +271 -68
  30. package/src/plugins/contract.ts +41 -0
  31. package/src/plugins/discover.ts +146 -0
  32. package/src/plugins/index.ts +136 -0
  33. package/src/plugins/load.ts +274 -0
  34. package/src/plugins/runtime.ts +15 -0
  35. package/src/project.ts +84 -0
  36. package/src/rules/index.ts +34 -20
  37. package/src/rules/list-comma-labelled-run.ts +117 -0
  38. package/src/rules/{pg007-inline-enum.ts → list-inline-enumeration-markers.ts} +56 -45
  39. package/src/rules/list-interpunct-joined-run.ts +71 -0
  40. package/src/rules/{pg003-semicolon-list.ts → list-semicolon-delimited-run.ts} +2 -2
  41. package/src/rules/{pg009-stacked-runs.ts → list-stacked-interpunct-runs.ts} +3 -3
  42. package/src/rules/paragraph-sentence-budget-exceeded.ts +64 -0
  43. package/src/rules/{pg004-em-dash.ts → punctuation-em-dash-in-prose.ts} +3 -3
  44. package/src/rules/{pg005-interpunct.ts → punctuation-interpunct-in-prose.ts} +6 -6
  45. package/src/rules/registry.ts +143 -0
  46. package/src/rules/{pg001-wrap.ts → sentence-one-per-line.ts} +45 -18
  47. package/src/rules/sentence-word-budget-exceeded.ts +57 -0
  48. package/src/rules/types.ts +71 -21
  49. package/src/testing.ts +60 -0
  50. package/src/rules/pg002-length.ts +0 -45
  51. package/src/rules/pg006-interpunct-run.ts +0 -68
  52. package/src/rules/pg008-labelled-run.ts +0 -101
package/src/cli.ts CHANGED
@@ -3,13 +3,20 @@
3
3
 
4
4
  import { readFile, writeFile } from "node:fs/promises";
5
5
  import { parseArgs } from "node:util";
6
- import { checkModel, DEFAULT_MAX_WORDS } from "./check.ts";
7
- import { fixMarkdown } from "./fix/engine.ts";
6
+ import { checkModel } from "./check.ts";
7
+ import { FlagError, ProseGatesError, UsageError } from "./errors.ts";
8
+ import { fixMarkdownReport } from "./fix/engine.ts";
8
9
  import { buildDocModel } from "./model.ts";
10
+ import type { PluginFailure } from "./plugins/index.ts";
11
+ import { setUp } from "./project.ts";
9
12
  import { RULES } from "./rules/index.ts";
13
+ import type { LoadedSource, Registry } from "./rules/registry.ts";
10
14
  import { CATEGORIES } from "./rules/types.ts";
11
15
 
12
- const USAGE = `usage: prose-gates <file.md> [...more] [--fix] [--json] [--max-words N]
16
+ const USAGE = `usage: prose-gates <file.md> [...more] [--fix] [--json] [--max-words N] [--config FILE]
17
+ [--no-plugins | --lenient-plugins]
18
+ prose-gates --list-rules
19
+ prose-gates --validate-plugins [--json] [--config FILE]
13
20
 
14
21
  Deterministic prose gates (* = autofixable):
15
22
  ${CATEGORIES.map((c) =>
@@ -25,58 +32,250 @@ markdown templates and their body is audited recursively (check-only).
25
32
 
26
33
  --fix applies every fix it can prove safe, then reports what remains.
27
34
  A fixer that is unsure leaves the text alone and the finding stays.
28
- Exits 1 when findings remain, 2 on usage error.`;
35
+ --config FILE reads that config instead of prose-gates.config.json or .mjs at the project root.
36
+ Plugins load on their own from .prose-gates/rules and from dependencies named prose-gates-plugin-*.
37
+ --no-plugins runs the built-in rules only, for a baseline or a repo you do not trust.
38
+ --lenient-plugins skips a plugin that fails to load and says so, instead of stopping the run.
39
+ --validate-plugins loads and checks every plugin, reports every failure, and runs no check or fix.
40
+ --list-rules prints every active rule by category, including plugin rules, and where each plugin came from.
41
+ Exits 1 when findings remain or a plugin fails to load, 2 on usage error.`;
29
42
 
30
- export const main = async (argv: string[] = process.argv.slice(2)): Promise<number> => {
31
- let values: { fix?: boolean; json?: boolean; help?: boolean; "max-words"?: string };
32
- let positionals: string[];
43
+ interface Parsed {
44
+ values: {
45
+ fix?: boolean;
46
+ json?: boolean;
47
+ help?: boolean;
48
+ "max-words"?: string;
49
+ config?: string;
50
+ "no-plugins"?: boolean;
51
+ "lenient-plugins"?: boolean;
52
+ "validate-plugins"?: boolean;
53
+ "list-rules"?: boolean;
54
+ };
55
+ positionals: string[];
56
+ }
57
+
58
+ const parse = (argv: string[]): Parsed =>
59
+ parseArgs({
60
+ args: argv,
61
+ options: {
62
+ fix: { type: "boolean", default: false },
63
+ json: { type: "boolean", default: false },
64
+ help: { type: "boolean", short: "h", default: false },
65
+ "max-words": { type: "string" },
66
+ config: { type: "string" },
67
+ "no-plugins": { type: "boolean", default: false },
68
+ "lenient-plugins": { type: "boolean", default: false },
69
+ "validate-plugins": { type: "boolean", default: false },
70
+ "list-rules": { type: "boolean", default: false },
71
+ },
72
+ allowPositionals: true,
73
+ strict: true,
74
+ });
75
+
76
+ const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
77
+
78
+ const namesOf = (sources: LoadedSource[]): string =>
79
+ sources.map((s) => `${s.namespace} (${plural(s.rules.length, "rule")})`).join(", ");
80
+
81
+ // The one line every run prints about third-party code, because loading it is
82
+ // automatic. It says what ran, or what was skipped, and prints nothing when the
83
+ // project has no plugins.
84
+ export const loadedLine = (sources: LoadedSource[], skipped: string[]): string | null => {
85
+ if (skipped.length > 0) return `prose-gates: plugins off; skipped ${skipped.join(", ")}`;
86
+ if (sources.length === 0) return null;
87
+ return `prose-gates: loaded ${namesOf(sources)}`;
88
+ };
89
+
90
+ // One line for each plugin a lenient run left out, with the named reason, and
91
+ // one for each config setting dropped because its plugin did not load.
92
+ export const skippedLines = (failures: PluginFailure[], ignored: string[]): string[] => [
93
+ ...failures.map((f) => `prose-gates: skipped plugin ${f.source} [${f.error.code}]: ${reasonOf(f)}`),
94
+ ...ignored.map((id) => `prose-gates: ignored config setting for ${id}, whose plugin did not load`),
95
+ ];
96
+
97
+ // The reason without the "plugin <source>:" the error message begins with.
98
+ const reasonOf = (f: PluginFailure): string => f.error.message.replace(`plugin ${f.source}: `, "");
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
+
108
+ // Every active rule under its category, then where each plugin came from.
109
+ // Built-in categories come first, in their fixed order, then plugin ones.
110
+ export const listRules = (registry: Registry): string => {
111
+ const lines: string[] = [];
112
+ for (const [category, description] of registry.categories) {
113
+ const rules = registry.rules.filter((r) => r.category === category);
114
+ if (rules.length === 0) continue;
115
+ lines.push(`${category}: ${description}`);
116
+ for (const r of rules) lines.push(` ${r.id}${r.fix ? "*" : " "} ${r.summary}`);
117
+ }
118
+ if (registry.sources.length > 0) {
119
+ lines.push("", "plugins:");
120
+ for (const s of registry.sources) lines.push(` ${s.namespace} (${s.kind}) ${s.spec}`);
121
+ }
122
+ return lines.join("\n");
123
+ };
124
+
125
+ // The result of --validate-plugins: what loaded, every failure, and the exit
126
+ // code. Nothing here runs a check or a fix.
127
+ const validate = (
128
+ registry: Registry,
129
+ failures: PluginFailure[],
130
+ json: boolean,
131
+ ): { code: number; stdout: string | null; stderr: string[] } => {
132
+ const sources = registry.sources.map((s) => ({ ...s }));
133
+ const failed = failures.map(failureRecord);
134
+ const code = failures.length > 0 ? 1 : 0;
135
+ if (json)
136
+ return {
137
+ code,
138
+ stdout: JSON.stringify({ valid: code === 0, plugins: sources, failures: failed }, null, 2),
139
+ stderr: [],
140
+ };
141
+ if (failures.length > 0) {
142
+ const total = failures.length + sources.length;
143
+ return {
144
+ code,
145
+ stdout: null,
146
+ stderr: [
147
+ `prose-gates: plugin validation failed, ${failures.length} of ${plural(total, "source")}`,
148
+ ...failed.map((f) => ` [${f.code}] ${f.source}: ${f.message}`),
149
+ ],
150
+ };
151
+ }
152
+ const line =
153
+ sources.length === 0 ? "prose-gates: no plugins found" : `prose-gates: plugins valid: ${namesOf(registry.sources)}`;
154
+ return { code, stdout: line, stderr: [] };
155
+ };
156
+
157
+ // The flags, or a usage error naming what parseArgs rejected.
158
+ const parseFlags = (argv: string[]): Parsed => {
33
159
  try {
34
- ({ values, positionals } = parseArgs({
35
- args: argv,
36
- options: {
37
- fix: { type: "boolean", default: false },
38
- json: { type: "boolean", default: false },
39
- help: { type: "boolean", short: "h", default: false },
40
- "max-words": { type: "string" },
41
- },
42
- allowPositionals: true,
43
- strict: true,
44
- }));
160
+ return parse(argv);
45
161
  } catch (err) {
46
- console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
47
- console.error(USAGE);
48
- return 2;
162
+ throw new FlagError("argv", err instanceof Error ? err.message : String(err));
163
+ }
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 => {
169
+ const maxWords = values["max-words"] === undefined ? undefined : Number(values["max-words"]);
170
+ if (maxWords !== undefined && (Number.isNaN(maxWords) || maxWords < 1)) {
171
+ throw new FlagError("--max-words", `--max-words must be a number of at least 1, not "${values["max-words"]}"`);
49
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"];
180
+ if (values["no-plugins"] && values["lenient-plugins"]) {
181
+ throw new FlagError("--lenient-plugins", "--no-plugins and --lenient-plugins contradict each other");
182
+ }
183
+ if (validating && (values["no-plugins"] || values.fix || listing)) {
184
+ throw new FlagError("--validate-plugins", "--validate-plugins takes no --no-plugins, --fix or --list-rules");
185
+ }
186
+ if (validating && positionals.length > 0) {
187
+ throw new FlagError("files", "--validate-plugins runs no check, so it takes no files");
188
+ }
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
+ };
229
+
230
+ const run = async (argv: string[], cwd: string): Promise<number> => {
231
+ const parsed = parseFlags(argv);
232
+ const { values, positionals } = parsed;
50
233
  if (values.help) {
51
234
  console.log(USAGE);
52
235
  return 0;
53
236
  }
54
- const maxWords = values["max-words"] === undefined ? DEFAULT_MAX_WORDS : Number(values["max-words"]);
55
- if (positionals.length === 0 || Number.isNaN(maxWords) || maxWords < 1) {
56
- console.error(USAGE);
57
- return 2;
58
- }
237
+ const maxWords = maxWordsOf(values);
238
+ checkFlags(parsed);
239
+ const validating = values["validate-plugins"];
240
+
241
+ const setup = await setUp({
242
+ cwd,
243
+ configPath: values.config,
244
+ noPlugins: values["no-plugins"],
245
+ lenient: values["lenient-plugins"],
246
+ collect: validating,
247
+ });
248
+ const { registry, skipped, failures } = setup;
249
+ if (validating) return printValidation(registry, failures, Boolean(values.json));
59
250
 
251
+ const note = loadedLine(registry.sources, skipped);
252
+ if (note) console.error(note);
253
+ for (const line of skippedLines(failures, registry.ignoredSettings)) console.error(line);
254
+ if (values["list-rules"]) {
255
+ console.log(listRules(registry));
256
+ return 0;
257
+ }
60
258
  const perFile = await Promise.all(
61
- positionals.map(async (file) => {
62
- let src = await readFile(file, "utf8");
63
- if (values.fix) {
64
- const fixed = fixMarkdown(src);
65
- if (fixed !== src) {
66
- await writeFile(file, fixed);
67
- src = fixed;
68
- }
69
- }
70
- return checkModel(buildDocModel(src), file, maxWords);
71
- }),
259
+ positionals.map((file) => checkFile(file, Boolean(values.fix), maxWords, registry)),
72
260
  );
73
- const all = perFile.flat();
261
+ return report(perFile.flat(), positionals, setup, Boolean(values.json));
262
+ };
74
263
 
75
- if (values.json) {
76
- console.log(JSON.stringify({ findings: all, files: positionals.length }, null, 2));
77
- } else {
78
- for (const f of all) console.log(`${f.file}:${f.line} ${f.rule} ${f.message}`);
79
- console.log(`${all.length} finding(s) in ${positionals.length} file(s)`);
264
+ // The line printed for a failure: its stable code, then the reason.
265
+ export const describeError = (err: unknown): string =>
266
+ err instanceof ProseGatesError
267
+ ? `error[${err.code}]: ${err.message}`
268
+ : `error: ${err instanceof Error ? err.message : String(err)}`;
269
+
270
+ // A usage error prints its reason and the usage, and exits 2. Anything else is
271
+ // a runtime failure and propagates to bin.ts, which exits 1.
272
+ export const main = async (argv: string[] = process.argv.slice(2), cwd: string = process.cwd()): Promise<number> => {
273
+ try {
274
+ return await run(argv, cwd);
275
+ } catch (err) {
276
+ if (!(err instanceof UsageError)) throw err;
277
+ console.error(describeError(err));
278
+ console.error(USAGE);
279
+ return 2;
80
280
  }
81
- return all.length > 0 ? 1 : 0;
82
281
  };
@@ -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
+ };
@@ -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
+ };