@erclx/aitk 0.63.2 → 0.64.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { linesOutsideFences } from '@/markdown/scan'
4
+
5
+ /**
6
+ * Headings the two standards state their closed sets under.
7
+ *
8
+ * Each standard says where its own bans sit and points at the other for the
9
+ * rest, so discovery anchors on the heading rather than on a bullet's position.
10
+ * A section renamed empties the set loudly at the report's legend, where a
11
+ * position-based read would narrow the check and still print a count.
12
+ */
13
+ export const WORD_BAN_HEADING = '## Language'
14
+ export const CHARACTER_BAN_HEADING = '## Punctuation'
15
+
16
+ /** Bullet lead-ins the two closed sets are stated behind. */
17
+ const BAN_LEAD = '- Do not use '
18
+ const SPELLING_LEAD = '- Use American English spelling'
19
+
20
+ /**
21
+ * Roots searched in order. The installed copy wins over the authoring root, so
22
+ * a target project measures against the standard it actually has rather than
23
+ * one only the toolkit carries.
24
+ */
25
+ const STANDARD_ROOTS = ['.claude/standards', 'standards']
26
+
27
+ const BACKTICKED = /`([^`]+)`/g
28
+ const WORD = /^[a-z]+$/
29
+ const SUFFIX = /^-[a-z]+$/
30
+
31
+ export interface BanReport {
32
+ /** Single characters the mechanics standard bans outright. */
33
+ readonly characters: readonly string[]
34
+ /** Single lowercase words the prose standard bans outright. */
35
+ readonly words: readonly string[]
36
+ /** Spellings derived from the prose standard's own examples. */
37
+ readonly spellings: readonly string[]
38
+ /** Repo-relative paths of the standards read, in the order read. */
39
+ readonly sources: readonly string[]
40
+ /**
41
+ * Standards that resolved under neither root. Absent is a distinct state from
42
+ * empty: a scan with no terms finds nothing, and reporting that as a clean
43
+ * file claims the prose passed when nothing was looked for.
44
+ */
45
+ readonly missing: readonly string[]
46
+ }
47
+
48
+ function section(markdown: string, heading: string): string[] {
49
+ const lines = linesOutsideFences(markdown)
50
+ const start = lines.findIndex((line) => line.trim() === heading)
51
+ if (start === -1) return []
52
+
53
+ const body = lines.slice(start + 1)
54
+ const end = body.findIndex((line) => line.startsWith('## '))
55
+
56
+ return end === -1 ? body : body.slice(0, end)
57
+ }
58
+
59
+ function backticked(line: string): string[] {
60
+ return [...line.matchAll(BACKTICKED)].map((match) => match[1].trim())
61
+ }
62
+
63
+ /**
64
+ * Pulls the single lowercase words out of the `Do not use` bullets.
65
+ *
66
+ * A multi-word term is left out rather than matched loosely. The standard bans
67
+ * a pattern like `It's not X, it's Y` with a placeholder standing in for the
68
+ * rest of the sentence, and no literal match reaches it, so the phrase bans
69
+ * stay a reader's judgment and the report names them as unmeasured.
70
+ */
71
+ export function parseWordBans(markdown: string): string[] {
72
+ const terms: string[] = []
73
+
74
+ for (const line of section(markdown, WORD_BAN_HEADING)) {
75
+ if (!line.startsWith(BAN_LEAD)) continue
76
+ for (const term of backticked(line)) {
77
+ if (WORD.test(term) && !terms.includes(term)) terms.push(term)
78
+ }
79
+ }
80
+
81
+ return terms
82
+ }
83
+
84
+ /**
85
+ * Pulls the single non-alphanumeric characters out of the `Do not use` bullets.
86
+ *
87
+ * Width is what separates the two ban classes in that section. The character
88
+ * bans are stated as one glyph each, and the parenthetical-aside ban quotes a
89
+ * whole clause, which is a shape no literal match should be built from.
90
+ */
91
+ export function parseCharacterBans(markdown: string): string[] {
92
+ const terms: string[] = []
93
+
94
+ for (const line of section(markdown, CHARACTER_BAN_HEADING)) {
95
+ if (!line.startsWith(BAN_LEAD)) continue
96
+ for (const term of backticked(line)) {
97
+ if (
98
+ term.length === 1 &&
99
+ !/[a-z0-9]/i.test(term) &&
100
+ !terms.includes(term)
101
+ ) {
102
+ terms.push(term)
103
+ }
104
+ }
105
+ }
106
+
107
+ return terms
108
+ }
109
+
110
+ /**
111
+ * Derives the banned spellings by applying the standard's own suffix rules to
112
+ * the standard's own examples.
113
+ *
114
+ * The spelling rule is stated as a preference with examples rather than as a
115
+ * closed set, and a suffix pattern run over prose is what produced 46 of the
116
+ * 58 false positives measured during intake: `exercises`, `promises`, and
117
+ * `revised` all end in `-ise` and none is a British spelling. Transforming the
118
+ * listed American words into their British forms yields a closed set that
119
+ * matches whole words and reaches none of them, and an example added to the
120
+ * standard extends the check without a code edit.
121
+ */
122
+ export function parseSpellingBans(markdown: string): string[] {
123
+ const line = section(markdown, WORD_BAN_HEADING).find((each) =>
124
+ each.startsWith(SPELLING_LEAD),
125
+ )
126
+ if (!line) return []
127
+
128
+ const terms = backticked(line)
129
+ const suffixes = terms.filter((term) => SUFFIX.test(term))
130
+ const examples = terms.filter((term) => WORD.test(term))
131
+
132
+ // Stated as `preferred` over `banned`, so the run of suffix terms pairs off
133
+ // in order. An odd count means the sentence was rewritten into a shape this
134
+ // cannot read, and pairing what is there would invent a rule from half of it.
135
+ if (suffixes.length === 0 || suffixes.length % 2 !== 0) return []
136
+
137
+ const pairs: { preferred: string; banned: string }[] = []
138
+ for (let index = 0; index < suffixes.length; index += 2) {
139
+ pairs.push({
140
+ preferred: suffixes[index].slice(1),
141
+ banned: suffixes[index + 1].slice(1),
142
+ })
143
+ }
144
+
145
+ const spellings: string[] = []
146
+ for (const example of examples) {
147
+ const pair = pairs.find((each) => example.endsWith(each.preferred))
148
+ if (!pair) continue
149
+
150
+ const banned = `${example.slice(0, -pair.preferred.length)}${pair.banned}`
151
+ if (!spellings.includes(banned)) spellings.push(banned)
152
+ }
153
+
154
+ return spellings
155
+ }
156
+
157
+ export interface StandardText {
158
+ /** Repo-relative path of the copy read, so a report says which root won. */
159
+ readonly source: string
160
+ readonly text: string
161
+ }
162
+
163
+ export interface Standards {
164
+ readonly markdown: StandardText | undefined
165
+ readonly prose: StandardText | undefined
166
+ /**
167
+ * Standards that resolved under neither root. Absent is a distinct state from
168
+ * empty: a scan with no terms finds nothing, and reporting that as a clean
169
+ * file claims the prose passed when nothing was looked for.
170
+ */
171
+ readonly missing: readonly string[]
172
+ }
173
+
174
+ async function readStandard(
175
+ root: string,
176
+ name: string,
177
+ ): Promise<StandardText | undefined> {
178
+ for (const standardRoot of STANDARD_ROOTS) {
179
+ const path = resolve(root, standardRoot, name)
180
+ const file = Bun.file(path)
181
+ if (!(await file.exists())) continue
182
+
183
+ return {
184
+ source: `${standardRoot}/${name}`,
185
+ text: await readFile(path, 'utf8'),
186
+ }
187
+ }
188
+
189
+ return undefined
190
+ }
191
+
192
+ /**
193
+ * Finds both standards, so one read serves the ban sets and the checkpoints.
194
+ *
195
+ * The texts are returned rather than the parsed sets alone because
196
+ * `structure.ts` reads its numbers out of the same `markdown.md`, and a second
197
+ * loader would open the file twice and could resolve a different root than
198
+ * this one did.
199
+ */
200
+ export async function loadStandards(root: string): Promise<Standards> {
201
+ const [markdown, prose] = await Promise.all([
202
+ readStandard(root, 'markdown.md'),
203
+ readStandard(root, 'prose.md'),
204
+ ])
205
+
206
+ return {
207
+ markdown,
208
+ prose,
209
+ missing: [
210
+ markdown ? undefined : 'markdown.md',
211
+ prose ? undefined : 'prose.md',
212
+ ].filter((name): name is string => name !== undefined),
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Reads both closed sets out of the standards stating them.
218
+ *
219
+ * Holding the lists in code was the alternative and it puts the bans in two
220
+ * places, where an author adding a banned word gets no enforcement until
221
+ * someone edits TypeScript. The trade is a reader of prose that a reformat can
222
+ * break, which `bans.test.ts` answers by asserting the parsed sets against the
223
+ * shipped standards.
224
+ */
225
+ export function banReport(standards: Standards): BanReport {
226
+ const { markdown, prose } = standards
227
+
228
+ return {
229
+ characters: markdown ? parseCharacterBans(markdown.text) : [],
230
+ words: prose ? parseWordBans(prose.text) : [],
231
+ spellings: prose ? parseSpellingBans(prose.text) : [],
232
+ sources: [markdown?.source, prose?.source].filter(
233
+ (source): source is string => source !== undefined,
234
+ ),
235
+ missing: standards.missing,
236
+ }
237
+ }
238
+
239
+ export async function loadBans(root: string): Promise<BanReport> {
240
+ return banReport(await loadStandards(root))
241
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { relative, resolve } from 'node:path'
3
+ import { listRepositoryFiles } from '@/git-files'
4
+
5
+ const MARKDOWN = /\.md$/
6
+
7
+ /**
8
+ * `unavailable` is a distinct state from an empty scope.
9
+ *
10
+ * A bare run takes its corpus from git, and a git that cannot answer would
11
+ * otherwise resolve zero files and report every one of them clean.
12
+ */
13
+ export type FileScope =
14
+ | {
15
+ readonly kind: 'resolved'
16
+ readonly files: readonly string[]
17
+ /** Arguments matching no markdown file, which is a typo rather than a pass. */
18
+ readonly unmatched: readonly string[]
19
+ }
20
+ | { readonly kind: 'unavailable' }
21
+
22
+ function isDirectory(path: string): boolean {
23
+ try {
24
+ return statSync(path).isDirectory()
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ /** Compiles the pattern once rather than per candidate. */
31
+ function matchGlob(files: readonly string[], pattern: string): string[] {
32
+ const glob = new Bun.Glob(pattern)
33
+ return files.filter((each) => glob.match(each))
34
+ }
35
+
36
+ /**
37
+ * Resolves the arguments to a markdown file list, repo-relative and sorted.
38
+ *
39
+ * The corpus is what git lists rather than a directory walk, which is what
40
+ * keeps `node_modules` and the gitignored session-scratch folders out without
41
+ * naming either. A directory argument narrows that list by prefix and a
42
+ * pattern narrows it by match, so the three argument shapes read one corpus
43
+ * and a file only the working tree has is in scope on the branch adding it.
44
+ *
45
+ * An explicit file path is taken as given. A caller naming one file means it,
46
+ * and refusing a path git does not list would make the verb unusable against a
47
+ * gitignored draft a session wants measured before it commits.
48
+ */
49
+ export async function resolveMarkdown(
50
+ root: string,
51
+ args: readonly string[],
52
+ ): Promise<FileScope> {
53
+ const listed = await listRepositoryFiles(root)
54
+ if (!listed) return { kind: 'unavailable' }
55
+
56
+ const markdown = listed.filter((rel) => MARKDOWN.test(rel))
57
+
58
+ if (args.length === 0) {
59
+ return { kind: 'resolved', files: markdown, unmatched: [] }
60
+ }
61
+
62
+ const files = new Set<string>()
63
+ const unmatched: string[] = []
64
+
65
+ for (const arg of args) {
66
+ const absolute = resolve(root, arg)
67
+ const rel = relative(root, absolute)
68
+
69
+ if (!isDirectory(absolute) && MARKDOWN.test(arg) && !arg.includes('*')) {
70
+ if (existsSync(absolute)) files.add(rel)
71
+ else unmatched.push(arg)
72
+ continue
73
+ }
74
+
75
+ // An empty `rel` is the root itself, where the prefix below matches nothing
76
+ // and would report the whole tree as an argument that resolved to no file.
77
+ const matched = isDirectory(absolute)
78
+ ? rel === ''
79
+ ? markdown
80
+ : markdown.filter((each) => each === rel || each.startsWith(`${rel}/`))
81
+ : matchGlob(markdown, arg)
82
+
83
+ if (matched.length === 0) {
84
+ unmatched.push(arg)
85
+ continue
86
+ }
87
+
88
+ for (const each of matched) files.add(each)
89
+ }
90
+
91
+ return { kind: 'resolved', files: [...files].sort(), unmatched }
92
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * A fence opens on a run of three or more backticks or tildes and closes on a
3
+ * run of the same character at least as long. Length matters: a ```` block
4
+ * displaying a ``` example closes on neither of the inner delimiters, and a
5
+ * walker toggling on any run reads the second inner fence as an opening and
6
+ * inverts the rest of the file.
7
+ */
8
+ const FENCE = /^(`{3,}|~{3,})/
9
+
10
+ /** Anchored at position 0, so a `---` block inside a fenced template is body. */
11
+ const FRONTMATTER = /^---\n[\s\S]*?\n---\n?/
12
+
13
+ /**
14
+ * Inline code, a link destination, and an autolink, the three spans holding
15
+ * text a reader is shown rather than told.
16
+ *
17
+ * The bans this masking serves are stated with backticked examples, so a
18
+ * standard quoting its own banned character would report itself without it. A
19
+ * link destination is masked because a query string carries a semicolon that no
20
+ * rewrite of the sentence can remove.
21
+ */
22
+ const CODE_SPAN = /(`+)(?:(?!\1).)*\1/g
23
+ const LINK_DESTINATION = /\]\([^)]*\)/g
24
+ const AUTOLINK = /<[^>\s]+>/g
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
+
37
+ export interface BodyLine {
38
+ readonly number: number
39
+ readonly text: string
40
+ /** True on a fence delimiter and on every line between one pair. */
41
+ readonly fenced: boolean
42
+ }
43
+
44
+ export type BanKind = 'character' | 'word' | 'spelling'
45
+
46
+ export interface BanFinding {
47
+ readonly line: number
48
+ readonly column: number
49
+ readonly kind: BanKind
50
+ /** The term as the standard states it, so a report names what to look up. */
51
+ readonly term: string
52
+ }
53
+
54
+ export interface BanSets {
55
+ readonly characters: readonly string[]
56
+ readonly words: readonly string[]
57
+ readonly spellings: readonly string[]
58
+ }
59
+
60
+ /**
61
+ * Marks which lines sit inside a fence without dropping them.
62
+ *
63
+ * Each measure excludes a fence for its own reason and needs a different
64
+ * response. The depth measure skips a fenced line so an example cannot break
65
+ * the run around it, bullet folding treats one as a break so a bullet does not
66
+ * absorb the block below it, and the ban scan ignores it outright. Returning
67
+ * the mark rather than a filtered list is what lets one walk serve all three.
68
+ */
69
+ function markFences(texts: readonly string[], offset: number): BodyLine[] {
70
+ const lines: BodyLine[] = []
71
+ let fence: string | undefined
72
+
73
+ for (const [index, text] of texts.entries()) {
74
+ const match = FENCE.exec(text.trim())
75
+ let fenced = false
76
+
77
+ if (fence) {
78
+ fenced = true
79
+ const closes =
80
+ match && match[1][0] === fence[0] && match[1].length >= fence.length
81
+ if (closes) fence = undefined
82
+ } else if (match) {
83
+ fenced = true
84
+ fence = match[1]
85
+ }
86
+
87
+ lines.push({ number: offset + index + 1, text, fenced })
88
+ }
89
+
90
+ return lines
91
+ }
92
+
93
+ /**
94
+ * Drops the frontmatter while keeping every surviving line's original number,
95
+ * so a finding points at the line an editor opens rather than at an offset into
96
+ * the body.
97
+ */
98
+ export function bodyLines(source: string): BodyLine[] {
99
+ const match = source.match(FRONTMATTER)
100
+ const offset = match ? match[0].split('\n').length - 1 : 0
101
+
102
+ return markFences(
103
+ source
104
+ .slice(match ? match[0].length : 0)
105
+ .replace(/\n$/, '')
106
+ .split('\n'),
107
+ offset,
108
+ )
109
+ }
110
+
111
+ /**
112
+ * Drops every fenced block from raw text, frontmatter included as content.
113
+ *
114
+ * The record validators read whole files whose frontmatter is part of what they
115
+ * check, so this walks the source as given rather than through `bodyLines`.
116
+ */
117
+ export function linesOutsideFences(text: string): string[] {
118
+ return markFences(text.split('\n'), 0)
119
+ .filter((line) => !line.fenced)
120
+ .map((line) => line.text)
121
+ }
122
+
123
+ /** Blanks a span while holding its width, so a column stays where it was. */
124
+ function blank(match: string): string {
125
+ return ' '.repeat(match.length)
126
+ }
127
+
128
+ /**
129
+ * Replaces displayed spans with spaces of equal width.
130
+ *
131
+ * Equal width is what keeps a reported column pointing at the character an
132
+ * editor puts the cursor on, which a plain deletion would shift left by
133
+ * everything masked ahead of it on the line.
134
+ */
135
+ export function maskDisplayed(text: string): string {
136
+ return text
137
+ .replace(CODE_SPAN, blank)
138
+ .replace(LINK_DESTINATION, blank)
139
+ .replace(AUTOLINK, blank)
140
+ }
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
+
178
+ function escape(term: string): string {
179
+ return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
180
+ }
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
+
211
+ /**
212
+ * Finds every banned term outside a fence, a code span, and a link.
213
+ *
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
217
+ * intake's false positives: `exercises` and `promises` end in the banned
218
+ * suffix and are not the banned words, and a closed set never reaches them.
219
+ */
220
+ export function scanBans(
221
+ lines: readonly BodyLine[],
222
+ bans: BanSets,
223
+ ): BanFinding[] {
224
+ const found: BanFinding[] = []
225
+
226
+ const patterns: { kind: BanKind; term: string; pattern: RegExp }[] = [
227
+ ...bans.words.map((term) => ({
228
+ kind: 'word' as const,
229
+ term,
230
+ pattern: bannedWord(term),
231
+ })),
232
+ ...bans.spellings.map((term) => ({
233
+ kind: 'spelling' as const,
234
+ term,
235
+ pattern: bannedSpelling(term),
236
+ })),
237
+ ]
238
+
239
+ for (const line of lines) {
240
+ if (line.fenced) continue
241
+ const text = maskDisplayed(line.text)
242
+
243
+ for (const term of bans.characters) {
244
+ let column = text.indexOf(term)
245
+ while (column !== -1) {
246
+ found.push({ line: line.number, column, kind: 'character', term })
247
+ column = text.indexOf(term, column + term.length)
248
+ }
249
+ }
250
+
251
+ for (const { kind, term, pattern } of patterns) {
252
+ for (const match of text.matchAll(pattern)) {
253
+ found.push({ line: line.number, column: match.index, kind, term })
254
+ }
255
+ }
256
+ }
257
+
258
+ return found.sort((a, b) => a.line - b.line || a.column - b.column)
259
+ }