@erclx/aitk 0.64.0 → 0.65.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/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +27 -27
- package/docs/agents/context-audit-checks.md +20 -2
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/markdown-audit.md +12 -2
- package/governance/rules/claude/510-context.md +12 -0
- package/package.json +1 -1
- package/src/commands/context.ts +103 -4
- package/src/context/audit.ts +152 -1
- package/src/context/narration.ts +99 -0
- package/src/markdown/scan.ts +81 -5
- package/src/markdown/structure.ts +19 -11
package/docs/agents/commands.md
CHANGED
|
@@ -9,33 +9,33 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
9
9
|
|
|
10
10
|
## Project-level
|
|
11
11
|
|
|
12
|
-
| Command | Purpose
|
|
13
|
-
| -------------------------- |
|
|
14
|
-
| `aitk init [path]` | Bootstrap a project with selected toolkit domains
|
|
15
|
-
| `aitk sync [path]` | Sync all installed domains in a target project
|
|
16
|
-
| `aitk sync --check` | Report toolkit drift without writing (`--json`, `--exit-code`)
|
|
17
|
-
| `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads
|
|
18
|
-
| `aitk sandbox reset` | Reset sandbox to baseline
|
|
19
|
-
| `aitk sandbox clean` | Wipe the sandbox
|
|
20
|
-
| `aitk sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict)
|
|
21
|
-
| `aitk sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`)
|
|
22
|
-
| `aitk indexes regen` | Regenerate `index.md` files from sibling frontmatter
|
|
23
|
-
| `aitk docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name)
|
|
24
|
-
| `aitk design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS
|
|
25
|
-
| `aitk slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck
|
|
26
|
-
| `aitk slides list` | List the available slide layouts (`--json` for the catalog)
|
|
27
|
-
| `aitk feedback` | Write toolkit feedback from stdin to `.claude/review/`, or open a GitHub issue with `--github`
|
|
28
|
-
| `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`)
|
|
29
|
-
| `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index
|
|
30
|
-
| `aitk tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`)
|
|
31
|
-
| `aitk tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`)
|
|
32
|
-
| `aitk tasks validate` | Report board rows whose plan, task file, group, or file set does not hold (`--json`)
|
|
33
|
-
| `aitk records validate` | Report a session record against the standard governing it, per kind (`--json`)
|
|
34
|
-
| `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git
|
|
35
|
-
| `aitk context audit` | Report required sections, length, cited paths, catalog tables, provenance, and index drift
|
|
36
|
-
| `aitk markdown audit` | Report any markdown path against the character bans, word bans, and structural checkpoints
|
|
37
|
-
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md`
|
|
38
|
-
| `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package
|
|
12
|
+
| Command | Purpose |
|
|
13
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
14
|
+
| `aitk init [path]` | Bootstrap a project with selected toolkit domains |
|
|
15
|
+
| `aitk sync [path]` | Sync all installed domains in a target project |
|
|
16
|
+
| `aitk sync --check` | Report toolkit drift without writing (`--json`, `--exit-code`) |
|
|
17
|
+
| `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
|
|
18
|
+
| `aitk sandbox reset` | Reset sandbox to baseline |
|
|
19
|
+
| `aitk sandbox clean` | Wipe the sandbox |
|
|
20
|
+
| `aitk sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
|
|
21
|
+
| `aitk sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
|
|
22
|
+
| `aitk indexes regen` | Regenerate `index.md` files from sibling frontmatter |
|
|
23
|
+
| `aitk docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
|
|
24
|
+
| `aitk design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
|
|
25
|
+
| `aitk slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck |
|
|
26
|
+
| `aitk slides list` | List the available slide layouts (`--json` for the catalog) |
|
|
27
|
+
| `aitk feedback` | Write toolkit feedback from stdin to `.claude/review/`, or open a GitHub issue with `--github` |
|
|
28
|
+
| `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
|
|
29
|
+
| `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
|
|
30
|
+
| `aitk tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
|
|
31
|
+
| `aitk tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
|
|
32
|
+
| `aitk tasks validate` | Report board rows whose plan, task file, group, or file set does not hold (`--json`) |
|
|
33
|
+
| `aitk records validate` | Report a session record against the standard governing it, per kind (`--json`) |
|
|
34
|
+
| `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
|
|
35
|
+
| `aitk context audit` | Report required sections, length, cited paths, catalog tables, provenance, superseded-decision narration, and index drift |
|
|
36
|
+
| `aitk markdown audit` | Report any markdown path against the character bans, word bans, and structural checkpoints |
|
|
37
|
+
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
|
38
|
+
| `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
|
|
39
39
|
|
|
40
40
|
## Domain commands
|
|
41
41
|
|
|
@@ -19,7 +19,7 @@ It reports rather than gates by default, the closer call because a missing secti
|
|
|
19
19
|
|
|
20
20
|
## Length
|
|
21
21
|
|
|
22
|
-
Length quotes its checkpoint from `.claude/standards/context.md`: roughly 150 rendered lines for an entry. It counts rendered lines rather than source lines, wrapping each line at 80 columns and summing the heights. Entries here are authored one line per bullet, so a block of fifteen paragraph-bullets occupies fifteen source lines and renders past sixty, which source counting cannot see. The measure counts fenced blocks and frontmatter, so a reference-heavy entry ranks by its examples, which the legend states on every run alongside the width, since a number in rendered lines cannot be reproduced without it.
|
|
22
|
+
Length quotes its checkpoint from `.claude/standards/context.md`: roughly 150 rendered lines for an entry. It counts rendered lines rather than source lines, wrapping each line at 80 columns and summing the heights. A line is wrapped at the width it renders at, so a link counts as its anchor text and a backticked path counts whole. That moves 2 of the 48 entries measured, `extensions.md` by 2 lines and `governance.md` by 1, because an entry cites a path in backticks rather than as a link. Entries here are authored one line per bullet, so a block of fifteen paragraph-bullets occupies fifteen source lines and renders past sixty, which source counting cannot see. The measure counts fenced blocks and frontmatter, so a reference-heavy entry ranks by its examples, which the legend states on every run alongside the width, since a number in rendered lines cannot be reproduced without it.
|
|
23
23
|
|
|
24
24
|
Depth and bullet weight are quoted from `.claude/standards/markdown.md`, which states both over every markdown file rather than over a context entry, so `aitk markdown audit` measures them and this command no longer does. They share `renderedHeight` with the length measure, since the two checkpoints sit in one section of that standard and a reader compares them. What the split costs is that a session wanting both numbers for one entry runs two commands, and what it buys is that either number can be had for a file in a folder this audit refuses to resolve.
|
|
25
25
|
|
|
@@ -31,9 +31,27 @@ The table check reports a catalog that grows a row per shipped thing, not a tabl
|
|
|
31
31
|
|
|
32
32
|
The provenance check reports the markers narrating how a domain reached its shape rather than describing what it is: a date, a change number, or a release label. The standard admits a rejected alternative and the reasoning that killed it while refusing the provenance attached to it, so a marker names a line to read rather than a line to delete. Findings group by entry and sort left to right within a line, since what a reader acts on is which file to open. Fenced blocks are excluded, which keeps a pinned version in an install command from reading as a claim the entry makes. Frontmatter is excluded with them, since the content checks read the body alone, and that is what keeps a diagram entry's dated `verified` stamp a record of its last check rather than a marker to settle. Length is the exception, counting the whole file, so a reader applying the 150-rendered-line checkpoint against the body alone lands a few lines under what the tool reports.
|
|
33
33
|
|
|
34
|
+
## Narration
|
|
35
|
+
|
|
36
|
+
The narration check reports a bullet that states the design a sibling bullet replaced instead of rewriting it. `.claude/standards/context.md` asks for the rewrite because the subject is still live and two bullets on one subject leave a reader to work out which of them is current, and no other measure sees that shape.
|
|
37
|
+
|
|
38
|
+
It reads structure rather than words, which the corpus decided. Measured across the 39 entries this toolkit held the day it shipped, the terms carrying clean signal for a supersession are too rare to catch anything: `superseded` appears twice, `previously` three times, and `formerly`, `originally`, and `at first` never. The one term that would have caught the case a review caught by hand is `now`, which appears 57 times across 24 entries in correct present-tense prose. A list including it reports 57 lines to catch one, and a list excluding it reports nothing.
|
|
39
|
+
|
|
40
|
+
What it matches instead is a bullet doing three things at once: opening with a pronoun whose antecedent is the bullet above it, carrying a past-tense verb, and following another top-level bullet. All three are required. Eight bullets in the corpus open with a back-reference, and the verb set narrows those to one. The pronoun is matched cased and anchored to the opening, since a mid-sentence `this` is a determiner rather than a reference back. The verb is matched uncased anywhere in the bullet, and rejected when a copula sits in front of it, since `is used to resolve the folder` is the passive of `use` rather than the past habitual the set means. Both sets are published under `## Narration pronouns` and `## Narration verbs` in a governance rule and read at run time, so widening either costs a rule edit rather than a TypeScript change. The copula list stays in code, because it is English grammar rather than corpus vocabulary and a rule publishing two of three headings would be another absent state to carry. Discovery keys on the headings rather than the filename, because rules are numbered and a renumber would empty the sets while the check kept reporting clean. A run finding no rule that publishes both says it scanned nothing rather than reporting clean.
|
|
41
|
+
|
|
42
|
+
A blank line does not end the run. Markdown reads the bullets around one as a single loose list, so a walker that broke there would leave the shape reachable by anyone who spaced their bullets out. What ends a run is content that is neither a bullet nor indented under one, which is what keeps the first bullet under a heading from reading as a reply to the last bullet above it.
|
|
43
|
+
|
|
44
|
+
A fence answers that test for itself, because every scan here skips a fenced line before reaching it. An unindented fence ends the run, since CommonMark reads one at column zero as interrupting the list and the bullets around it are then two lists with no antecedent crossing between them. A fence indented under its bullet stays inside the item and leaves the run intact.
|
|
45
|
+
|
|
46
|
+
The opening delimiter decides that for the whole block rather than each line deciding for itself. A blank line inside an indented fence carries no indentation to read, and a content line may sit at column zero inside one because CommonMark strips the fence's own indent and nothing further. Reading either as unindented ends a run that should have continued, which costs findings rather than inventing them, so no corpus count moves when it is wrong.
|
|
47
|
+
|
|
48
|
+
Two fenced blocks with nothing between them read as one, so the second takes the first's answer. An unindented block written directly behind an indented one therefore leaves a run standing that it should have ended, which is a false positive rather than a missed finding. Separating the two needs the closing delimiter, which the line walker does not report, and no entry writes adjacent fences today.
|
|
49
|
+
|
|
50
|
+
Precision is the whole value, so recall is the accepted exposure, and two shapes are knowingly out of reach. A narration written as one bullet carrying its own before and after slips through, and nothing else sees it either. So does the perfect passive, since `has been superseded` narrates a supersession and the copula guard rejects it with the passives it exists for. A rejected alternative is a back-reference in the past tense by construction, and the standard keeps what was tried and why it lost, so a legitimate hit exists and no measure separates it from a violation. The report states that on every run, which is why the finding names a line to read rather than a line to delete. The JSON record carries the findings per entry as `entries[].narration` and the sets as `checkpoints.narration`, which is absent under `--citations-only` where the run never loads them and null where no rule publishes both.
|
|
51
|
+
|
|
34
52
|
## Which folders each check reaches
|
|
35
53
|
|
|
36
|
-
The provenance
|
|
54
|
+
The provenance, required-section, and narration checks cover `.claude/context/` alone, while length and the table finding reach every audited folder. What narrows the three is stated in `.claude/standards/context.md`, which opens its scope by handing diagrams and wireframes to `diagrams.md` and `wireframes.md`, and the sibling standards do not restate it. A marker reported in a diagram entry would cite a rule that entry's own standard routes elsewhere, and a diagram entry carries a heading per kind rather than a run of bullets deciding anything. The split is between kinds of rule rather than kinds of folder, and what decides it is which tier states the rule rather than what the check measures. Length and the table finding generalize as judgments about how far a reader travels, so both reach wherever the audit is pointed. Required sections narrow for a plainer reason: the names are the context standard's own, and neither sibling standard states a required section at all.
|
|
37
55
|
|
|
38
56
|
The same test is what moved depth and bullet weight out of this command entirely. A rule stated at the attribute tier reaches every markdown file, and a check reaching every markdown file has no reason to require a folder that resolves. The scoping key is the folder an entry was audited under, so `--folder` still reaches a folder the default list does not carry, and a domain split into `context/<sub-area>/` is governed as `context`. Every run states the reach, including a run where no audited folder is the governed one. The JSON record carries it as `checkpoints.provenanceFolder` and a per-folder `governsContent`.
|
|
39
57
|
|
|
@@ -37,7 +37,7 @@ A run where no requested name resolves refuses, whichever list it read. Naming t
|
|
|
37
37
|
|
|
38
38
|
## Exit codes
|
|
39
39
|
|
|
40
|
-
Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a gating finding. An unresolved citation gates under every mode. Length, table, and
|
|
40
|
+
Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a gating finding. An unresolved citation gates under every mode. Length, table, provenance, and narration findings print and return `0` under every mode, because each is a judgment and failing a push on one would make the check something to route around. Narration is the weakest of the four, since whether two bullets share a subject is a call the measure approximates from structure alone, and one of the shapes it matches is the rejected alternative the standard asks an entry to keep.
|
|
41
41
|
|
|
42
42
|
Required-section and index findings sit between the two. Both are answerable from the file rather than weighed, so `--gate` promotes them to failing codes while a bare run leaves them advisory. The toolkit runs the bare form against itself and the widened form against the seed tree, described below.
|
|
43
43
|
|
|
@@ -41,13 +41,21 @@ Deriving the spellings rather than pattern-matching a suffix is what keeps `exer
|
|
|
41
41
|
|
|
42
42
|
Frontmatter, fenced blocks, inline code spans, and link destinations are excluded. Without the code-span exclusion each standard would report its own backticked examples, and without the link exclusion a semicolon in a query string would report as prose no rewrite can fix.
|
|
43
43
|
|
|
44
|
+
A banned word is bounded on a word character or a hyphen either side. A plain word boundary sits after a hyphen, so a banned word ending a hyphenated compound reported from inside one, and a compound is a single word to the reader who wrote it.
|
|
45
|
+
|
|
46
|
+
A banned spelling keeps the plain word boundary, hyphens included. The two bans target different things: a word ban targets the word, so reading a compound as one word is correct, while a spelling ban targets the orthography inside it, which sits in `behaviour-driven` as plainly as it sits alone.
|
|
47
|
+
|
|
44
48
|
Two ban shapes stay unmeasured and the report says so on every run. A phrase ban carries a placeholder standing in for the rest of the sentence, so no literal match reaches it, and every rule under `## Voice` is a judgment. A report listing hits without naming those would read as a verdict on the whole standard.
|
|
45
49
|
|
|
46
50
|
### Bullets, paragraphs, and depth
|
|
47
51
|
|
|
48
52
|
Bullet weight and depth are the checks that moved off `aitk context audit`, unchanged in what they measure. A top-level bullet reports past roughly 400 characters with continuation lines folded in and nested items left out. A run of lines no heading breaks reports past roughly 40 rendered lines, measured at 80 columns, skipping fenced blocks and exempting a flat peer list averaging under 130 characters a bullet and a run that is entirely table rows.
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
Every weight and depth measure counts the text a reader is shown. A link reduces to its anchor text and an autolink drops whole, since no reader is shown either destination. A backticked path stays counted, which is where these measures part from the ban scan above: that one blanks a code span so a standard quoting its own banned character does not report itself, and discounting the same span here would under-report a paragraph carrying several. One file holds both span sets and each answers its own question.
|
|
55
|
+
|
|
56
|
+
A code span is walked around rather than through, so a path quoting link or angle-bracket syntax keeps the width the page gives it. Masking inside one takes back the decision to count it, and the placeholders this toolkit writes are where that shows.
|
|
57
|
+
|
|
58
|
+
The paragraph check is new and measures both halves of one rule. `markdown.md` caps a paragraph at four sentences, and a sentence cap on its own is satisfied by writing fewer and longer ones: measured across 3374 paragraphs, 486 sit inside four sentences and past 400 characters, and the heaviest of those runs 1121 characters. The standard therefore states a weight beside the sentence cap, and the verb reads it as its own checkpoint.
|
|
51
59
|
|
|
52
60
|
The paragraph weight and the bullet weight are the same number today because the two shapes measure one population, sharing a median near 170 characters with no gap behind either candidate. They are separate checkpoints in the standard and separate patterns in the parser, so retuning one leaves the other where it is.
|
|
53
61
|
|
|
@@ -59,7 +67,9 @@ Exit codes are `0` for a completed run and `1` for a refusal. Every finding repo
|
|
|
59
67
|
|
|
60
68
|
A banned character is a fact rather than a judgment, which is the test that would ordinarily make it gate. What holds it back is that gating on day one against a corpus never checked mechanically fails loudly on work nobody has had a chance to fix. The order is to land the verb reporting, measure the corpus once, fix what it finds, and turn the gate on as its own change. Bullet, paragraph, and depth weight are judgments and stay advisory under any later gate.
|
|
61
69
|
|
|
62
|
-
Measured across
|
|
70
|
+
Measured across 444 files once the weight measures moved to visible text: 8 word hits, no character or spelling hits, 119 heavy bullets, 615 heavy paragraphs, and 40 files carrying a run past the depth checkpoint. The ban count is what a gate would have to hold at zero, and it is the only one of the five a gate should ever read.
|
|
71
|
+
|
|
72
|
+
Masking took 7 of the 493 weight-only paragraphs and 4 of the 44 files under their checkpoints, and no bullet at all. The first corpus triage put those at 31 paragraphs and 2 bullets, and neither reproduces: a code span is walked around, so a backticked path holding an angle-bracket placeholder keeps the width the page gives it, and both bullets the triage counted were that shape.
|
|
63
73
|
|
|
64
74
|
## What it does not cover
|
|
65
75
|
|
|
@@ -12,6 +12,18 @@ paths:
|
|
|
12
12
|
- Leave the entry conforming when work in its domain changes what it describes.
|
|
13
13
|
- Rewrite the decision a change supersedes rather than appending a second one narrating the transition.
|
|
14
14
|
|
|
15
|
+
## Narration pronouns
|
|
16
|
+
|
|
17
|
+
Do not open a bullet with one of these where the antecedent is the bullet above it.
|
|
18
|
+
|
|
19
|
+
- `It`, `That`, `This`, `These`, `Those`, `They`
|
|
20
|
+
|
|
21
|
+
## Narration verbs
|
|
22
|
+
|
|
23
|
+
Do not put a sibling bullet's design in the past with one of these.
|
|
24
|
+
|
|
25
|
+
- `was`, `were`, `became`, `replaced`, `superseded`, `used to`
|
|
26
|
+
|
|
15
27
|
## Authority
|
|
16
28
|
|
|
17
29
|
- Follow `.claude/standards/context.md` for entry shape: frontmatter, sections, length, and what goes in. It is the single source.
|
package/package.json
CHANGED
package/src/commands/context.ts
CHANGED
|
@@ -19,6 +19,12 @@ import {
|
|
|
19
19
|
} from '@/context/folders'
|
|
20
20
|
import { isGating } from '@/context/gate'
|
|
21
21
|
import { auditIndexes, type FolderDrift } from '@/context/index-drift'
|
|
22
|
+
import {
|
|
23
|
+
loadNarration,
|
|
24
|
+
type Narration,
|
|
25
|
+
PRONOUN_HEADING,
|
|
26
|
+
VERB_HEADING,
|
|
27
|
+
} from '@/context/narration'
|
|
22
28
|
import { RENDER_WIDTH } from '@/markdown/structure'
|
|
23
29
|
import {
|
|
24
30
|
frameError,
|
|
@@ -54,7 +60,7 @@ export function register(program: Command): void {
|
|
|
54
60
|
context
|
|
55
61
|
.command('audit')
|
|
56
62
|
.description(
|
|
57
|
-
'Report required sections, entry length, citations, catalog tables, provenance, and index drift',
|
|
63
|
+
'Report required sections, entry length, citations, catalog tables, provenance, superseded-decision narration, and index drift',
|
|
58
64
|
)
|
|
59
65
|
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
60
66
|
.helpOption('-h, --help', 'Show this help message')
|
|
@@ -79,8 +85,8 @@ export function register(program: Command): void {
|
|
|
79
85
|
'',
|
|
80
86
|
'An unresolved citation always gates. --gate widens the gate to the',
|
|
81
87
|
'other two findings that are facts rather than judgments: a missing',
|
|
82
|
-
'required section and index drift. Length, table, and
|
|
83
|
-
'findings are
|
|
88
|
+
'required section and index drift. Length, table, provenance, and',
|
|
89
|
+
'narration findings are judgments and stay advisory under both.',
|
|
84
90
|
'',
|
|
85
91
|
'Depth and bullet weight are stated over every markdown file rather',
|
|
86
92
|
'than over a context entry, so `aitk markdown audit` measures them.',
|
|
@@ -180,7 +186,16 @@ async function runAudit(
|
|
|
180
186
|
)
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
const
|
|
189
|
+
const narration: Narration = gateOnly
|
|
190
|
+
? { kind: 'absent' }
|
|
191
|
+
: await loadNarration(root)
|
|
192
|
+
const entries = gateOnly
|
|
193
|
+
? []
|
|
194
|
+
: await measureFolders(
|
|
195
|
+
root,
|
|
196
|
+
folders,
|
|
197
|
+
narration.kind === 'loaded' ? narration : undefined,
|
|
198
|
+
)
|
|
184
199
|
const drift = gateOnly ? [] : await auditIndexes(folders)
|
|
185
200
|
const sections = gateOnly ? [] : missingSections(root, folders, entries)
|
|
186
201
|
|
|
@@ -194,6 +209,7 @@ async function runAudit(
|
|
|
194
209
|
reportLength(entries)
|
|
195
210
|
reportTables(entries)
|
|
196
211
|
reportProvenance(entries, folders)
|
|
212
|
+
reportNarration(entries, folders, narration)
|
|
197
213
|
reportDrift(drift)
|
|
198
214
|
outro()
|
|
199
215
|
}
|
|
@@ -222,6 +238,20 @@ async function runAudit(
|
|
|
222
238
|
renderWidth: RENDER_WIDTH,
|
|
223
239
|
provenanceFolder: PROVENANCE_FOLDER,
|
|
224
240
|
requiredSections: REQUIRED_SECTIONS,
|
|
241
|
+
// Three states rather than two, so a record showing no finding says
|
|
242
|
+
// which terms were looked for. The key is absent when the run never
|
|
243
|
+
// scanned, which is `--citations-only`, and null when it scanned and
|
|
244
|
+
// no rule published both headings. Collapsing the first into null
|
|
245
|
+
// reports an absent rule against a run that never opened one.
|
|
246
|
+
narration: gateOnly
|
|
247
|
+
? undefined
|
|
248
|
+
: narration.kind === 'loaded'
|
|
249
|
+
? {
|
|
250
|
+
source: narration.source,
|
|
251
|
+
pronouns: narration.pronouns,
|
|
252
|
+
verbs: narration.verbs,
|
|
253
|
+
}
|
|
254
|
+
: null,
|
|
225
255
|
},
|
|
226
256
|
})}\n`,
|
|
227
257
|
)
|
|
@@ -486,6 +516,75 @@ function reportProvenance(
|
|
|
486
516
|
)
|
|
487
517
|
}
|
|
488
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Reports the bullets narrating a decision the bullet above them replaced.
|
|
521
|
+
*
|
|
522
|
+
* The reach line names the rule the sets were read from, since the check is
|
|
523
|
+
* silent when no rule publishes them and a run that scanned nothing otherwise
|
|
524
|
+
* prints the same clean line as a run that scanned everything.
|
|
525
|
+
*
|
|
526
|
+
* The legitimate-hit line is here for the same reason the provenance section
|
|
527
|
+
* says a marker is a judgment. The standard keeps a rejected alternative and
|
|
528
|
+
* why it lost, which is a back-reference in the past tense by construction, and
|
|
529
|
+
* no measure separates one from the shape the rule bans.
|
|
530
|
+
*/
|
|
531
|
+
function reportNarration(
|
|
532
|
+
entries: readonly EntryReport[],
|
|
533
|
+
folders: readonly AuditedFolder[],
|
|
534
|
+
narration: Narration,
|
|
535
|
+
): void {
|
|
536
|
+
logStep('Narration')
|
|
537
|
+
|
|
538
|
+
const governed = folders.filter(governsContent)
|
|
539
|
+
if (governed.length === 0) {
|
|
540
|
+
logInfo(
|
|
541
|
+
`Out of scope. The rule is stated in the standard governing .claude/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
|
|
542
|
+
)
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (narration.kind === 'absent') {
|
|
547
|
+
logWarn(
|
|
548
|
+
`Not scanned. No rule under .claude/rules/ or governance/rules/ publishes both ${PRONOUN_HEADING} and ${VERB_HEADING}.`,
|
|
549
|
+
)
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
logInfo(
|
|
554
|
+
`Covers .claude/${PROVENANCE_FOLDER}/ alone, reading ${plural(narration.pronouns.length, 'pronoun')} and ${plural(narration.verbs.length, 'verb')} from ${narration.source}.`,
|
|
555
|
+
)
|
|
556
|
+
logInfo(
|
|
557
|
+
'A rejected alternative is a legitimate hit, since the standard keeps what was tried and why it lost.',
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
const carrying = entries
|
|
561
|
+
.filter((entry) => entry.narration.length > 0)
|
|
562
|
+
.sort((a, b) => b.narration.length - a.narration.length)
|
|
563
|
+
|
|
564
|
+
if (carrying.length === 0) {
|
|
565
|
+
logInfo('No bullet narrates a decision the bullet above it replaced.')
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const total = carrying.reduce((sum, entry) => sum + entry.narration.length, 0)
|
|
570
|
+
logWarn(
|
|
571
|
+
`${plural(total, 'bullet')} to read across ${carrying.length} ${carrying.length === 1 ? 'entry' : 'entries'}`,
|
|
572
|
+
)
|
|
573
|
+
pipeOutput(
|
|
574
|
+
carrying
|
|
575
|
+
.map(
|
|
576
|
+
(entry) =>
|
|
577
|
+
`${entry.rel} ${plural(entry.narration.length, 'bullet')}\n${entry.narration
|
|
578
|
+
.map(
|
|
579
|
+
(found) =>
|
|
580
|
+
` :${found.line} ${found.pronoun} with ${found.verb}`,
|
|
581
|
+
)
|
|
582
|
+
.join('\n')}`,
|
|
583
|
+
)
|
|
584
|
+
.join('\n'),
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
489
588
|
function reportDrift(drift: readonly FolderDrift[]): void {
|
|
490
589
|
logStep('Index drift')
|
|
491
590
|
|
package/src/context/audit.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { relative } from 'node:path'
|
|
3
3
|
import type { AuditedFolder } from '@/context/folders'
|
|
4
|
-
import
|
|
4
|
+
import type { NarrationTerms } from '@/context/narration'
|
|
5
|
+
import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
|
|
5
6
|
import { renderedHeight } from '@/markdown/structure'
|
|
6
7
|
import { isStubSeed } from '@/seed-marker'
|
|
7
8
|
|
|
@@ -46,6 +47,10 @@ const TABLE_ROW = /^\s*\|/
|
|
|
46
47
|
const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
|
|
47
48
|
const NAMED_CELL = /`[^`]+`|\[[^\]]+\]\([^)]+\)/
|
|
48
49
|
|
|
50
|
+
const TOP_BULLET = /^-\s+(.+)$/
|
|
51
|
+
/** Any indented line, which is a nested bullet or a wrapped continuation. */
|
|
52
|
+
const INSIDE_LIST = /^\s+\S/
|
|
53
|
+
|
|
49
54
|
/**
|
|
50
55
|
* Spellings of how the domain reached its shape rather than what it is now.
|
|
51
56
|
*
|
|
@@ -64,6 +69,12 @@ const PROVENANCE: readonly { kind: ProvenanceKind; pattern: RegExp }[] = [
|
|
|
64
69
|
/**
|
|
65
70
|
* The folder whose standard carries the exclusion above.
|
|
66
71
|
*
|
|
72
|
+
* Three measures narrow here rather than one: provenance, the required
|
|
73
|
+
* sections, and the superseded-decision narration. The name is the first of
|
|
74
|
+
* them because it was the first, and it stays because renaming a constant the
|
|
75
|
+
* JSON record publishes as `checkpoints.provenanceFolder` breaks every consumer
|
|
76
|
+
* reading that field for a gain of one word.
|
|
77
|
+
*
|
|
67
78
|
* `standards/context.md` opens its scope by handing diagrams and wireframes to
|
|
68
79
|
* `diagrams.md` and `wireframes.md`, so a marker reported in either would cite
|
|
69
80
|
* a rule that entry's own standard routes elsewhere. The length and table
|
|
@@ -98,6 +109,14 @@ export interface ProvenanceFinding {
|
|
|
98
109
|
readonly text: string
|
|
99
110
|
}
|
|
100
111
|
|
|
112
|
+
export interface NarrationFinding {
|
|
113
|
+
readonly line: number
|
|
114
|
+
/** The opening that points back at the bullet above. */
|
|
115
|
+
readonly pronoun: string
|
|
116
|
+
/** The past-tense verb that turns the back-reference into a narration. */
|
|
117
|
+
readonly verb: string
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
export interface EntryReport {
|
|
102
121
|
readonly rel: string
|
|
103
122
|
/**
|
|
@@ -116,6 +135,12 @@ export interface EntryReport {
|
|
|
116
135
|
readonly catalogTables: readonly TableFinding[]
|
|
117
136
|
/** Empty for an entry no standard bans a change narrative in. */
|
|
118
137
|
readonly provenance: readonly ProvenanceFinding[]
|
|
138
|
+
/**
|
|
139
|
+
* Empty outside the governed folder, and empty on a run whose caller loaded
|
|
140
|
+
* no term sets. The report distinguishes the two from the vocabulary itself,
|
|
141
|
+
* since an empty list here is silent about which one produced it.
|
|
142
|
+
*/
|
|
143
|
+
readonly narration: readonly NarrationFinding[]
|
|
119
144
|
/**
|
|
120
145
|
* Required sections this entry declares, in the standard's order, and empty
|
|
121
146
|
* outside the folder whose standard names them. What the folder is short of
|
|
@@ -226,6 +251,128 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
|
|
|
226
251
|
.map((each) => each.finding)
|
|
227
252
|
}
|
|
228
253
|
|
|
254
|
+
function escape(term: string): string {
|
|
255
|
+
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Copulas that turn the verb behind them into a present-tense passive.
|
|
260
|
+
*
|
|
261
|
+
* `used to` is the term this exists for, since `is used to resolve the folder`
|
|
262
|
+
* is the passive of `use` rather than the past habitual the set means. This
|
|
263
|
+
* list is English grammar rather than corpus vocabulary, so it stays in code
|
|
264
|
+
* while the two tunable sets stay in the rule, and a rule publishing two of
|
|
265
|
+
* three headings would be a fourth absent state to carry for no tuning anyone
|
|
266
|
+
* wants.
|
|
267
|
+
*
|
|
268
|
+
* What it costs is the perfect passive. `has been superseded` narrates a
|
|
269
|
+
* supersession and is rejected with the rest, which is a recall gap taken
|
|
270
|
+
* knowingly on the trade this check already makes everywhere else.
|
|
271
|
+
*/
|
|
272
|
+
const COPULA = ['is', 'are', 'be', 'been', 'being', 'was', 'were']
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Finds the bullets narrating a decision the bullet above them replaced.
|
|
276
|
+
*
|
|
277
|
+
* The signal is structural rather than lexical, and the corpus is what decides
|
|
278
|
+
* that. The terms carrying clean signal for a supersession are too rare to
|
|
279
|
+
* catch anything, and the one term that would have caught the known case is
|
|
280
|
+
* `now`, which appears 57 times across 24 entries in correct present-tense
|
|
281
|
+
* prose. What separates the shape instead is a bullet pointing back at its
|
|
282
|
+
* sibling and putting the sibling's design in the past: an opening pronoun with
|
|
283
|
+
* no antecedent of its own, plus a past-tense verb, plus a bullet above it to
|
|
284
|
+
* refer to. Precision is the whole value, so recall is the accepted exposure
|
|
285
|
+
* and a narration written as a single bullet is not reached.
|
|
286
|
+
*
|
|
287
|
+
* The pronoun is matched cased and anchored, since a mid-sentence `this` is a
|
|
288
|
+
* determiner rather than a back-reference. The verb is matched uncased anywhere
|
|
289
|
+
* in the bullet, since the tense is what carries the signal wherever it sits,
|
|
290
|
+
* and rejected behind a `COPULA` for the reason stated there. Displayed spans
|
|
291
|
+
* are masked so a term quoted in backticks is not read as prose the entry
|
|
292
|
+
* writes, and fenced blocks are skipped for the reason the scans above skip
|
|
293
|
+
* them.
|
|
294
|
+
*
|
|
295
|
+
* A blank line does not end the run. Markdown reads two bullets around one as a
|
|
296
|
+
* single loose list, so breaking there would leave the shape reachable by
|
|
297
|
+
* anyone who spaced their bullets out. What ends a run is content that is
|
|
298
|
+
* neither a bullet nor indented under one, which the fenced branch below has to
|
|
299
|
+
* answer for itself because a fenced line is skipped before that test.
|
|
300
|
+
*/
|
|
301
|
+
function narration(
|
|
302
|
+
lines: readonly BodyLine[],
|
|
303
|
+
terms: NarrationTerms,
|
|
304
|
+
): NarrationFinding[] {
|
|
305
|
+
if (terms.pronouns.length === 0 || terms.verbs.length === 0) return []
|
|
306
|
+
|
|
307
|
+
const findings: NarrationFinding[] = []
|
|
308
|
+
let following = false
|
|
309
|
+
let fenceInsideList: boolean | undefined
|
|
310
|
+
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
// A fenced line is never a bullet, but an unindented block still ends the
|
|
313
|
+
// run. CommonMark reads a fence at column zero as interrupting the list, so
|
|
314
|
+
// the bullets around it are two lists and the second has no antecedent
|
|
315
|
+
// above it. A block indented under its bullet stays inside the item.
|
|
316
|
+
//
|
|
317
|
+
// The first delimiter of a contiguous fenced run decides for every line of
|
|
318
|
+
// it. Reading each line instead ends the run on a blank line inside an
|
|
319
|
+
// indented fence, which has no indentation to read, and on a content line
|
|
320
|
+
// at column zero, which CommonMark permits since only the fence's own
|
|
321
|
+
// indent is stripped.
|
|
322
|
+
//
|
|
323
|
+
// Two blocks with nothing between them are one such run, so the second
|
|
324
|
+
// inherits the first's answer and an unindented block behind an indented
|
|
325
|
+
// one leaves a run standing that should have ended. Telling them apart
|
|
326
|
+
// needs the closing delimiter, which `BodyLine` does not carry, and parsing
|
|
327
|
+
// one here would be the second fence walker this repository consolidated
|
|
328
|
+
// away after its first pair disagreed. That fix belongs in
|
|
329
|
+
// `src/markdown/scan.ts` rather than in a copy of it.
|
|
330
|
+
if (line.fenced) {
|
|
331
|
+
if (fenceInsideList === undefined) {
|
|
332
|
+
fenceInsideList = INSIDE_LIST.test(line.text)
|
|
333
|
+
}
|
|
334
|
+
if (!fenceInsideList) following = false
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
fenceInsideList = undefined
|
|
339
|
+
|
|
340
|
+
if (line.text.trim() === '') continue
|
|
341
|
+
|
|
342
|
+
const bullet = line.text.match(TOP_BULLET)
|
|
343
|
+
if (!bullet) {
|
|
344
|
+
// A nested bullet and a wrapped continuation both sit inside the list, so
|
|
345
|
+
// neither ends the run. Anything else does, which is what keeps a bullet
|
|
346
|
+
// opening the list under a heading from reading as a reply to the last
|
|
347
|
+
// bullet of the list before it.
|
|
348
|
+
if (!INSIDE_LIST.test(line.text)) following = false
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const text = maskDisplayed(bullet[1])
|
|
353
|
+
|
|
354
|
+
if (following) {
|
|
355
|
+
const pronoun = terms.pronouns.find((term) =>
|
|
356
|
+
new RegExp(`^${escape(term)}\\b`).test(text),
|
|
357
|
+
)
|
|
358
|
+
const verb = pronoun
|
|
359
|
+
? terms.verbs.find((term) =>
|
|
360
|
+
new RegExp(
|
|
361
|
+
`(?<!\\b(?:${COPULA.join('|')})\\s+)\\b${escape(term)}\\b`,
|
|
362
|
+
'i',
|
|
363
|
+
).test(text),
|
|
364
|
+
)
|
|
365
|
+
: undefined
|
|
366
|
+
|
|
367
|
+
if (pronoun && verb) findings.push({ line: line.number, pronoun, verb })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
following = true
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return findings
|
|
374
|
+
}
|
|
375
|
+
|
|
229
376
|
/**
|
|
230
377
|
* Finds which required sections the entry declares.
|
|
231
378
|
*
|
|
@@ -264,6 +411,7 @@ export function measureEntry(
|
|
|
264
411
|
rel: string,
|
|
265
412
|
source: string,
|
|
266
413
|
governsContent = true,
|
|
414
|
+
terms?: NarrationTerms,
|
|
267
415
|
): EntryReport {
|
|
268
416
|
const lines = bodyLines(source)
|
|
269
417
|
|
|
@@ -275,6 +423,7 @@ export function measureEntry(
|
|
|
275
423
|
.reduce((sum, text) => sum + renderedHeight(text), 0),
|
|
276
424
|
catalogTables: catalogTables(lines),
|
|
277
425
|
provenance: governsContent ? provenance(lines) : [],
|
|
426
|
+
narration: governsContent && terms ? narration(lines, terms) : [],
|
|
278
427
|
sections: governsContent ? declaredSections(lines) : [],
|
|
279
428
|
stub: isStubSeed(source),
|
|
280
429
|
}
|
|
@@ -291,6 +440,7 @@ export function measureEntry(
|
|
|
291
440
|
export async function measureFolders(
|
|
292
441
|
root: string,
|
|
293
442
|
folders: readonly AuditedFolder[],
|
|
443
|
+
terms?: NarrationTerms,
|
|
294
444
|
): Promise<EntryReport[]> {
|
|
295
445
|
const reports: EntryReport[] = []
|
|
296
446
|
|
|
@@ -301,6 +451,7 @@ export async function measureFolders(
|
|
|
301
451
|
relative(root, path),
|
|
302
452
|
await readFile(path, 'utf8'),
|
|
303
453
|
governsContent(folder),
|
|
454
|
+
terms,
|
|
304
455
|
),
|
|
305
456
|
)
|
|
306
457
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The headings a rule carries to publish the two sets.
|
|
7
|
+
*
|
|
8
|
+
* Discovery is anchored on these rather than on a filename for the reason
|
|
9
|
+
* `src/comments/vocabulary.ts` anchors its own: governance rules are numbered,
|
|
10
|
+
* and a renumber would empty the sets while the check kept reporting clean.
|
|
11
|
+
* Both open with `Narration` so neither loader can read the other's list.
|
|
12
|
+
*/
|
|
13
|
+
export const PRONOUN_HEADING = '## Narration pronouns'
|
|
14
|
+
export const VERB_HEADING = '## Narration verbs'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Roots searched in order. The installed copy wins over the toolkit source, so
|
|
18
|
+
* a target project measures against the rule it actually has rather than one
|
|
19
|
+
* only the toolkit carries.
|
|
20
|
+
*/
|
|
21
|
+
const RULE_ROOTS = ['.claude/rules', 'governance/rules']
|
|
22
|
+
|
|
23
|
+
export interface NarrationTerms {
|
|
24
|
+
/** Back-reference openings, matched at the start of a bullet and cased. */
|
|
25
|
+
readonly pronouns: readonly string[]
|
|
26
|
+
/** Past-tense verbs, matched anywhere in the bullet and uncased. */
|
|
27
|
+
readonly verbs: readonly string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Absent is a distinct state from empty, the split the comment vocabulary
|
|
32
|
+
* draws for the same reason: a scan with no terms finds nothing, and reporting
|
|
33
|
+
* that as zero findings claims the corpus is clean when nothing was looked for.
|
|
34
|
+
*/
|
|
35
|
+
export type Narration =
|
|
36
|
+
| ({ readonly kind: 'loaded'; readonly source: string } & NarrationTerms)
|
|
37
|
+
| { readonly kind: 'absent' }
|
|
38
|
+
|
|
39
|
+
/** Pulls the backticked terms out of the bullets under one heading. */
|
|
40
|
+
export function parseTerms(
|
|
41
|
+
markdown: string,
|
|
42
|
+
heading: string,
|
|
43
|
+
): string[] | undefined {
|
|
44
|
+
const lines = markdown.split('\n')
|
|
45
|
+
const start = lines.findIndex((line) => line.trim() === heading)
|
|
46
|
+
if (start === -1) return undefined
|
|
47
|
+
|
|
48
|
+
const terms: string[] = []
|
|
49
|
+
|
|
50
|
+
for (const line of lines.slice(start + 1)) {
|
|
51
|
+
if (line.startsWith('## ')) break
|
|
52
|
+
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
53
|
+
const term = match[1].trim()
|
|
54
|
+
if (term && !terms.includes(term)) terms.push(term)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return terms
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Finds the rule publishing both sets under `root`.
|
|
63
|
+
*
|
|
64
|
+
* Both come from one file rather than from whichever rule carries each. A
|
|
65
|
+
* pronoun set and a verb set are halves of one signal, and pairing them across
|
|
66
|
+
* files would let a renumber recombine them into a signal nobody wrote.
|
|
67
|
+
*/
|
|
68
|
+
export async function loadNarration(root: string): Promise<Narration> {
|
|
69
|
+
for (const ruleRoot of RULE_ROOTS) {
|
|
70
|
+
const dir = resolve(root, ruleRoot)
|
|
71
|
+
if (!existsSync(dir)) continue
|
|
72
|
+
|
|
73
|
+
const paths: string[] = []
|
|
74
|
+
for await (const rel of new Bun.Glob('**/*.md').scan({
|
|
75
|
+
cwd: dir,
|
|
76
|
+
onlyFiles: true,
|
|
77
|
+
})) {
|
|
78
|
+
paths.push(rel)
|
|
79
|
+
}
|
|
80
|
+
paths.sort()
|
|
81
|
+
|
|
82
|
+
for (const rel of paths) {
|
|
83
|
+
const source = await readFile(resolve(dir, rel), 'utf8')
|
|
84
|
+
const pronouns = parseTerms(source, PRONOUN_HEADING)
|
|
85
|
+
const verbs = parseTerms(source, VERB_HEADING)
|
|
86
|
+
|
|
87
|
+
if (pronouns && verbs && pronouns.length > 0 && verbs.length > 0) {
|
|
88
|
+
return {
|
|
89
|
+
kind: 'loaded',
|
|
90
|
+
source: `${ruleRoot}/${rel}`,
|
|
91
|
+
pronouns,
|
|
92
|
+
verbs,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { kind: 'absent' }
|
|
99
|
+
}
|
package/src/markdown/scan.ts
CHANGED
|
@@ -23,6 +23,17 @@ const CODE_SPAN = /(`+)(?:(?!\1).)*\1/g
|
|
|
23
23
|
const LINK_DESTINATION = /\]\([^)]*\)/g
|
|
24
24
|
const AUTOLINK = /<[^>\s]+>/g
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A whole inline link, capturing the anchor text a reader is shown.
|
|
28
|
+
*
|
|
29
|
+
* `LINK_DESTINATION` covers the span both measures drop and this covers the
|
|
30
|
+
* brackets only the weight measure drops, which a reader is no more shown than
|
|
31
|
+
* the destination. The narrower pattern still runs after this one, since a link
|
|
32
|
+
* wrapped across two source lines puts its opening bracket on a line this one
|
|
33
|
+
* never matches.
|
|
34
|
+
*/
|
|
35
|
+
const LINK = /\[([^\]]*)\]\([^)]*\)/g
|
|
36
|
+
|
|
26
37
|
export interface BodyLine {
|
|
27
38
|
readonly number: number
|
|
28
39
|
readonly text: string
|
|
@@ -128,16 +139,81 @@ export function maskDisplayed(text: string): string {
|
|
|
128
139
|
.replace(AUTOLINK, blank)
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Drops the spans a reader is never shown, returning the text they read.
|
|
144
|
+
*
|
|
145
|
+
* This is what a weight measure counts, and it is deliberately not
|
|
146
|
+
* `maskDisplayed`. That one holds each span's width so a ban finding can name a
|
|
147
|
+
* column, which leaves behind the very characters a weight measure exists to
|
|
148
|
+
* discount. The two also disagree on the span set: a backticked path is text a
|
|
149
|
+
* reader reads and stays counted here, while the ban scan blanks it so a
|
|
150
|
+
* standard quoting its own banned character does not report itself. One file
|
|
151
|
+
* therefore holds two answers to what a reader sees, each correct for its own
|
|
152
|
+
* measure, and collapsing them into one helper breaks whichever loses.
|
|
153
|
+
*
|
|
154
|
+
* A code span is walked around rather than through, since keeping it counted
|
|
155
|
+
* and then dropping spans from inside it takes back the decision. The
|
|
156
|
+
* placeholders this repository writes are the case: a reader is shown all of
|
|
157
|
+
* `.claude/context/<domain>.md` and the autolink pattern reaches the angle
|
|
158
|
+
* brackets in the middle of it.
|
|
159
|
+
*/
|
|
160
|
+
export function visibleText(text: string): string {
|
|
161
|
+
const drop = (segment: string): string =>
|
|
162
|
+
segment
|
|
163
|
+
.replace(LINK, '$1')
|
|
164
|
+
.replace(LINK_DESTINATION, '')
|
|
165
|
+
.replace(AUTOLINK, '')
|
|
166
|
+
|
|
167
|
+
let visible = ''
|
|
168
|
+
let read = 0
|
|
169
|
+
|
|
170
|
+
for (const span of text.matchAll(CODE_SPAN)) {
|
|
171
|
+
visible += drop(text.slice(read, span.index)) + span[0]
|
|
172
|
+
read = span.index + span[0].length
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return visible + drop(text.slice(read))
|
|
176
|
+
}
|
|
177
|
+
|
|
131
178
|
function escape(term: string): string {
|
|
132
179
|
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
133
180
|
}
|
|
134
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Bounds a banned word on a word character or a hyphen either side.
|
|
184
|
+
*
|
|
185
|
+
* `\b` sits after a hyphen, so a banned word ending a hyphenated compound
|
|
186
|
+
* reports from inside it: `allows` came back out of `auto-allows`. A compound
|
|
187
|
+
* is one word to a reader, and the ban is on the word rather than on a morpheme
|
|
188
|
+
* of it. Stripping hyphens before matching was the alternative and it joins the
|
|
189
|
+
* compound into a token neither half reaches.
|
|
190
|
+
*
|
|
191
|
+
* A spelling ban takes `wordBoundary` instead. This is not that rule with a
|
|
192
|
+
* wider fence, since the two bans target different things.
|
|
193
|
+
*/
|
|
194
|
+
function bannedWord(term: string): RegExp {
|
|
195
|
+
return new RegExp(`(?<![\\w-])${escape(term)}(?![\\w-])`, 'gi')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Bounds a banned spelling on a word character alone, hyphens included.
|
|
200
|
+
*
|
|
201
|
+
* A word ban targets the word, so a compound reading as one word is correct.
|
|
202
|
+
* A spelling ban targets the orthography inside it, and a compound is exactly
|
|
203
|
+
* where the orthography still sits: `behaviour-driven` carries the banned
|
|
204
|
+
* spelling as plainly as `behaviour` does, and rejecting a hyphen here would
|
|
205
|
+
* leave the usual spelling of that phrase unreported.
|
|
206
|
+
*/
|
|
207
|
+
function bannedSpelling(term: string): RegExp {
|
|
208
|
+
return new RegExp(`\\b${escape(term)}\\b`, 'gi')
|
|
209
|
+
}
|
|
210
|
+
|
|
135
211
|
/**
|
|
136
212
|
* Finds every banned term outside a fence, a code span, and a link.
|
|
137
213
|
*
|
|
138
|
-
* A word ban matches
|
|
139
|
-
*
|
|
140
|
-
*
|
|
214
|
+
* A word ban matches in either casing, since the standard states each in
|
|
215
|
+
* lowercase and bans the word rather than a spelling of it. A closed set of
|
|
216
|
+
* whole words separates the check from the pattern that produced most of the
|
|
141
217
|
* intake's false positives: `exercises` and `promises` end in the banned
|
|
142
218
|
* suffix and are not the banned words, and a closed set never reaches them.
|
|
143
219
|
*/
|
|
@@ -151,12 +227,12 @@ export function scanBans(
|
|
|
151
227
|
...bans.words.map((term) => ({
|
|
152
228
|
kind: 'word' as const,
|
|
153
229
|
term,
|
|
154
|
-
pattern:
|
|
230
|
+
pattern: bannedWord(term),
|
|
155
231
|
})),
|
|
156
232
|
...bans.spellings.map((term) => ({
|
|
157
233
|
kind: 'spelling' as const,
|
|
158
234
|
term,
|
|
159
|
-
pattern:
|
|
235
|
+
pattern: bannedSpelling(term),
|
|
160
236
|
})),
|
|
161
237
|
]
|
|
162
238
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type BodyLine, visibleText } from '@/markdown/scan'
|
|
2
2
|
|
|
3
3
|
const HEADING = /^#{1,6}\s/
|
|
4
4
|
const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
|
|
@@ -143,9 +143,13 @@ export function parseCheckpoints(markdown: string): Checkpoints {
|
|
|
143
143
|
*
|
|
144
144
|
* A blank line renders as the gap it is rather than as nothing, which keeps it
|
|
145
145
|
* the distance the source measure already counted it as.
|
|
146
|
+
*
|
|
147
|
+
* The width is measured against what renders, since a rendered line shows a
|
|
148
|
+
* link's anchor text rather than its destination and a source measure
|
|
149
|
+
* over-counts exactly where this rule cares how far a reader travels.
|
|
146
150
|
*/
|
|
147
151
|
export function renderedHeight(text: string, width = RENDER_WIDTH): number {
|
|
148
|
-
return Math.max(1, Math.ceil(text.length / width))
|
|
152
|
+
return Math.max(1, Math.ceil(visibleText(text).length / width))
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
/**
|
|
@@ -173,7 +177,7 @@ function isScannablePeerList(
|
|
|
173
177
|
if (!match) return false
|
|
174
178
|
indents.add(match[1].length)
|
|
175
179
|
items++
|
|
176
|
-
characters += text.length
|
|
180
|
+
characters += visibleText(text).length
|
|
177
181
|
}
|
|
178
182
|
|
|
179
183
|
if (indents.size !== 1) return false
|
|
@@ -297,10 +301,13 @@ export function heavyBullets(
|
|
|
297
301
|
const item = line.text.match(LIST_ITEM)
|
|
298
302
|
const text = line.text.trim()
|
|
299
303
|
|
|
304
|
+
// Structure is read off the raw line and only the weight is masked. A line
|
|
305
|
+
// carrying nothing but an autolink has no visible text at all, and reading
|
|
306
|
+
// its masked form as blank would close the bullet it continues.
|
|
300
307
|
if (item) {
|
|
301
308
|
close()
|
|
302
309
|
if (item[1].length === 0) {
|
|
303
|
-
open = { line: line.number, characters: text.length }
|
|
310
|
+
open = { line: line.number, characters: visibleText(text).length }
|
|
304
311
|
}
|
|
305
312
|
continue
|
|
306
313
|
}
|
|
@@ -312,7 +319,11 @@ export function heavyBullets(
|
|
|
312
319
|
|
|
313
320
|
// The joining space a wrapped line would have carried, so folding two
|
|
314
321
|
// source lines measures what one unwrapped line would have.
|
|
315
|
-
if (open)
|
|
322
|
+
if (open)
|
|
323
|
+
open = {
|
|
324
|
+
...open,
|
|
325
|
+
characters: open.characters + visibleText(text).length + 1,
|
|
326
|
+
}
|
|
316
327
|
}
|
|
317
328
|
|
|
318
329
|
close()
|
|
@@ -353,16 +364,13 @@ export function heavyParagraphs(
|
|
|
353
364
|
if (block.length > 0) {
|
|
354
365
|
const text = block.map((line) => line.text.trim()).join(' ')
|
|
355
366
|
const sentences = countSentences(text)
|
|
367
|
+
const characters = visibleText(text).length
|
|
356
368
|
|
|
357
369
|
if (
|
|
358
370
|
sentences > checkpoints.sentences ||
|
|
359
|
-
|
|
371
|
+
characters > checkpoints.paragraph
|
|
360
372
|
) {
|
|
361
|
-
findings.push({
|
|
362
|
-
line: block[0].number,
|
|
363
|
-
sentences,
|
|
364
|
-
characters: text.length,
|
|
365
|
-
})
|
|
373
|
+
findings.push({ line: block[0].number, sentences, characters })
|
|
366
374
|
}
|
|
367
375
|
}
|
|
368
376
|
block = []
|