@agentsfleet/orly 0.5.3 → 0.5.4
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/AGENTS.md +5 -5
- package/README.md +30 -14
- package/SOUL.md +2 -2
- package/core/operating-model.md +3 -3
- package/docs/DOCUMENTATION_RULES.md +8 -3
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/fixtures/doc-slop.md +29 -0
- package/package.json +1 -1
- package/src/criteria.ts +34 -1
- package/src/doc_rules.test.ts +101 -0
- package/src/doc_rules.ts +358 -0
- package/src/gates.test.ts +1 -1
package/AGENTS.md
CHANGED
|
@@ -10,13 +10,13 @@ You are this repository's coding agent: deterministic, autonomous, command-line-
|
|
|
10
10
|
|
|
11
11
|
**Tone.** Be properly funny — human-funny, dry, actually landing; not a bot doing bits. Swear words are absolutely fine. Never trade technical clarity for either.
|
|
12
12
|
|
|
13
|
-
**Start:** `SOUL.md` (
|
|
13
|
+
**Start:** `SOUL.md` (Tyto's working notes) is inlined as the final section; re-read it when padding or burying the answer.
|
|
14
14
|
|
|
15
15
|
## Owner & Style
|
|
16
16
|
|
|
17
|
-
**The human is Kishore** — casual handle **Indy**; either name, any case, addresses him. Ambiguous "the user" / "they" here = Kishore. The agent is **Oracle**, casual handle **
|
|
17
|
+
**The human is Kishore** — casual handle **Indy**; either name, any case, addresses him. Ambiguous "the user" / "they" here = Kishore. The agent is **Oracle**, casual handle **Tyto**. `orly` names the agentsfleet CLI only (`orly gate`, `orly init`, `orly update`, and the `Orly-Override` trailer it writes).
|
|
18
18
|
|
|
19
|
-
**Address tags.** Kishore: **🤠 Indy**; agent: **🦉
|
|
19
|
+
**Address tags.** Kishore: **🤠 Indy**; agent: **🦉 Tyto** (`Oracle`). **Project name:** `agentsfleet` (domains → `agentsfleet.net`). Write product as `agentsfleet` (inline code), never bare; stale legacy-brand reps → flag, replace when in scope, inform Indy. Products: `agentsfleet` / `agentsfleetd` / `agentsfleet-runner`; entities/API: `fleet`, `fleet_id`, `/fleets`, `core.fleet_*`. Keep: `agentsfleet.dev`, `github.com/agentsfleet/agentsfleet`, `@agentsfleet/*`, `~/Projects/agentsfleet`.
|
|
20
20
|
|
|
21
21
|
MacBook. Languages: Python, Go, Rust, TypeScript, Zig. Tooling: `mise` first, `brew` fallback. Forges: `gh`/`glab`. Commit identity from `git config`, never here.
|
|
22
22
|
|
|
@@ -241,9 +241,9 @@ Required when spec involved — after last COMMIT, before PR. Also runs when par
|
|
|
241
241
|
|
|
242
242
|
---
|
|
243
243
|
|
|
244
|
-
# SOUL.md —
|
|
244
|
+
# SOUL.md — Tyto's working notes
|
|
245
245
|
|
|
246
|
-
> First-person:
|
|
246
|
+
> First-person: Tyto to future Tyto. `AGENTS.md` carries the rules; this file
|
|
247
247
|
> carries the judgment — how Indy decides, what he accepts, what he rejects.
|
|
248
248
|
> In force every session; standing orders, not suggestions. Re-read when
|
|
249
249
|
> padding or burying the answer.
|
package/README.md
CHANGED
|
@@ -39,20 +39,6 @@ Works with Claude Code, Codex, OpenCode, and Amp.
|
|
|
39
39
|
| a coding agent | something has to read the rules | Claude Code, Codex, OpenCode, or Amp |
|
|
40
40
|
| your own check commands | `orly gate` runs what `.oracle/orly.json` names | whatever your repository already runs |
|
|
41
41
|
|
|
42
|
-
### gstack
|
|
43
|
-
|
|
44
|
-
[gstack](https://github.com/garrytan/gstack) is **optional**. orly does not
|
|
45
|
-
require it to install or run.
|
|
46
|
-
|
|
47
|
-
orly's rules route the review stage through `gstack /review` when gstack is
|
|
48
|
-
available. If it is installed, `/review` is detected and runs automatically.
|
|
49
|
-
If not, orly records the skipped review in the Pull Request notes so it can be
|
|
50
|
-
rerun before merge.
|
|
51
|
-
|
|
52
|
-
**Bottom line:** orly works without gstack. Installing
|
|
53
|
-
[gstack](https://github.com/garrytan/gstack) gives you the automated `/review`
|
|
54
|
-
step.
|
|
55
|
-
|
|
56
42
|
```bash
|
|
57
43
|
bunx @agentsfleet/orly init
|
|
58
44
|
```
|
|
@@ -71,6 +57,36 @@ orly init
|
|
|
71
57
|
Commit what it wrote. Teammates get the rules on clone. One exception: git
|
|
72
58
|
never clones hooks, so each person runs `orly init` once in their own checkout.
|
|
73
59
|
|
|
60
|
+
## gstack (optional)
|
|
61
|
+
|
|
62
|
+
[gstack](https://github.com/garrytan/gstack) is **optional**. orly does not
|
|
63
|
+
require it to install or run.
|
|
64
|
+
|
|
65
|
+
orly's rules route the review stage through `gstack /review` when gstack is
|
|
66
|
+
available. If it is installed, `/review` is detected and runs automatically.
|
|
67
|
+
If not, orly records the skipped review in the Pull Request notes so it can be
|
|
68
|
+
rerun before merge.
|
|
69
|
+
|
|
70
|
+
**Bottom line:** orly works without gstack. Installing
|
|
71
|
+
[gstack](https://github.com/garrytan/gstack) gives you the automated `/review`
|
|
72
|
+
step.
|
|
73
|
+
|
|
74
|
+
gstack also installs skills of its own, one set per agent host. orly neither
|
|
75
|
+
installs nor manages gstack.
|
|
76
|
+
|
|
77
|
+
The governance skills (`orly-spec-new`, `orly-babysit-prs`,
|
|
78
|
+
`orly-write-unit-test`, `orly-write-integration-test`) come from orly's
|
|
79
|
+
`workflow.skills` pack, per repository. The general-purpose skills come from
|
|
80
|
+
gstack, per agent host.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
cd ~/.local/share/gstack && ./setup --host auto
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`--host auto` covers every agent host gstack finds. Name one to target it
|
|
87
|
+
alone: `claude`, `codex`, `kiro`, `factory`, `opencode`, `openclaw`, `hermes`,
|
|
88
|
+
`gbrain`, or `auto`.
|
|
89
|
+
|
|
74
90
|
## What happens when you use it
|
|
75
91
|
|
|
76
92
|
`orly init` is the setup. This is the loop you live in, one task from prompt
|
package/SOUL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- oracle-packs:start persona.indy -->
|
|
2
|
-
# SOUL.md —
|
|
2
|
+
# SOUL.md — Tyto's working notes
|
|
3
3
|
|
|
4
|
-
> First-person:
|
|
4
|
+
> First-person: Tyto to future Tyto. `AGENTS.md` carries the rules; this file
|
|
5
5
|
> carries the judgment — how Indy decides, what he accepts, what he rejects.
|
|
6
6
|
> In force every session; standing orders, not suggestions. Re-read when
|
|
7
7
|
> padding or burying the answer.
|
package/core/operating-model.md
CHANGED
|
@@ -5,13 +5,13 @@ You are this repository's coding agent: deterministic, autonomous, command-line-
|
|
|
5
5
|
<!-- oracle-packs:start persona.indy -->
|
|
6
6
|
**Tone.** Be properly funny — human-funny, dry, actually landing; not a bot doing bits. Swear words are absolutely fine. Never trade technical clarity for either.
|
|
7
7
|
|
|
8
|
-
**Start:** `SOUL.md` (
|
|
8
|
+
**Start:** `SOUL.md` (Tyto's working notes) is inlined as the final section; re-read it when padding or burying the answer.
|
|
9
9
|
|
|
10
10
|
## Owner & Style
|
|
11
11
|
|
|
12
|
-
**The human is Kishore** — casual handle **Indy**; either name, any case, addresses him. Ambiguous "the user" / "they" here = Kishore. The agent is **Oracle**, casual handle **
|
|
12
|
+
**The human is Kishore** — casual handle **Indy**; either name, any case, addresses him. Ambiguous "the user" / "they" here = Kishore. The agent is **Oracle**, casual handle **Tyto**. `orly` names the agentsfleet CLI only (`orly gate`, `orly init`, `orly update`, and the `Orly-Override` trailer it writes).
|
|
13
13
|
|
|
14
|
-
**Address tags.** Kishore: **🤠 Indy**; agent: **🦉
|
|
14
|
+
**Address tags.** Kishore: **🤠 Indy**; agent: **🦉 Tyto** (`Oracle`). **Project name:** `agentsfleet` (domains → `agentsfleet.net`). Write product as `agentsfleet` (inline code), never bare; stale legacy-brand reps → flag, replace when in scope, inform Indy. Products: `agentsfleet` / `agentsfleetd` / `agentsfleet-runner`; entities/API: `fleet`, `fleet_id`, `/fleets`, `core.fleet_*`. Keep: `agentsfleet.dev`, `github.com/agentsfleet/agentsfleet`, `@agentsfleet/*`, `~/Projects/agentsfleet`.
|
|
15
15
|
|
|
16
16
|
MacBook. Languages: Python, Go, Rust, TypeScript, Zig. Tooling: `mise` first, `brew` fallback. Forges: `gh`/`glab`. Commit identity from `git config`, never here.
|
|
17
17
|
|
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
> any narrower guide such as `CHANGELOG_VOICE.md` or before editing documentation
|
|
5
5
|
> about the dispatch model.
|
|
6
6
|
|
|
7
|
-
These rules govern
|
|
7
|
+
These rules govern any documentation orly writes or renders. They keep pages
|
|
8
8
|
short, accurate, and usable by a first-day reader. Compliance is binary. A
|
|
9
9
|
rejection cites the Documentation (DOC) rule identifier that failed.
|
|
10
10
|
|
|
11
11
|
## Scope and precedence
|
|
12
12
|
|
|
13
|
-
- **DOC-S1 —
|
|
14
|
-
|
|
13
|
+
- **DOC-S1 — Any page orly writes.** Every Markdown or Markdown JSX (MDX) file
|
|
14
|
+
orly writes, renders, or materialises follows every applicable rule in this
|
|
15
|
+
document. A published page listed in `docs.json`, a rule page installed into
|
|
16
|
+
a repository, a skill page, and a `README.md` are all in scope.
|
|
15
17
|
- **DOC-S2 — Generated API pages.** OpenAPI source follows the reference-page
|
|
16
18
|
intent through summaries, descriptions, parameters, examples, responses, and
|
|
17
19
|
stable errors. Generated pages do not need MDX front matter.
|
|
@@ -26,6 +28,9 @@ rejection cites the Documentation (DOC) rule identifier that failed.
|
|
|
26
28
|
- **DOC-S6 — Source wins.** Runtime code is the source of truth. OpenAPI and the
|
|
27
29
|
command-line interface follow runtime behavior. Published pages follow all
|
|
28
30
|
three.
|
|
31
|
+
- **DOC-S7 — Records stay as written.** A historical record keeps its original
|
|
32
|
+
wording. Spec records under `docs/v1/` and closed changelog entries are read,
|
|
33
|
+
never rewritten to a later rule.
|
|
29
34
|
|
|
30
35
|
## Required front matter
|
|
31
36
|
|
package/docs/RULE_ENFORCEMENT.md
CHANGED
|
@@ -9,12 +9,12 @@ regenerates this file and fails on any difference. To update it, run
|
|
|
9
9
|
| `docs/LOGGING_STANDARD.md` | `LOG` → `logging.sh` | 10 | 13 | 26/26 | mechanical |
|
|
10
10
|
| `docs/REST_API_DESIGN_GUIDELINES.md` | — | 0 | 0 | 0/74 | mechanical |
|
|
11
11
|
| `docs/SCHEMA_CONVENTIONS.md` | — | 0 | 0 | 0/16 | mechanical |
|
|
12
|
-
| `docs/DOCUMENTATION_RULES.md` | — | 0 | 0 | 0/
|
|
12
|
+
| `docs/DOCUMENTATION_RULES.md` | — | 0 | 0 | 0/21 | latent |
|
|
13
13
|
| `docs/LIFECYCLE_PATTERNS.md` | — | 0 | 0 | 0/22 | mechanical |
|
|
14
14
|
| `docs/CHANGELOG_VOICE.md` | — | 0 | 0 | 0/3 | latent |
|
|
15
15
|
| `docs/VERIFY_TIERS.md` | — | 0 | 0 | 0/4 | uncited |
|
|
16
16
|
| `docs/greptile-learnings/RULES.md` | — | 0 | 0 | 0/113 | mechanical |
|
|
17
|
-
| **corpus** | | | | **26/
|
|
17
|
+
| **corpus** | | | | **26/279** | |
|
|
18
18
|
|
|
19
19
|
## How to read this
|
|
20
20
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Slop fixture
|
|
2
|
+
|
|
3
|
+
This page is the fail fixture for the documentation-language check. Every
|
|
4
|
+
violation below is deliberate, and the test asserts the check finds each one.
|
|
5
|
+
|
|
6
|
+
We leverage a robust and seamless pipeline to execute the request, and the
|
|
7
|
+
result is a powerful experience that lets you delve into a realm of insight
|
|
8
|
+
without ever once thinking about the mechanics of the thing you are using.
|
|
9
|
+
This sentence exists to be long. This one too. And a fourth, to break DOC-03.
|
|
10
|
+
|
|
11
|
+
An em dash — in short copy — is over budget by itself.
|
|
12
|
+
|
|
13
|
+
### Skipped level
|
|
14
|
+
|
|
15
|
+
The heading above jumps from H1 to H3.
|
|
16
|
+
|
|
17
|
+
# Second level-one heading
|
|
18
|
+
|
|
19
|
+
`make harness-verify` and `provision-env-1password` are command names, not
|
|
20
|
+
banned words. The check must leave both alone.
|
|
21
|
+
|
|
22
|
+
| leverage | robust | seamless |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| a table cell is exempt | so is this | and this |
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# a fenced block is exempt: execute the robust and seamless pipeline
|
|
28
|
+
orly gate verify
|
|
29
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentsfleet/orly",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary — for any coding agent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/criteria.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
openQuestions, productClarity, specBaseline, specDeferrals, specDimensions,
|
|
9
9
|
specGate, specMoved, specOrdering,
|
|
10
10
|
} from "./criteria_spec";
|
|
11
|
+
import { documentationSurfaces, scanSurfaces } from "./doc_rules";
|
|
11
12
|
import { isObject, JsonObject, objectValue, OrlyError, RulesModel } from "./model";
|
|
12
13
|
import { readConfigSync, RepoConfig } from "./config";
|
|
13
14
|
import { classifyBranch, SurfaceReport } from "./surfaces";
|
|
@@ -30,6 +31,10 @@ const GIT_TREE = "git.tree";
|
|
|
30
31
|
const GIT_PUSHED = "git.pushed";
|
|
31
32
|
const REPO_CONFIG = "repo.config";
|
|
32
33
|
const DOCS_UPDATED = "docs.updated";
|
|
34
|
+
const DOCS_LANGUAGE = "docs.language";
|
|
35
|
+
// How many findings a report names before it stops listing them. A criterion
|
|
36
|
+
// line is a summary, not a linter dump.
|
|
37
|
+
const NAMED_FINDINGS = 3;
|
|
33
38
|
|
|
34
39
|
const FAST_TIER = "fast";
|
|
35
40
|
const SLOW_TIER = "slow";
|
|
@@ -44,7 +49,7 @@ const SLOW_COMMANDS = ["verify.integration", "verify.memory"];
|
|
|
44
49
|
// pr = can this ship (whole-branch checks + the slow suites).
|
|
45
50
|
export function criteriaFor(gate: string, context: CriterionContext): Criterion[] {
|
|
46
51
|
if (gate === "work") return [gitBranch(), gitTree(), repositoryConfig()];
|
|
47
|
-
if (gate === "verify") return [specDimensions(), ...commandCriteria(context, FAST_TIER)];
|
|
52
|
+
if (gate === "verify") return [specDimensions(), docsLanguage(), ...commandCriteria(context, FAST_TIER)];
|
|
48
53
|
if (gate === "pr") {
|
|
49
54
|
return [
|
|
50
55
|
gitTree(), gitPushed(), specGate(), openQuestions(), productClarity(), specDimensions(),
|
|
@@ -139,6 +144,34 @@ function docsUpdated(): Criterion {
|
|
|
139
144
|
});
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
// docs/DOCUMENTATION_RULES.md, read back against the pages orly renders here.
|
|
148
|
+
// The scope rule is the document's own: any documentation orly writes or
|
|
149
|
+
// renders, README.md included.
|
|
150
|
+
//
|
|
151
|
+
// REPORTING ONLY, and deliberately so. The rule document landed against a
|
|
152
|
+
// corpus that predates it, and the corpus does not obey it yet: this
|
|
153
|
+
// repository alone carries hundreds of pre-existing findings. A red gate on
|
|
154
|
+
// day one would be switched off by the end of the week, and grandfathering
|
|
155
|
+
// them into a baseline file would be a list of chores wearing a gate's badge.
|
|
156
|
+
// So the check reports what it found and leaves the verdict green until a
|
|
157
|
+
// human has decided how the debt gets paid. Partial mechanization stated
|
|
158
|
+
// honestly beats a red that means nothing (the same call audits/doc-read.sh
|
|
159
|
+
// makes when its record is missing). Flipping this to a gate is a one-line
|
|
160
|
+
// change: return ok on `findings.length === 0`.
|
|
161
|
+
function docsLanguage(): Criterion {
|
|
162
|
+
return criterion(DOCS_LANGUAGE, (context) => {
|
|
163
|
+
const surfaces = documentationSurfaces(context.root);
|
|
164
|
+
if (surfaces.length === 0) return { ok: true, detail: "no documentation surface here" };
|
|
165
|
+
const findings = scanSurfaces(context.root, surfaces);
|
|
166
|
+
if (findings.length === 0) return { ok: true, detail: `${surfaces.length} page(s) clean` };
|
|
167
|
+
const named = findings.slice(0, NAMED_FINDINGS).map((finding) => `${finding.file}:${finding.line} ${finding.code}`);
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
detail: `${findings.length} finding(s) across ${surfaces.length} page(s), reporting only: ${named.join(", ")}`,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
// The branch is classified once per inspection and cached on the context.
|
|
143
176
|
function report(context: CriterionContext, surfaces: JsonObject | undefined): SurfaceReport {
|
|
144
177
|
context.surfaces ??= classifyBranch(context.root, surfaces);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { DOC_DASH, DOC_HEADING, DOC_PARAGRAPH, DOC_SENTENCE, DOC_WORD, documentationSurfaces, scanDocument } from "./doc_rules";
|
|
6
|
+
|
|
7
|
+
const ROOT = resolve(import.meta.dir, "..");
|
|
8
|
+
const SLOP_FIXTURE = "fixtures/doc-slop.md";
|
|
9
|
+
const PAGE = "page.md";
|
|
10
|
+
|
|
11
|
+
// The pass fixture: one clean page written to the rules it is checked against.
|
|
12
|
+
// Every line here is prose the check must leave alone, so a false positive
|
|
13
|
+
// anywhere in the reader turns this test red.
|
|
14
|
+
const CLEAN = `# A clean page
|
|
15
|
+
|
|
16
|
+
orly writes this page. The check reads it and finds nothing.
|
|
17
|
+
|
|
18
|
+
## What it leaves alone
|
|
19
|
+
|
|
20
|
+
Command names keep their banned word: run \`make harness-verify\` first.
|
|
21
|
+
A link keeps its text and drops its [target](https://example.com/execute).
|
|
22
|
+
|
|
23
|
+
| leverage | robust |
|
|
24
|
+
|---|---|
|
|
25
|
+
| a table cell is exempt | so is this |
|
|
26
|
+
|
|
27
|
+
\`\`\`bash
|
|
28
|
+
# fenced code is exempt: execute the robust pipeline
|
|
29
|
+
orly gate verify
|
|
30
|
+
\`\`\`
|
|
31
|
+
|
|
32
|
+
The EXECUTE stage is a name, not a verb. A rule may quote “leverage” to ban it.
|
|
33
|
+
|
|
34
|
+
<!-- lint-ignore: DOCWORD — this paragraph names the banned words to ban them -->
|
|
35
|
+
Also banned: delve, foster, elevate, harness, tapestry, realm.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function codes(text: string): string[] {
|
|
39
|
+
return scanDocument(PAGE, text).map((finding) => finding.code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("scanDocument", () => {
|
|
43
|
+
test("passes a clean page", () => {
|
|
44
|
+
expect(scanDocument(PAGE, CLEAN)).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("fails the slop fixture on every mechanical rule", () => {
|
|
48
|
+
const found = codes(readFileSync(join(ROOT, SLOP_FIXTURE), "utf8"));
|
|
49
|
+
|
|
50
|
+
expect(found).toContain(DOC_WORD);
|
|
51
|
+
expect(found).toContain(DOC_DASH);
|
|
52
|
+
expect(found).toContain(DOC_SENTENCE);
|
|
53
|
+
expect(found).toContain(DOC_PARAGRAPH);
|
|
54
|
+
expect(found).toContain(DOC_HEADING);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("names the banned word it found", () => {
|
|
58
|
+
const findings = scanDocument(PAGE, "# Page\n\nWe leverage it.\n");
|
|
59
|
+
|
|
60
|
+
expect(findings).toHaveLength(1);
|
|
61
|
+
expect(findings[0]?.message).toContain("leverage");
|
|
62
|
+
expect(findings[0]?.line).toBe(3);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("counts an over-budget page by its overage", () => {
|
|
66
|
+
const findings = scanDocument(PAGE, "# Page\n\nOne — two — three.\n").filter((finding) => finding.code === DOC_DASH);
|
|
67
|
+
|
|
68
|
+
expect(findings[0]?.weight).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("reads a lowercase product name as a sentence start", () => {
|
|
72
|
+
const paragraph = "# Page\n\nOne here. orly two. bun three. git four.\n";
|
|
73
|
+
|
|
74
|
+
expect(codes(paragraph)).toEqual([DOC_PARAGRAPH]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("leaves a hyphenated command name alone", () => {
|
|
78
|
+
expect(codes("# Page\n\nRun provision-env-1password before the hook.\n")).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("counts a code span as one word, not as a hole", () => {
|
|
82
|
+
const findings = scanDocument(PAGE, "# Page\n\nThe hooks run on every commit and push. `orly gate pr` then refuses it.\n");
|
|
83
|
+
|
|
84
|
+
expect(findings).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("requires exactly one level-one heading", () => {
|
|
88
|
+
expect(codes("## Only a level two\n\nProse.\n")).toContain(DOC_HEADING);
|
|
89
|
+
expect(codes("# One\n\nProse.\n\n# Two\n\nMore prose.\n")).toContain(DOC_HEADING);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("documentationSurfaces", () => {
|
|
94
|
+
test("covers the readme and the pages orly renders", () => {
|
|
95
|
+
const surfaces = documentationSurfaces(ROOT);
|
|
96
|
+
|
|
97
|
+
expect(surfaces).toContain("README.md");
|
|
98
|
+
expect(surfaces).toContain("docs/DOCUMENTATION_RULES.md");
|
|
99
|
+
expect(surfaces.every((relative) => relative.endsWith(".md"))).toBeTrue();
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/doc_rules.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
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
|
+
const README = "README.md";
|
|
41
|
+
const CONFIG_RELATIVE_PATH = ".oracle/orly.json";
|
|
42
|
+
const REGISTRY_FILE = "registry.json";
|
|
43
|
+
const CODE_PLACEHOLDER = "Code";
|
|
44
|
+
const EMPTY = "";
|
|
45
|
+
const CLOSERS = "]\")'";
|
|
46
|
+
const ENDERS = ".!?";
|
|
47
|
+
const SPACE = " ";
|
|
48
|
+
const UTF8 = "utf8";
|
|
49
|
+
const FRONT_MATTER_FENCE = "---";
|
|
50
|
+
// The first capture group, kept when a markdown span is unwrapped.
|
|
51
|
+
const FIRST_GROUP = "$1";
|
|
52
|
+
|
|
53
|
+
// Enumerated, never stemmed: a stemmer that turns "execute" into "executable"
|
|
54
|
+
// bans a correct English word, and one false positive is what gets a check
|
|
55
|
+
// switched off. The inflections that carry the same slop are listed by hand.
|
|
56
|
+
const BANNED_WORDS = new Set([
|
|
57
|
+
"utilise", "utilises", "utilised", "utilising", "utilisation",
|
|
58
|
+
"utilize", "utilizes", "utilized", "utilizing", "utilization",
|
|
59
|
+
"facilitate", "facilitates", "facilitated", "facilitating",
|
|
60
|
+
"leverage", "leverages", "leveraged", "leveraging",
|
|
61
|
+
"orchestrate", "orchestrates", "orchestrated", "orchestrating", "orchestration",
|
|
62
|
+
"instantiate", "instantiates", "instantiated", "instantiating", "instantiation",
|
|
63
|
+
"terminate", "terminates", "terminated", "terminating", "termination",
|
|
64
|
+
"provision", "provisions", "provisioned", "provisioning",
|
|
65
|
+
"execute", "executes", "executed", "executing", "execution", "executions",
|
|
66
|
+
"persist", "persists", "persisted", "persisting", "persistence",
|
|
67
|
+
"hydrate", "hydrates", "hydrated", "hydrating", "hydration",
|
|
68
|
+
"artifact", "artifacts",
|
|
69
|
+
"powerful", "revolutionary", "enterprise-grade", "seamless", "seamlessly",
|
|
70
|
+
"cutting-edge", "robust", "next-generation", "world-class", "intelligent",
|
|
71
|
+
"delve", "delves", "delved", "delving",
|
|
72
|
+
"foster", "fosters", "fostered", "fostering",
|
|
73
|
+
"elevate", "elevates", "elevated", "elevating",
|
|
74
|
+
"harness", "harnesses", "harnessed", "harnessing",
|
|
75
|
+
"supercharge", "supercharges", "supercharged", "supercharging",
|
|
76
|
+
"embark", "embarks", "embarked", "embarking",
|
|
77
|
+
"ever-evolving", "tapestry", "realm", "realms",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const BANNED_PHRASES = ["paradigm shift", "game changer", "game-changer"];
|
|
81
|
+
|
|
82
|
+
// Forms that end in a period without ending a sentence.
|
|
83
|
+
const ABBREVIATIONS = new Set(["e.g.", "i.e.", "etc.", "vs.", "cf.", "no.", "fig.", "approx."]);
|
|
84
|
+
// Product names are written lowercase and open sentences all over these pages.
|
|
85
|
+
// Without this list "…host. orly neither…" reads as one sentence and both
|
|
86
|
+
// length rules quietly under-report. The list is closed on purpose: guessing
|
|
87
|
+
// at lowercase sentence starts in general would split mid-sentence and invent
|
|
88
|
+
// violations.
|
|
89
|
+
const LOWERCASE_OPENERS = new Set(["orly", "gstack", "agentsfleet", "bun", "bunx", "git", "npm"]);
|
|
90
|
+
|
|
91
|
+
// Which DOC identifiers each code stands for, so a `lint-ignore` may name
|
|
92
|
+
// either the code or the rule it enforces.
|
|
93
|
+
const RULE_NAMES: Record<string, string> = {
|
|
94
|
+
[DOC_WORD]: "DOC-05 DOC-07 DOC-14b",
|
|
95
|
+
[DOC_DASH]: "DOC-14b",
|
|
96
|
+
[DOC_SENTENCE]: "DOC-02",
|
|
97
|
+
[DOC_PARAGRAPH]: "DOC-03",
|
|
98
|
+
[DOC_HEADING]: "DOC-25",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// The pages orly renders into a repository, plus its readme. The materialised
|
|
102
|
+
// set is authoritative and it is recorded, not guessed: `.oracle/orly.json`
|
|
103
|
+
// names every file orly wrote here. In the engine's own checkout the registry
|
|
104
|
+
// names the sources those files are rendered from, which is the same set one
|
|
105
|
+
// step earlier. README.md joins either list by the scope rule in
|
|
106
|
+
// docs/DOCUMENTATION_RULES.md — the rules cover any documentation orly writes.
|
|
107
|
+
export function documentationSurfaces(root: string): string[] {
|
|
108
|
+
const found = new Set<string>();
|
|
109
|
+
for (const path of [join(root, REGISTRY_FILE), join(root, CONFIG_RELATIVE_PATH)]) {
|
|
110
|
+
if (!existsSync(path)) continue;
|
|
111
|
+
for (const match of readFileSync(path, UTF8).matchAll(/"([^"\n]+\.md)"/g)) {
|
|
112
|
+
const relative = match[1] ?? EMPTY;
|
|
113
|
+
if (!relative.startsWith("/") && !relative.includes("..")) found.add(relative);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
found.add(README);
|
|
117
|
+
return [...found].filter((relative) => existsSync(join(root, relative))).sort();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function scanSurfaces(root: string, surfaces: string[]): DocFinding[] {
|
|
121
|
+
return surfaces.flatMap((relative) => scanDocument(relative, readFileSync(join(root, relative), UTF8)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function scanDocument(file: string, text: string): DocFinding[] {
|
|
125
|
+
return new Reader(file, text).run();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
type Paragraph = { text: string; line: number; ignore: string };
|
|
129
|
+
|
|
130
|
+
class Reader {
|
|
131
|
+
private readonly findings: DocFinding[] = [];
|
|
132
|
+
private paragraph: Paragraph = { text: EMPTY, line: 0, ignore: EMPTY };
|
|
133
|
+
// A pragma introduces the paragraph under it, so it outlives its own line
|
|
134
|
+
// and dies with the paragraph it was written for.
|
|
135
|
+
private pendingIgnore = EMPTY;
|
|
136
|
+
private readonly declared = new Set<string>();
|
|
137
|
+
private proseWords = 0;
|
|
138
|
+
private dashes = 0;
|
|
139
|
+
private headingLevels: number[] = [];
|
|
140
|
+
private inComment = false;
|
|
141
|
+
private inFence = false;
|
|
142
|
+
private inFrontMatter = false;
|
|
143
|
+
|
|
144
|
+
constructor(private readonly file: string, private readonly text: string) {}
|
|
145
|
+
|
|
146
|
+
run(): DocFinding[] {
|
|
147
|
+
const lines = this.text.split(/\r?\n/);
|
|
148
|
+
for (const [index, raw] of lines.entries()) this.read(raw, index + 1);
|
|
149
|
+
this.flush();
|
|
150
|
+
this.reportHeadings();
|
|
151
|
+
this.reportDashes();
|
|
152
|
+
return this.findings;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private read(raw: string, lineNumber: number): void {
|
|
156
|
+
if (lineNumber === 1 && raw === FRONT_MATTER_FENCE) {
|
|
157
|
+
this.inFrontMatter = true;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (this.inFrontMatter) {
|
|
161
|
+
if (raw === FRONT_MATTER_FENCE) this.inFrontMatter = false;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (/^[ \t]*(```|~~~)/.test(raw)) {
|
|
165
|
+
this.inFence = !this.inFence;
|
|
166
|
+
this.flush();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (this.inFence) return;
|
|
170
|
+
if (this.inComment) {
|
|
171
|
+
if (raw.includes("-->")) this.inComment = false;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// A comment that opens and closes on one line is cut out of the line, not
|
|
175
|
+
// used to drop it: a prose line ending in a pack marker is still prose.
|
|
176
|
+
// One comment is read rather than dropped, `lint-ignore: <CODE> — reason`,
|
|
177
|
+
// the spelling DOC-35 already gives an exception. It clears that code for
|
|
178
|
+
// the paragraph it introduces, and it is how a page that must NAME a
|
|
179
|
+
// banned word says so out loud instead of silently.
|
|
180
|
+
const ignore = /lint-ignore:[ \t]*(DOC[A-Z0-9-]*)/.exec(raw)?.[1] ?? EMPTY;
|
|
181
|
+
if (ignore) {
|
|
182
|
+
this.declared.add(ignore);
|
|
183
|
+
this.pendingIgnore = ignore;
|
|
184
|
+
}
|
|
185
|
+
const active = this.pendingIgnore;
|
|
186
|
+
const line = raw.replace(/<!--.*?-->/g, SPACE);
|
|
187
|
+
if (line.includes("<!--")) {
|
|
188
|
+
this.inComment = true;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Tables are exempt by DOC-13, and a cell is not a sentence.
|
|
192
|
+
if (/^[ \t]*\|/.test(line)) {
|
|
193
|
+
this.flush();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const heading = /^(#{1,6})[ \t]+(.*)$/.exec(line);
|
|
197
|
+
if (heading) {
|
|
198
|
+
this.flush();
|
|
199
|
+
this.readHeading(heading[1]?.length ?? 0, clean(heading[2] ?? EMPTY), lineNumber, active);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (/^[ \t]*$/.test(line)) {
|
|
203
|
+
// Unless the line went blank because it held nothing but a lint-ignore:
|
|
204
|
+
// a pragma on its own line introduces the paragraph under it.
|
|
205
|
+
if (!ignore) this.flush();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (/^[ \t]*([-*+]|[0-9]+[.)])[ \t]/.test(line)) this.flush();
|
|
209
|
+
const prose = clean(line.replace(/^[ \t]*>[ \t]?/, EMPTY).replace(/^[ \t]*([-*+]|[0-9]+[.)])[ \t]+/, EMPTY));
|
|
210
|
+
if (/^[ \t]*$/.test(prose)) return;
|
|
211
|
+
this.scanWords(prose, lineNumber, active);
|
|
212
|
+
this.countDashes(prose);
|
|
213
|
+
if (!this.paragraph.text) this.paragraph = { text: prose, line: lineNumber, ignore: active };
|
|
214
|
+
else this.paragraph.text += `${SPACE}${prose}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private readHeading(level: number, title: string, lineNumber: number, ignore: string): void {
|
|
218
|
+
this.scanWords(title, lineNumber, ignore);
|
|
219
|
+
this.countDashes(title);
|
|
220
|
+
const previous = this.headingLevels[this.headingLevels.length - 1];
|
|
221
|
+
if (previous === undefined) {
|
|
222
|
+
if (level !== 1) this.report(lineNumber, DOC_HEADING, `first heading is H${level}, not H1`, 1, ignore);
|
|
223
|
+
} else if (level > previous + 1) {
|
|
224
|
+
this.report(lineNumber, DOC_HEADING, `heading level jumps H${previous} to H${level}`, 1, ignore);
|
|
225
|
+
}
|
|
226
|
+
this.headingLevels.push(level);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private scanWords(prose: string, lineNumber: number, ignore: string): void {
|
|
230
|
+
this.proseWords += words(prose).length;
|
|
231
|
+
const spoken = unquote(prose);
|
|
232
|
+
for (const token of words(spoken)) {
|
|
233
|
+
// An all-caps token is a name, not vocabulary: EXECUTE is a lifecycle
|
|
234
|
+
// stage, CONFORM is a gate, DOC-05 is a rule identifier. DOC-09 asks for
|
|
235
|
+
// one canonical name per concept, and this is how those names are written.
|
|
236
|
+
if (/^[A-Z0-9_.-]+$/.test(token) && /[A-Z]/.test(token)) continue;
|
|
237
|
+
const word = token.toLowerCase().replace(/^[./-]+/, EMPTY).replace(/[.,;:!?)/-]+$/, EMPTY);
|
|
238
|
+
if (BANNED_WORDS.has(word)) {
|
|
239
|
+
this.report(lineNumber, DOC_WORD, `banned word "${token}" (DOC-05/DOC-07/DOC-14b)`, 1, ignore);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const lowered = spoken.toLowerCase();
|
|
243
|
+
for (const phrase of BANNED_PHRASES) {
|
|
244
|
+
if (lowered.includes(phrase)) this.report(lineNumber, DOC_WORD, `banned phrase "${phrase}" (DOC-14b)`, 1, ignore);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private countDashes(prose: string): void {
|
|
249
|
+
this.dashes += prose.split(EM_DASH).length - 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private flush(): void {
|
|
253
|
+
const { text, line, ignore } = this.paragraph;
|
|
254
|
+
this.paragraph = { text: EMPTY, line: 0, ignore: EMPTY };
|
|
255
|
+
if (!text) return;
|
|
256
|
+
this.pendingIgnore = EMPTY;
|
|
257
|
+
const parts = sentences(text);
|
|
258
|
+
if (parts.length > MAX_PARAGRAPH_SENTENCES) {
|
|
259
|
+
this.report(line, DOC_PARAGRAPH, `${parts.length} sentences in one paragraph, DOC-03 allows ${MAX_PARAGRAPH_SENTENCES}`, 1, ignore);
|
|
260
|
+
}
|
|
261
|
+
for (const sentence of parts) {
|
|
262
|
+
const count = words(sentence).length;
|
|
263
|
+
if (count > MAX_SENTENCE_WORDS) {
|
|
264
|
+
this.report(line, DOC_SENTENCE, `${count} words in one sentence, DOC-02 allows ${MAX_SENTENCE_WORDS}: "${excerpt(sentence)}"`, 1, ignore);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private reportHeadings(): void {
|
|
270
|
+
const first = this.headingLevels.filter((level) => level === 1).length;
|
|
271
|
+
if (first === 0) this.reportPage(DOC_HEADING, "no level-one heading", 1);
|
|
272
|
+
if (first > 1) this.reportPage(DOC_HEADING, `${first} level-one headings, DOC-25 allows one`, first - 1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private reportDashes(): void {
|
|
276
|
+
const short = this.proseWords < SHORT_COPY_WORDS;
|
|
277
|
+
const budget = short ? 0 : LONG_PAGE_DASHES;
|
|
278
|
+
if (this.dashes <= budget) return;
|
|
279
|
+
const where = short ? `short copy (${this.proseWords} prose words), budget 0` : `a long page, budget ${budget}`;
|
|
280
|
+
this.reportPage(DOC_DASH, `${this.dashes} em dashes in ${where}`, this.dashes - budget);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// `weight` is how much debt a finding carries: one for a line-level
|
|
284
|
+
// violation, the overage for a page-level budget. A page five dashes over
|
|
285
|
+
// and a page one dash over are not the same finding.
|
|
286
|
+
private report(line: number, code: string, message: string, weight: number, ignore: string): void {
|
|
287
|
+
if (ignore && allows(ignore, code)) return;
|
|
288
|
+
this.findings.push({ file: this.file, line, code, message, weight });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private reportPage(code: string, message: string, weight: number): void {
|
|
292
|
+
for (const declared of this.declared) if (allows(declared, code)) return;
|
|
293
|
+
this.findings.push({ file: this.file, line: 0, code, message, weight });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function allows(ignore: string, code: string): boolean {
|
|
298
|
+
return ignore === code || (RULE_NAMES[code] ?? EMPTY).includes(ignore);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function words(prose: string): string[] {
|
|
302
|
+
return prose.replace(/[^A-Za-z0-9_/.-]+/g, SPACE).split(/[ \t]+/).filter(Boolean);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Everything the rules exempt comes out before any rule reads the text. An
|
|
306
|
+
// inline code span becomes a placeholder word rather than a hole: a hole loses
|
|
307
|
+
// the capital that starts the next sentence, and the splitter then welds two
|
|
308
|
+
// sentences into one long false positive. A link keeps its text and drops its
|
|
309
|
+
// target, because the text is prose and the target is a path no rule governs.
|
|
310
|
+
function clean(line: string): string {
|
|
311
|
+
return line
|
|
312
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, SPACE)
|
|
313
|
+
.replace(/`[^`]*`/g, CODE_PLACEHOLDER)
|
|
314
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, FIRST_GROUP)
|
|
315
|
+
.replace(/\[([^\]]*)\]\[[^\]]*\]/g, FIRST_GROUP)
|
|
316
|
+
.replace(/https?:\/\/[^\s)>]+/g, SPACE)
|
|
317
|
+
.replace(/<[^>]*>/g, SPACE);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Use versus mention: a word inside quotation marks is being named, not used.
|
|
321
|
+
// A rule that bans a word has to print it to ban it, and a page quoting a
|
|
322
|
+
// caller is reporting speech rather than writing it.
|
|
323
|
+
function unquote(prose: string): string {
|
|
324
|
+
return prose.replace(/“[^”]*”/g, SPACE).replace(/"[^"]*"/g, SPACE);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function excerpt(sentence: string): string {
|
|
328
|
+
const limit = 57;
|
|
329
|
+
return sentence.length > limit + 3 ? `${sentence.slice(0, limit)}...` : sentence;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// A period, question mark, or exclamation mark, then optional closing brackets
|
|
333
|
+
// or quotes, then a space, then the next sentence. What follows the space
|
|
334
|
+
// decides: a lowercase letter, a digit, or more punctuation continues the
|
|
335
|
+
// sentence, unless the word is one of the lowercase product names.
|
|
336
|
+
function sentences(paragraph: string): string[] {
|
|
337
|
+
const parts: string[] = [];
|
|
338
|
+
let current = EMPTY;
|
|
339
|
+
for (let index = 0; index < paragraph.length; index += 1) {
|
|
340
|
+
const character = paragraph[index] ?? EMPTY;
|
|
341
|
+
current += character;
|
|
342
|
+
if (!ENDERS.includes(character)) continue;
|
|
343
|
+
while (CLOSERS.includes(paragraph[index + 1] ?? EMPTY) && index + 1 < paragraph.length) {
|
|
344
|
+
index += 1;
|
|
345
|
+
current += paragraph[index];
|
|
346
|
+
}
|
|
347
|
+
const gap = paragraph[index + 1] ?? EMPTY;
|
|
348
|
+
if (gap !== SPACE && gap !== "\t" && gap !== EMPTY) continue;
|
|
349
|
+
const rest = paragraph.slice(index + 2);
|
|
350
|
+
if (gap !== EMPTY && /^[a-z0-9,;:]/.test(rest) && !LOWERCASE_OPENERS.has(/^[A-Za-z0-9_-]+/.exec(rest)?.[0] ?? EMPTY)) continue;
|
|
351
|
+
const previous = current.split(/[ \t]/).pop() ?? EMPTY;
|
|
352
|
+
if (ABBREVIATIONS.has(previous.toLowerCase()) || /^[A-Za-z]\.$/.test(previous)) continue;
|
|
353
|
+
parts.push(current.trim());
|
|
354
|
+
current = EMPTY;
|
|
355
|
+
}
|
|
356
|
+
if (current.trim()) parts.push(current.trim());
|
|
357
|
+
return parts;
|
|
358
|
+
}
|
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",
|