@erclx/aitk 0.63.1 → 0.64.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/claude/skills/claude-docs/SKILL.md +3 -1
- package/claude/skills/claude-feature/SKILL.md +2 -0
- package/claude/skills/claude-memory-capture/SKILL.md +9 -2
- package/claude/skills/claude-memory-review/SKILL.md +5 -2
- package/claude/skills/claude-review/SKILL.md +2 -0
- package/claude/skills/claude-screencast/SKILL.md +2 -0
- package/claude/skills/claude-seed-sync/SKILL.md +2 -0
- package/claude/skills/claude-tasks/SKILL.md +3 -1
- package/claude/skills/claude-ui-test/SKILL.md +2 -0
- package/claude/skills/claude-ux-audit/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +9 -3
- package/docs/agents/commands.md +5 -1
- package/docs/agents/context-audit-checks.md +9 -11
- package/docs/agents/context-audit.md +3 -1
- package/docs/agents/index.md +3 -2
- package/docs/agents/markdown-audit.md +70 -0
- package/docs/agents/tasks.md +51 -2
- package/docs/ai-workflow.md +1 -1
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/context.ts +8 -115
- package/src/commands/markdown.ts +383 -0
- package/src/commands/records.ts +1 -18
- package/src/commands/tasks.ts +314 -19
- package/src/context/audit.ts +34 -309
- package/src/context/citations.ts +2 -30
- package/src/git-files.ts +31 -0
- package/src/markdown/bans.ts +241 -0
- package/src/markdown/files.ts +92 -0
- package/src/markdown/scan.ts +183 -0
- package/src/markdown/structure.ts +408 -0
- package/src/records/validate.ts +1 -37
- package/src/tasks/archive.ts +34 -3
- package/src/tasks/record.ts +311 -0
- package/src/worktree.ts +23 -0
- package/standards/context.md +2 -7
- package/standards/markdown.md +10 -2
- package/tooling/claude/seeds/CLAUDE.md +1 -0
|
@@ -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,183 @@
|
|
|
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
|
+
export interface BodyLine {
|
|
27
|
+
readonly number: number
|
|
28
|
+
readonly text: string
|
|
29
|
+
/** True on a fence delimiter and on every line between one pair. */
|
|
30
|
+
readonly fenced: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type BanKind = 'character' | 'word' | 'spelling'
|
|
34
|
+
|
|
35
|
+
export interface BanFinding {
|
|
36
|
+
readonly line: number
|
|
37
|
+
readonly column: number
|
|
38
|
+
readonly kind: BanKind
|
|
39
|
+
/** The term as the standard states it, so a report names what to look up. */
|
|
40
|
+
readonly term: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BanSets {
|
|
44
|
+
readonly characters: readonly string[]
|
|
45
|
+
readonly words: readonly string[]
|
|
46
|
+
readonly spellings: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Marks which lines sit inside a fence without dropping them.
|
|
51
|
+
*
|
|
52
|
+
* Each measure excludes a fence for its own reason and needs a different
|
|
53
|
+
* response. The depth measure skips a fenced line so an example cannot break
|
|
54
|
+
* the run around it, bullet folding treats one as a break so a bullet does not
|
|
55
|
+
* absorb the block below it, and the ban scan ignores it outright. Returning
|
|
56
|
+
* the mark rather than a filtered list is what lets one walk serve all three.
|
|
57
|
+
*/
|
|
58
|
+
function markFences(texts: readonly string[], offset: number): BodyLine[] {
|
|
59
|
+
const lines: BodyLine[] = []
|
|
60
|
+
let fence: string | undefined
|
|
61
|
+
|
|
62
|
+
for (const [index, text] of texts.entries()) {
|
|
63
|
+
const match = FENCE.exec(text.trim())
|
|
64
|
+
let fenced = false
|
|
65
|
+
|
|
66
|
+
if (fence) {
|
|
67
|
+
fenced = true
|
|
68
|
+
const closes =
|
|
69
|
+
match && match[1][0] === fence[0] && match[1].length >= fence.length
|
|
70
|
+
if (closes) fence = undefined
|
|
71
|
+
} else if (match) {
|
|
72
|
+
fenced = true
|
|
73
|
+
fence = match[1]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
lines.push({ number: offset + index + 1, text, fenced })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Drops the frontmatter while keeping every surviving line's original number,
|
|
84
|
+
* so a finding points at the line an editor opens rather than at an offset into
|
|
85
|
+
* the body.
|
|
86
|
+
*/
|
|
87
|
+
export function bodyLines(source: string): BodyLine[] {
|
|
88
|
+
const match = source.match(FRONTMATTER)
|
|
89
|
+
const offset = match ? match[0].split('\n').length - 1 : 0
|
|
90
|
+
|
|
91
|
+
return markFences(
|
|
92
|
+
source
|
|
93
|
+
.slice(match ? match[0].length : 0)
|
|
94
|
+
.replace(/\n$/, '')
|
|
95
|
+
.split('\n'),
|
|
96
|
+
offset,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Drops every fenced block from raw text, frontmatter included as content.
|
|
102
|
+
*
|
|
103
|
+
* The record validators read whole files whose frontmatter is part of what they
|
|
104
|
+
* check, so this walks the source as given rather than through `bodyLines`.
|
|
105
|
+
*/
|
|
106
|
+
export function linesOutsideFences(text: string): string[] {
|
|
107
|
+
return markFences(text.split('\n'), 0)
|
|
108
|
+
.filter((line) => !line.fenced)
|
|
109
|
+
.map((line) => line.text)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Blanks a span while holding its width, so a column stays where it was. */
|
|
113
|
+
function blank(match: string): string {
|
|
114
|
+
return ' '.repeat(match.length)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Replaces displayed spans with spaces of equal width.
|
|
119
|
+
*
|
|
120
|
+
* Equal width is what keeps a reported column pointing at the character an
|
|
121
|
+
* editor puts the cursor on, which a plain deletion would shift left by
|
|
122
|
+
* everything masked ahead of it on the line.
|
|
123
|
+
*/
|
|
124
|
+
export function maskDisplayed(text: string): string {
|
|
125
|
+
return text
|
|
126
|
+
.replace(CODE_SPAN, blank)
|
|
127
|
+
.replace(LINK_DESTINATION, blank)
|
|
128
|
+
.replace(AUTOLINK, blank)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function escape(term: string): string {
|
|
132
|
+
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Finds every banned term outside a fence, a code span, and a link.
|
|
137
|
+
*
|
|
138
|
+
* A word ban matches on word boundaries and either casing, since the standard
|
|
139
|
+
* states each in lowercase and bans the word rather than a spelling of it. This
|
|
140
|
+
* is what separates the check from the pattern that produced most of the
|
|
141
|
+
* intake's false positives: `exercises` and `promises` end in the banned
|
|
142
|
+
* suffix and are not the banned words, and a closed set never reaches them.
|
|
143
|
+
*/
|
|
144
|
+
export function scanBans(
|
|
145
|
+
lines: readonly BodyLine[],
|
|
146
|
+
bans: BanSets,
|
|
147
|
+
): BanFinding[] {
|
|
148
|
+
const found: BanFinding[] = []
|
|
149
|
+
|
|
150
|
+
const patterns: { kind: BanKind; term: string; pattern: RegExp }[] = [
|
|
151
|
+
...bans.words.map((term) => ({
|
|
152
|
+
kind: 'word' as const,
|
|
153
|
+
term,
|
|
154
|
+
pattern: new RegExp(`\\b${escape(term)}\\b`, 'gi'),
|
|
155
|
+
})),
|
|
156
|
+
...bans.spellings.map((term) => ({
|
|
157
|
+
kind: 'spelling' as const,
|
|
158
|
+
term,
|
|
159
|
+
pattern: new RegExp(`\\b${escape(term)}\\b`, 'gi'),
|
|
160
|
+
})),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
if (line.fenced) continue
|
|
165
|
+
const text = maskDisplayed(line.text)
|
|
166
|
+
|
|
167
|
+
for (const term of bans.characters) {
|
|
168
|
+
let column = text.indexOf(term)
|
|
169
|
+
while (column !== -1) {
|
|
170
|
+
found.push({ line: line.number, column, kind: 'character', term })
|
|
171
|
+
column = text.indexOf(term, column + term.length)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const { kind, term, pattern } of patterns) {
|
|
176
|
+
for (const match of text.matchAll(pattern)) {
|
|
177
|
+
found.push({ line: line.number, column: match.index, kind, term })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return found.sort((a, b) => a.line - b.line || a.column - b.column)
|
|
183
|
+
}
|