@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpeakai/prose-gates",
3
- "version": "0.2.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 and no rule reads another's result, so every
5
- // rule is started at once and the engine waits for all of them. A rule may be
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 perRule = registry.rules.map(async (rule) => {
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((f) => ({ source: f.source, kind: f.kind, code: f.error.code, message: reasonOf(f) }));
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
- const run = async (argv: string[], cwd: string): Promise<number> => {
150
- let parsed: Parsed;
157
+ // The flags, or a usage error naming what parseArgs rejected.
158
+ const parseFlags = (argv: string[]): Parsed => {
151
159
  try {
152
- parsed = parse(argv);
160
+ return parse(argv);
153
161
  } catch (err) {
154
162
  throw new FlagError("argv", err instanceof Error ? err.message : String(err));
155
163
  }
156
- const { values, positionals } = parsed;
157
- if (values.help) {
158
- console.log(USAGE);
159
- return 0;
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
- const { registry, skipped, failures } = await setUp({
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 (listing) {
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(async (file) => {
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
- const all = perFile.flat();
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.
@@ -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
+ };
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;