@agentsfleet/orly 0.5.3 → 0.6.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.
@@ -0,0 +1,390 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ // Mechanical enforcement of docs/DOCUMENTATION_RULES.md over the markdown orly
5
+ // writes and renders.
6
+ //
7
+ // It lives in the CLI, not in this repository's audits/, because the gap it
8
+ // closes is in what orly RENDERS. A shell script here would only ever guard
9
+ // this checkout; a criterion in the engine travels with every install and
10
+ // reads the pages wherever they land.
11
+ //
12
+ // What it enforces, mechanically and without judgment:
13
+ // DOCWORD DOC-05 + DOC-07 + DOC-14b banned vocabulary, exact forms only.
14
+ // DOCDASH DOC-14b em-dash budget: none in short copy, two per long page.
15
+ // DOCSENT DOC-02 prose sentences carry at most 25 words.
16
+ // DOCPARA DOC-03 paragraphs carry at most three sentences.
17
+ // DOCHEAD DOC-25 one level-one heading, and no skipped heading level.
18
+ //
19
+ // What it deliberately leaves alone: DOC-06 (meaning survives the shorter
20
+ // form), DOC-12 (pronoun referents), DOC-34 (review checks), and the
21
+ // reading-joy set DOC-36..DOC-42. Each needs a reader who understands the
22
+ // page. DOC-33 and DOC-34 already split the rules that way, and faking the
23
+ // judgment half would report confidence nobody has.
24
+
25
+ export const DOC_WORD = "DOCWORD";
26
+ export const DOC_DASH = "DOCDASH";
27
+ export const DOC_SENTENCE = "DOCSENT";
28
+ export const DOC_PARAGRAPH = "DOCPARA";
29
+ export const DOC_HEADING = "DOCHEAD";
30
+
31
+ export type DocFinding = { file: string; line: number; code: string; message: string; weight: number };
32
+
33
+ const MAX_SENTENCE_WORDS = 25;
34
+ const MAX_PARAGRAPH_SENTENCES = 3;
35
+ // "Short copy" and "long page" need a bound to be mechanical. A page under
36
+ // this many words of prose is short copy and gets no em dash at all.
37
+ const SHORT_COPY_WORDS = 300;
38
+ const LONG_PAGE_DASHES = 2;
39
+ const EM_DASH = "—";
40
+ // The em-dash budget is calibrated for prose a first-day reader skims. The
41
+ // rule corpus is not that: dispatch pages, the operating model, and skill
42
+ // bodies are dense reference text where the dash separates a claim from its
43
+ // consequence, and a comma or a period there reads worse. Measured across the
44
+ // corpus the budget flagged 36 of 39 pages, which is a rule mis-fit rather
45
+ // than 36 defects. These pages keep every other rule.
46
+ const RULE_PAGE_DIRECTORIES = ["dispatch/", "core/", "audits/", "skills/"];
47
+ const RULE_PAGE_FILES = ["AGENTS.md", "AGENTS.orly.md", "SOUL.md", "RULES.md"];
48
+
49
+ function isRulePage(file: string): boolean {
50
+ const normalised = file.replaceAll("\\", "/");
51
+ if (RULE_PAGE_DIRECTORIES.some((directory) => normalised.includes(directory))) return true;
52
+ const name = normalised.slice(normalised.lastIndexOf("/") + 1);
53
+ return RULE_PAGE_FILES.includes(name);
54
+ }
55
+ const README = "README.md";
56
+ const CONFIG_RELATIVE_PATH = ".oracle/orly.json";
57
+ const REGISTRY_FILE = "registry.json";
58
+ const CODE_PLACEHOLDER = "Code";
59
+ const EMPTY = "";
60
+ const CLOSERS = "]\")'";
61
+ const ENDERS = ".!?";
62
+ const SPACE = " ";
63
+ const UTF8 = "utf8";
64
+ const FRONT_MATTER_FENCE = "---";
65
+ // The first capture group, kept when a markdown span is unwrapped.
66
+ const FIRST_GROUP = "$1";
67
+
68
+ // Enumerated, never stemmed: a stemmer that turns "execute" into "executable"
69
+ // bans a correct English word, and one false positive is what gets a check
70
+ // switched off. The inflections that carry the same slop are listed by hand.
71
+ const BANNED_WORDS = new Set([
72
+ "utilise", "utilises", "utilised", "utilising", "utilisation",
73
+ "utilize", "utilizes", "utilized", "utilizing", "utilization",
74
+ "facilitate", "facilitates", "facilitated", "facilitating",
75
+ "leverage", "leverages", "leveraged", "leveraging",
76
+ "orchestrate", "orchestrates", "orchestrated", "orchestrating", "orchestration",
77
+ "instantiate", "instantiates", "instantiated", "instantiating", "instantiation",
78
+ "terminate", "terminates", "terminated", "terminating", "termination",
79
+ "provision", "provisions", "provisioned", "provisioning",
80
+ "execute", "executes", "executed", "executing", "execution", "executions",
81
+ "persist", "persists", "persisted", "persisting", "persistence",
82
+ "hydrate", "hydrates", "hydrated", "hydrating", "hydration",
83
+ "artifact", "artifacts",
84
+ "powerful", "revolutionary", "enterprise-grade", "seamless", "seamlessly",
85
+ "cutting-edge", "robust", "next-generation", "world-class", "intelligent",
86
+ "delve", "delves", "delved", "delving",
87
+ "foster", "fosters", "fostered", "fostering",
88
+ "elevate", "elevates", "elevated", "elevating",
89
+ "supercharge", "supercharges", "supercharged", "supercharging",
90
+ "embark", "embarks", "embarked", "embarking",
91
+ "ever-evolving", "tapestry", "realm", "realms",
92
+ // SOUL.md's reply-shape list restated these and drifted from this one. The
93
+ // prose in DOCUMENTATION_RULES.md named only the British "utilise" while
94
+ // the American forms above were already enforced here.
95
+ "streamline", "streamlines", "streamlined", "streamlining",
96
+ "empower", "empowers", "empowered", "empowering",
97
+ "journey", "journeys", "landscape", "landscapes", "testament",
98
+ "pivotal", "notably", "moreover", "furthermore", "myriad", "plethora",
99
+ "aforementioned", "meticulous", "meticulously", "boasts", "cornerstone",
100
+ "best-in-class", "industry-leading", "state-of-the-art", "unparalleled",
101
+ "effortless", "effortlessly", "turnkey", "battle-tested", "rock-solid",
102
+ "innovative",
103
+ // core/operating-model.md already bans these two and names the
104
+ // replacements: Prototype/Milestone/Workstream/Section/Dimension/Batch for
105
+ // the hierarchy, "stages" for lifecycle steps, "rules" for what AGENTS.md
106
+ // enforces. The rule shipped to every consumer with nothing enforcing it.
107
+ "contract", "contracts", "contracted", "contracting",
108
+ "phase", "phases", "phased", "phasing",
109
+ ]);
110
+
111
+ const BANNED_PHRASES = ["paradigm shift", "game changer", "game-changer", "deep dive", "blazing fast", "blazingly fast"];
112
+
113
+ // Forms that end in a period without ending a sentence.
114
+ const ABBREVIATIONS = new Set(["e.g.", "i.e.", "etc.", "vs.", "cf.", "no.", "fig.", "approx."]);
115
+ // Product names are written lowercase and open sentences all over these pages.
116
+ // Without this list "…host. orly neither…" reads as one sentence and both
117
+ // length rules quietly under-report. The list is closed on purpose: guessing
118
+ // at lowercase sentence starts in general would split mid-sentence and invent
119
+ // violations.
120
+ const LOWERCASE_OPENERS = new Set(["orly", "gstack", "agentsfleet", "bun", "bunx", "git", "npm"]);
121
+
122
+ // Which DOC identifiers each code stands for, so a `lint-ignore` may name
123
+ // either the code or the rule it enforces.
124
+ const RULE_NAMES: Record<string, string> = {
125
+ [DOC_WORD]: "DOC-05 DOC-07 DOC-14b",
126
+ [DOC_DASH]: "DOC-14b",
127
+ [DOC_SENTENCE]: "DOC-02",
128
+ [DOC_PARAGRAPH]: "DOC-03",
129
+ [DOC_HEADING]: "DOC-25",
130
+ };
131
+
132
+ // The pages orly renders into a repository, plus its readme. The materialised
133
+ // set is authoritative and it is recorded, not guessed: `.oracle/orly.json`
134
+ // names every file orly wrote here. In the engine's own checkout the registry
135
+ // names the sources those files are rendered from, which is the same set one
136
+ // step earlier. README.md joins either list by the scope rule in
137
+ // docs/DOCUMENTATION_RULES.md — the rules cover any documentation orly writes.
138
+ export function documentationSurfaces(root: string): string[] {
139
+ const found = new Set<string>();
140
+ for (const path of [join(root, REGISTRY_FILE), join(root, CONFIG_RELATIVE_PATH)]) {
141
+ if (!existsSync(path)) continue;
142
+ for (const match of readFileSync(path, UTF8).matchAll(/"([^"\n]+\.md)"/g)) {
143
+ const relative = match[1] ?? EMPTY;
144
+ if (!relative.startsWith("/") && !relative.includes("..")) found.add(relative);
145
+ }
146
+ }
147
+ found.add(README);
148
+ return [...found].filter((relative) => existsSync(join(root, relative))).sort();
149
+ }
150
+
151
+ export function scanSurfaces(root: string, surfaces: string[]): DocFinding[] {
152
+ return surfaces.flatMap((relative) => scanDocument(relative, readFileSync(join(root, relative), UTF8)));
153
+ }
154
+
155
+ export function scanDocument(file: string, text: string): DocFinding[] {
156
+ return new Reader(file, text).run();
157
+ }
158
+
159
+ type Paragraph = { text: string; line: number; ignore: string };
160
+
161
+ class Reader {
162
+ private readonly findings: DocFinding[] = [];
163
+ private paragraph: Paragraph = { text: EMPTY, line: 0, ignore: EMPTY };
164
+ // A pragma introduces the paragraph under it, so it outlives its own line
165
+ // and dies with the paragraph it was written for.
166
+ private pendingIgnore = EMPTY;
167
+ private readonly declared = new Set<string>();
168
+ private proseWords = 0;
169
+ private dashes = 0;
170
+ private headingLevels: number[] = [];
171
+ private inComment = false;
172
+ private inFence = false;
173
+ private inFrontMatter = false;
174
+
175
+ constructor(private readonly file: string, private readonly text: string) {}
176
+
177
+ run(): DocFinding[] {
178
+ const lines = this.text.split(/\r?\n/);
179
+ for (const [index, raw] of lines.entries()) this.read(raw, index + 1);
180
+ this.flush();
181
+ this.reportHeadings();
182
+ this.reportDashes();
183
+ return this.findings;
184
+ }
185
+
186
+ private read(raw: string, lineNumber: number): void {
187
+ if (lineNumber === 1 && raw === FRONT_MATTER_FENCE) {
188
+ this.inFrontMatter = true;
189
+ return;
190
+ }
191
+ if (this.inFrontMatter) {
192
+ if (raw === FRONT_MATTER_FENCE) this.inFrontMatter = false;
193
+ return;
194
+ }
195
+ if (/^[ \t]*(```|~~~)/.test(raw)) {
196
+ this.inFence = !this.inFence;
197
+ this.flush();
198
+ return;
199
+ }
200
+ if (this.inFence) return;
201
+ if (this.inComment) {
202
+ if (raw.includes("-->")) this.inComment = false;
203
+ return;
204
+ }
205
+ // A comment that opens and closes on one line is cut out of the line, not
206
+ // used to drop it: a prose line ending in a pack marker is still prose.
207
+ // One comment is read rather than dropped, `lint-ignore: <CODE> — reason`,
208
+ // the spelling DOC-35 already gives an exception. It clears that code for
209
+ // the paragraph it introduces, and it is how a page that must NAME a
210
+ // banned word says so out loud instead of silently.
211
+ const ignore = /lint-ignore:[ \t]*(DOC[A-Z0-9-]*)/.exec(raw)?.[1] ?? EMPTY;
212
+ if (ignore) {
213
+ this.declared.add(ignore);
214
+ this.pendingIgnore = ignore;
215
+ }
216
+ const active = this.pendingIgnore;
217
+ const line = raw.replace(/<!--.*?-->/g, SPACE);
218
+ if (line.includes("<!--")) {
219
+ this.inComment = true;
220
+ return;
221
+ }
222
+ // Tables are exempt by DOC-13, and a cell is not a sentence.
223
+ if (/^[ \t]*\|/.test(line)) {
224
+ this.flush();
225
+ return;
226
+ }
227
+ const heading = /^(#{1,6})[ \t]+(.*)$/.exec(line);
228
+ if (heading) {
229
+ this.flush();
230
+ this.readHeading(heading[1]?.length ?? 0, clean(heading[2] ?? EMPTY), lineNumber, active);
231
+ return;
232
+ }
233
+ if (/^[ \t]*$/.test(line)) {
234
+ // Unless the line went blank because it held nothing but a lint-ignore:
235
+ // a pragma on its own line introduces the paragraph under it.
236
+ if (!ignore) this.flush();
237
+ return;
238
+ }
239
+ if (/^[ \t]*([-*+]|[0-9]+[.)])[ \t]/.test(line)) this.flush();
240
+ const prose = clean(line.replace(/^[ \t]*>[ \t]?/, EMPTY).replace(/^[ \t]*([-*+]|[0-9]+[.)])[ \t]+/, EMPTY));
241
+ if (/^[ \t]*$/.test(prose)) return;
242
+ this.scanWords(prose, lineNumber, active);
243
+ this.countDashes(prose);
244
+ if (!this.paragraph.text) this.paragraph = { text: prose, line: lineNumber, ignore: active };
245
+ else this.paragraph.text += `${SPACE}${prose}`;
246
+ }
247
+
248
+ private readHeading(level: number, title: string, lineNumber: number, ignore: string): void {
249
+ this.scanWords(title, lineNumber, ignore);
250
+ this.countDashes(title);
251
+ const previous = this.headingLevels[this.headingLevels.length - 1];
252
+ if (previous === undefined) {
253
+ if (level !== 1) this.report(lineNumber, DOC_HEADING, `first heading is H${level}, not H1`, 1, ignore);
254
+ } else if (level > previous + 1) {
255
+ this.report(lineNumber, DOC_HEADING, `heading level jumps H${previous} to H${level}`, 1, ignore);
256
+ }
257
+ this.headingLevels.push(level);
258
+ }
259
+
260
+ private scanWords(prose: string, lineNumber: number, ignore: string): void {
261
+ this.proseWords += words(prose).length;
262
+ const spoken = unquote(prose);
263
+ for (const token of words(spoken)) {
264
+ // An all-caps token is a name, not vocabulary: EXECUTE is a lifecycle
265
+ // stage, CONFORM is a gate, DOC-05 is a rule identifier. DOC-09 asks for
266
+ // one canonical name per concept, and this is how those names are written.
267
+ if (/^[A-Z0-9_.-]+$/.test(token) && /[A-Z]/.test(token)) continue;
268
+ const word = token.toLowerCase().replace(/^[./-]+/, EMPTY).replace(/[.,;:!?)/-]+$/, EMPTY);
269
+ if (BANNED_WORDS.has(word)) {
270
+ this.report(lineNumber, DOC_WORD, `banned word "${token}" (DOC-05/DOC-07/DOC-14b)`, 1, ignore);
271
+ }
272
+ }
273
+ const lowered = spoken.toLowerCase();
274
+ for (const phrase of BANNED_PHRASES) {
275
+ if (lowered.includes(phrase)) this.report(lineNumber, DOC_WORD, `banned phrase "${phrase}" (DOC-14b)`, 1, ignore);
276
+ }
277
+ }
278
+
279
+ private countDashes(prose: string): void {
280
+ this.dashes += prose.split(EM_DASH).length - 1;
281
+ }
282
+
283
+ private flush(): void {
284
+ const { text, line, ignore } = this.paragraph;
285
+ this.paragraph = { text: EMPTY, line: 0, ignore: EMPTY };
286
+ if (!text) return;
287
+ this.pendingIgnore = EMPTY;
288
+ const parts = sentences(text);
289
+ if (parts.length > MAX_PARAGRAPH_SENTENCES) {
290
+ this.report(line, DOC_PARAGRAPH, `${parts.length} sentences in one paragraph, DOC-03 allows ${MAX_PARAGRAPH_SENTENCES}`, 1, ignore);
291
+ }
292
+ for (const sentence of parts) {
293
+ const count = words(sentence).length;
294
+ if (count > MAX_SENTENCE_WORDS) {
295
+ this.report(line, DOC_SENTENCE, `${count} words in one sentence, DOC-02 allows ${MAX_SENTENCE_WORDS}: "${excerpt(sentence)}"`, 1, ignore);
296
+ }
297
+ }
298
+ }
299
+
300
+ private reportHeadings(): void {
301
+ const first = this.headingLevels.filter((level) => level === 1).length;
302
+ if (first === 0) this.reportPage(DOC_HEADING, "no level-one heading", 1);
303
+ if (first > 1) this.reportPage(DOC_HEADING, `${first} level-one headings, DOC-25 allows one`, first - 1);
304
+ }
305
+
306
+ private reportDashes(): void {
307
+ if (isRulePage(this.file)) return;
308
+ const short = this.proseWords < SHORT_COPY_WORDS;
309
+ const budget = short ? 0 : LONG_PAGE_DASHES;
310
+ if (this.dashes <= budget) return;
311
+ const where = short ? `short copy (${this.proseWords} prose words), budget 0` : `a long page, budget ${budget}`;
312
+ this.reportPage(DOC_DASH, `${this.dashes} em dashes in ${where}`, this.dashes - budget);
313
+ }
314
+
315
+ // `weight` is how much debt a finding carries: one for a line-level
316
+ // violation, the overage for a page-level budget. A page five dashes over
317
+ // and a page one dash over are not the same finding.
318
+ private report(line: number, code: string, message: string, weight: number, ignore: string): void {
319
+ if (ignore && allows(ignore, code)) return;
320
+ this.findings.push({ file: this.file, line, code, message, weight });
321
+ }
322
+
323
+ private reportPage(code: string, message: string, weight: number): void {
324
+ for (const declared of this.declared) if (allows(declared, code)) return;
325
+ this.findings.push({ file: this.file, line: 0, code, message, weight });
326
+ }
327
+ }
328
+
329
+ function allows(ignore: string, code: string): boolean {
330
+ return ignore === code || (RULE_NAMES[code] ?? EMPTY).includes(ignore);
331
+ }
332
+
333
+ function words(prose: string): string[] {
334
+ return prose.replace(/[^A-Za-z0-9_/.-]+/g, SPACE).split(/[ \t]+/).filter(Boolean);
335
+ }
336
+
337
+ // Everything the rules exempt comes out before any rule reads the text. An
338
+ // inline code span becomes a placeholder word rather than a hole: a hole loses
339
+ // the capital that starts the next sentence, and the splitter then welds two
340
+ // sentences into one long false positive. A link keeps its text and drops its
341
+ // target, because the text is prose and the target is a path no rule governs.
342
+ function clean(line: string): string {
343
+ return line
344
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, SPACE)
345
+ .replace(/`[^`]*`/g, CODE_PLACEHOLDER)
346
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, FIRST_GROUP)
347
+ .replace(/\[([^\]]*)\]\[[^\]]*\]/g, FIRST_GROUP)
348
+ .replace(/https?:\/\/[^\s)>]+/g, SPACE)
349
+ .replace(/<[^>]*>/g, SPACE);
350
+ }
351
+
352
+ // Use versus mention: a word inside quotation marks is being named, not used.
353
+ // A rule that bans a word has to print it to ban it, and a page quoting a
354
+ // caller is reporting speech rather than writing it.
355
+ function unquote(prose: string): string {
356
+ return prose.replace(/“[^”]*”/g, SPACE).replace(/"[^"]*"/g, SPACE);
357
+ }
358
+
359
+ function excerpt(sentence: string): string {
360
+ const limit = 57;
361
+ return sentence.length > limit + 3 ? `${sentence.slice(0, limit)}...` : sentence;
362
+ }
363
+
364
+ // A period, question mark, or exclamation mark, then optional closing brackets
365
+ // or quotes, then a space, then the next sentence. What follows the space
366
+ // decides: a lowercase letter, a digit, or more punctuation continues the
367
+ // sentence, unless the word is one of the lowercase product names.
368
+ function sentences(paragraph: string): string[] {
369
+ const parts: string[] = [];
370
+ let current = EMPTY;
371
+ for (let index = 0; index < paragraph.length; index += 1) {
372
+ const character = paragraph[index] ?? EMPTY;
373
+ current += character;
374
+ if (!ENDERS.includes(character)) continue;
375
+ while (CLOSERS.includes(paragraph[index + 1] ?? EMPTY) && index + 1 < paragraph.length) {
376
+ index += 1;
377
+ current += paragraph[index];
378
+ }
379
+ const gap = paragraph[index + 1] ?? EMPTY;
380
+ if (gap !== SPACE && gap !== "\t" && gap !== EMPTY) continue;
381
+ const rest = paragraph.slice(index + 2);
382
+ if (gap !== EMPTY && /^[a-z0-9,;:]/.test(rest) && !LOWERCASE_OPENERS.has(/^[A-Za-z0-9_-]+/.exec(rest)?.[0] ?? EMPTY)) continue;
383
+ const previous = current.split(/[ \t]/).pop() ?? EMPTY;
384
+ if (ABBREVIATIONS.has(previous.toLowerCase()) || /^[A-Za-z]\.$/.test(previous)) continue;
385
+ parts.push(current.trim());
386
+ current = EMPTY;
387
+ }
388
+ if (current.trim()) parts.push(current.trim());
389
+ return parts;
390
+ }
package/src/gates.test.ts CHANGED
@@ -30,7 +30,7 @@ describe("gate groups", () => {
30
30
  const model = await modelFor(project);
31
31
 
32
32
  expect(names(runGate(model, project, "work"))).toEqual(["git.branch", "git.tree", "repo.config"]);
33
- expect(names(runGate(model, project, "verify"))).toEqual(["cmd.conform", "cmd.verify.unit", "spec.dimensions"]);
33
+ expect(names(runGate(model, project, "verify"))).toEqual(["cmd.conform", "cmd.verify.unit", "docs.language", "spec.dimensions"]);
34
34
  expect(names(runGate(model, project, "pr"))).toEqual([
35
35
  "docs.updated", "git.pushed", "git.tree", "spec.baseline", "spec.deferrals", "spec.dimensions", "spec.gate",
36
36
  "spec.moved", "spec.open-questions", "spec.ordering", "spec.product-clarity",