agent-simple-english 0.3.0 → 0.4.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-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -4
- package/package.json +13 -7
- package/src/cli/hook.ts +4 -1
- package/src/cli/main.ts +43 -19
- package/src/cli/observation-log.ts +1 -1
- package/src/cli/session-command.ts +13 -1
- package/src/cli/session-state.ts +17 -6
- package/src/config/load.ts +1 -1
- package/src/engine/comments.ts +1 -1
- package/src/engine/html.ts +158 -0
- package/src/engine/kinds.ts +24 -0
- package/src/engine/lint.ts +6 -4
- package/src/engine/suppression.ts +12 -7
- package/src/engine/types.ts +1 -1
- package/src/extension/index.ts +6 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "simple-english",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "Apply technical and house-style writing rules to writes, edits, and git commit messages.",
|
|
6
6
|
"repository": "https://github.com/jyooi/agent-simple-english",
|
|
7
7
|
"license": "MIT",
|
package/README.md
CHANGED
|
@@ -174,7 +174,7 @@ Observation-write failures are silent and do not change the hook output or decis
|
|
|
174
174
|
|
|
175
175
|
Enabled Hook mode logs every write, edit, static commit-message, and reply lint decision to the local XDG state directory.
|
|
176
176
|
Observation logging is on by default, and the log includes clean allows and soft Findings.
|
|
177
|
-
Plain lint runs, disabled hook sessions, and the pi Adapter do not write Observations.
|
|
177
|
+
Plain lint runs, disabled hook sessions, skipped paths, and the pi Adapter do not write Observations.
|
|
178
178
|
Set `SIMPLE_ENGLISH_OBSERVE=0` to stop observation logging.
|
|
179
179
|
|
|
180
180
|
Monthly Observation files use `$XDG_STATE_HOME/simple-english/observations/YYYY-MM.jsonl`.
|
|
@@ -249,13 +249,14 @@ Soft violations can appear with exit code 0.
|
|
|
249
249
|
|
|
250
250
|
### CLI flags
|
|
251
251
|
|
|
252
|
-
- `--json` writes one JSON report with `violations` and `
|
|
252
|
+
- `--json` writes one JSON report with `violations`, `summary`, and `skipped` fields.
|
|
253
|
+
The `skipped` array is always present, and it is empty when no input was skipped.
|
|
253
254
|
Each violation includes its offending sentence or paragraph as `snippet`.
|
|
254
255
|
|
|
255
256
|
- `--config <path>` uses only that config file and disables config discovery.
|
|
256
257
|
|
|
257
258
|
- `--kind <kind>` sets one content kind for all inputs.
|
|
258
|
-
Valid values are `prose-file`, `slash-source`, `hash-source`, and `commit-message`.
|
|
259
|
+
Valid values are `prose-file`, `slash-source`, `hash-source`, `html`, and `commit-message`.
|
|
259
260
|
The form `--kind=<kind>` also works.
|
|
260
261
|
|
|
261
262
|
- `--help` writes the command usage.
|
|
@@ -278,8 +279,45 @@ It is the default for standard input, extensionless paths, and file types that h
|
|
|
278
279
|
`hash-source` checks comments in these file types:
|
|
279
280
|
`.sh`, `.bash`, `.zsh`, `.py`, `.rb`, `.yaml`, `.yml`, `.toml`, and `.pl`.
|
|
280
281
|
|
|
282
|
+
`html` checks the text nodes of `.html` and `.htm` files.
|
|
283
|
+
It reads the page as a reader sees it, and it ignores everything else.
|
|
284
|
+
|
|
281
285
|
`commit-message` checks the complete input as a commit message.
|
|
282
286
|
|
|
287
|
+
A skipped path carries no check.
|
|
288
|
+
These extensions skip every check: `.css`, `.scss`, `.less`, `.json`, `.jsonc`, `.svg`, `.xml`, `.typ`, `.csv`, `.tsv`, and `.lock`.
|
|
289
|
+
An extensionless path still uses `prose-file`.
|
|
290
|
+
The Claude Code hook and the pi write and edit gates allow a skipped path with no lint.
|
|
291
|
+
The CLI prints one line that names a skipped file and exits 0.
|
|
292
|
+
An explicit `--kind` flag forces a lint on a skipped file.
|
|
293
|
+
|
|
294
|
+
#### The html kind
|
|
295
|
+
|
|
296
|
+
The `html` kind parses the page and keeps only its text nodes.
|
|
297
|
+
It drops tags, attribute values, comments, entity references, and doctype declarations.
|
|
298
|
+
It also drops the content of `script`, `style`, `pre`, `code`, and `textarea`.
|
|
299
|
+
A semicolon in a style rule or an inline script stays quiet.
|
|
300
|
+
A semicolon in a paragraph reports a violation at its original column.
|
|
301
|
+
|
|
302
|
+
Each prose block is one unit for the sentence rules and the paragraph rule.
|
|
303
|
+
A sentence split across phrasing elements counts as one sentence.
|
|
304
|
+
These phrasing elements stay inside their prose block:
|
|
305
|
+
|
|
306
|
+
```text
|
|
307
|
+
a abbr b bdi bdo cite code data del dfn em i img ins kbd label mark q rp rt
|
|
308
|
+
ruby s samp small span strong sub sup time u var wbr
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Every other element starts a new prose block.
|
|
312
|
+
That rule separates each of these elements from its neighbours:
|
|
313
|
+
|
|
314
|
+
```text
|
|
315
|
+
p li h1 h2 h3 h4 h5 h6 td th blockquote figcaption dt dd title br
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Bare text directly inside `div`, `section`, or `body` is a prose block of its own.
|
|
319
|
+
An unknown element also starts a prose block.
|
|
320
|
+
|
|
283
321
|
File extension matching does not depend on letter case.
|
|
284
322
|
Source kinds ignore comment markers inside supported string literal forms.
|
|
285
323
|
All kinds preserve the original line and column.
|
|
@@ -288,7 +326,7 @@ They ignore identifiers, YAML frontmatter, valid GFM tables, and fenced, indente
|
|
|
288
326
|
### Inline suppression
|
|
289
327
|
|
|
290
328
|
A suppression directive names one or more registered rule IDs and applies only to the next physical line.
|
|
291
|
-
Use this
|
|
329
|
+
Use this comment form in prose files and in HTML pages:
|
|
292
330
|
|
|
293
331
|
```md
|
|
294
332
|
<!-- ste-disable-next-line marketing -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-simple-english",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Technical and house-style English lint engine, CLI, and host adapters",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,9 +15,15 @@
|
|
|
15
15
|
"bugs": {
|
|
16
16
|
"url": "https://github.com/jyooi/agent-simple-english/issues"
|
|
17
17
|
},
|
|
18
|
-
"keywords": [
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pi-package",
|
|
20
|
+
"simplified-technical-english",
|
|
21
|
+
"linter"
|
|
22
|
+
],
|
|
19
23
|
"pi": {
|
|
20
|
-
"extensions": [
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./src/extension/index.ts"
|
|
26
|
+
]
|
|
21
27
|
},
|
|
22
28
|
"files": [
|
|
23
29
|
".claude-plugin",
|
|
@@ -65,12 +71,12 @@
|
|
|
65
71
|
}
|
|
66
72
|
},
|
|
67
73
|
"devDependencies": {
|
|
68
|
-
"@biomejs/biome": "^
|
|
69
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
70
|
-
"@types/node": "^
|
|
74
|
+
"@biomejs/biome": "^2.5.12",
|
|
75
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
76
|
+
"@types/node": "^26.0.0",
|
|
71
77
|
"jiti": "2.7.0",
|
|
72
78
|
"typebox": "1.3.7",
|
|
73
79
|
"typescript": "^5.8.0",
|
|
74
|
-
"vitest": "^
|
|
80
|
+
"vitest": "^5.0.0"
|
|
75
81
|
}
|
|
76
82
|
}
|
package/src/cli/hook.ts
CHANGED
|
@@ -14,9 +14,9 @@ import type { Tagger } from "../engine/tagger.ts"
|
|
|
14
14
|
import type { LintKind, LintOptions, ReportViolation } from "../engine/types.ts"
|
|
15
15
|
import { TaggerService } from "../tagger/wink.ts"
|
|
16
16
|
import {
|
|
17
|
+
appendObservation,
|
|
17
18
|
type ObservationDraft,
|
|
18
19
|
type ObservationEvent,
|
|
19
|
-
appendObservation,
|
|
20
20
|
} from "./observation-log.ts"
|
|
21
21
|
import {
|
|
22
22
|
consumePendingFeedback,
|
|
@@ -585,6 +585,9 @@ function textDecision(
|
|
|
585
585
|
previousText?: string,
|
|
586
586
|
): HookEvaluation {
|
|
587
587
|
const classification = classifyPath(path)
|
|
588
|
+
if (classification.skipped) {
|
|
589
|
+
return { output: allow() }
|
|
590
|
+
}
|
|
588
591
|
const report = lint(classification.kind, text, {
|
|
589
592
|
...options,
|
|
590
593
|
sourceDialect: classification.sourceDialect,
|
package/src/cli/main.ts
CHANGED
|
@@ -5,7 +5,7 @@ import packageManifest from "../../package.json" with { type: "json" }
|
|
|
5
5
|
import { loadConfig } from "../config/load.ts"
|
|
6
6
|
import { loadConfiguredDictionary } from "../dictionary/configured.ts"
|
|
7
7
|
import { loadRuleData } from "../dictionary/load.ts"
|
|
8
|
-
import { classifyPath } from "../engine/kinds.ts"
|
|
8
|
+
import { classifyPath, type PathClassification } from "../engine/kinds.ts"
|
|
9
9
|
import { lint } from "../engine/lint.ts"
|
|
10
10
|
import type { LintKind, LintReport } from "../engine/types.ts"
|
|
11
11
|
import { TaggerService, WinkTaggerLive } from "../tagger/wink.ts"
|
|
@@ -13,7 +13,13 @@ import { hookInternalFailure, runHookMode } from "./hook.ts"
|
|
|
13
13
|
import { observationStats, reviewObservations } from "./observation-log.ts"
|
|
14
14
|
import { runSessionCommand } from "./session-command.ts"
|
|
15
15
|
|
|
16
|
-
const KINDS: readonly LintKind[] = [
|
|
16
|
+
const KINDS: readonly LintKind[] = [
|
|
17
|
+
"prose-file",
|
|
18
|
+
"slash-source",
|
|
19
|
+
"hash-source",
|
|
20
|
+
"html",
|
|
21
|
+
"commit-message",
|
|
22
|
+
]
|
|
17
23
|
|
|
18
24
|
const USAGE = `Usage: simple-english [options] [paths...]
|
|
19
25
|
simple-english observe review
|
|
@@ -102,6 +108,7 @@ interface FileViolation {
|
|
|
102
108
|
interface CliReport {
|
|
103
109
|
readonly violations: readonly FileViolation[]
|
|
104
110
|
readonly summary: { readonly total: number; readonly hard: number }
|
|
111
|
+
readonly skipped: readonly string[]
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
const readStdin = Effect.promise(async () => {
|
|
@@ -120,7 +127,10 @@ const readInput = (path: string) =>
|
|
|
120
127
|
catch: (cause) => new Error(`cannot read ${path}: ${cause}`),
|
|
121
128
|
}).pipe(Effect.map((text) => ({ path, text })))
|
|
122
129
|
|
|
123
|
-
const toCliReport = (
|
|
130
|
+
const toCliReport = (
|
|
131
|
+
reports: readonly { path: string; report: LintReport }[],
|
|
132
|
+
skipped: readonly string[],
|
|
133
|
+
): CliReport => {
|
|
124
134
|
const violations = reports.flatMap(({ path, report }) =>
|
|
125
135
|
report.violations.map((violation) => ({ file: path, ...violation })),
|
|
126
136
|
)
|
|
@@ -130,6 +140,7 @@ const toCliReport = (reports: readonly { path: string; report: LintReport }[]):
|
|
|
130
140
|
total: violations.length,
|
|
131
141
|
hard: violations.filter((violation) => violation.severity === "hard").length,
|
|
132
142
|
},
|
|
143
|
+
skipped,
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
|
|
@@ -137,9 +148,13 @@ const render = (report: CliReport, json: boolean): string => {
|
|
|
137
148
|
if (json) {
|
|
138
149
|
return JSON.stringify(report, null, 2)
|
|
139
150
|
}
|
|
140
|
-
|
|
141
|
-
.map((
|
|
142
|
-
.
|
|
151
|
+
const lines = [
|
|
152
|
+
...report.skipped.map((path) => `${path}: skipped (non-prose extension)`),
|
|
153
|
+
...report.violations.map(
|
|
154
|
+
(v) => `${v.file}:${v.line}:${v.column} [${v.severity}] ${v.ruleId} ${v.message}`,
|
|
155
|
+
),
|
|
156
|
+
]
|
|
157
|
+
return lines.join("\n")
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
const args = process.argv.slice(2)
|
|
@@ -225,20 +240,29 @@ const lintProgram = Effect.gen(function* () {
|
|
|
225
240
|
? [{ path: "<stdin>", text: yield* readStdin }]
|
|
226
241
|
: yield* Effect.forEach(paths, readInput)
|
|
227
242
|
|
|
243
|
+
const skippedPaths: string[] = []
|
|
244
|
+
const lintable: { path: string; text: string; classification: PathClassification }[] = []
|
|
245
|
+
for (const input of inputs) {
|
|
246
|
+
const classification = classifyPath(input.path)
|
|
247
|
+
if (kind === undefined && classification.skipped) {
|
|
248
|
+
skippedPaths.push(input.path)
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
lintable.push({ ...input, classification })
|
|
252
|
+
}
|
|
253
|
+
|
|
228
254
|
const report = toCliReport(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
}),
|
|
255
|
+
lintable.map(({ path, text, classification }) => ({
|
|
256
|
+
path,
|
|
257
|
+
report: lint(kind ?? classification.kind, text, {
|
|
258
|
+
...config,
|
|
259
|
+
dictionary,
|
|
260
|
+
ruleData,
|
|
261
|
+
tagger,
|
|
262
|
+
sourceDialect: classification.sourceDialect,
|
|
263
|
+
}),
|
|
264
|
+
})),
|
|
265
|
+
skippedPaths,
|
|
242
266
|
)
|
|
243
267
|
|
|
244
268
|
const output = render(report, json)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
-
import { mkdir, open,
|
|
2
|
+
import { mkdir, open, readdir, readFile } from "node:fs/promises"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import { createInterface } from "node:readline"
|
|
5
5
|
import type { LintKind, ReportViolation } from "../engine/types.ts"
|
|
@@ -5,10 +5,11 @@ import { loadConfig } from "../config/load.ts"
|
|
|
5
5
|
import { loadConfiguredDictionary } from "../dictionary/configured.ts"
|
|
6
6
|
import { loadRuleData } from "../dictionary/load.ts"
|
|
7
7
|
import {
|
|
8
|
-
type SessionControl,
|
|
9
8
|
getSessionControl,
|
|
9
|
+
type SessionControl,
|
|
10
10
|
setSessionEnabled,
|
|
11
11
|
setSessionStrict,
|
|
12
|
+
toggleSessionEnabled,
|
|
12
13
|
} from "./session-state.ts"
|
|
13
14
|
|
|
14
15
|
const USAGE = "Usage: /ase [on|off|status|strict|strict off]"
|
|
@@ -32,6 +33,12 @@ const updateEnabled = (sessionId: string, enabled: boolean) =>
|
|
|
32
33
|
catch: (cause) => new Error(`cannot update session state: ${cause}`),
|
|
33
34
|
})
|
|
34
35
|
|
|
36
|
+
const toggleEnabled = (sessionId: string) =>
|
|
37
|
+
Effect.tryPromise({
|
|
38
|
+
try: () => toggleSessionEnabled(sessionId),
|
|
39
|
+
catch: (cause) => new Error(`cannot update session state: ${cause}`),
|
|
40
|
+
})
|
|
41
|
+
|
|
35
42
|
const updateStrict = (sessionId: string, strict: boolean) =>
|
|
36
43
|
Effect.tryPromise({
|
|
37
44
|
try: () => setSessionStrict(sessionId, strict),
|
|
@@ -69,6 +76,11 @@ export function runSessionCommand(args: readonly string[]): Effect.Effect<string
|
|
|
69
76
|
return Effect.fail(new Error(USAGE))
|
|
70
77
|
}
|
|
71
78
|
const command = commandParts.join(" ").trim().toLowerCase()
|
|
79
|
+
if (command === "") {
|
|
80
|
+
return toggleEnabled(sessionId).pipe(
|
|
81
|
+
Effect.map((enabled) => `Writing-rule enforcement ${enabled ? "enabled" : "disabled"}.`),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
72
84
|
if (command === "status") return status(sessionId, cwd)
|
|
73
85
|
if (command === "on") {
|
|
74
86
|
return updateEnabled(sessionId, true).pipe(Effect.as("Writing-rule enforcement enabled."))
|
package/src/cli/session-state.ts
CHANGED
|
@@ -147,15 +147,26 @@ export async function getSessionControl(sessionId: string): Promise<SessionContr
|
|
|
147
147
|
})
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
const setEnabled = (state: SessionState, enabled: boolean): SessionState => ({
|
|
151
|
+
...state,
|
|
152
|
+
enabled,
|
|
153
|
+
strict: enabled ? state.strict : false,
|
|
154
|
+
...(enabled || state.pendingFeedback === undefined ? {} : { pendingFeedback: undefined }),
|
|
155
|
+
})
|
|
156
|
+
|
|
150
157
|
export async function setSessionEnabled(sessionId: string, enabled: boolean): Promise<void> {
|
|
151
158
|
await withStateLock(sessionId, async () => {
|
|
152
159
|
const state = currentState(await readState(sessionId))
|
|
153
|
-
await writeState(sessionId,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
await writeState(sessionId, setEnabled(state, enabled))
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function toggleSessionEnabled(sessionId: string): Promise<boolean> {
|
|
165
|
+
return withStateLock(sessionId, async () => {
|
|
166
|
+
const state = currentState(await readState(sessionId))
|
|
167
|
+
const enabled = !state.enabled
|
|
168
|
+
await writeState(sessionId, setEnabled(state, enabled))
|
|
169
|
+
return enabled
|
|
159
170
|
})
|
|
160
171
|
}
|
|
161
172
|
|
package/src/config/load.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from "node:os"
|
|
|
3
3
|
import { isAbsolute, join, resolve } from "node:path"
|
|
4
4
|
import { Effect } from "effect"
|
|
5
5
|
import { mergeConfigs } from "./merge.ts"
|
|
6
|
-
import { ConfigError, type SteConfig
|
|
6
|
+
import { ConfigError, decodeConfig, type SteConfig } from "./schema.ts"
|
|
7
7
|
|
|
8
8
|
const legacyAgentConfigDirectory = (cwd: string): string => {
|
|
9
9
|
const configured = process.env.PI_CODING_AGENT_DIR
|
package/src/engine/comments.ts
CHANGED
|
@@ -354,7 +354,7 @@ interface YamlBlockScalar {
|
|
|
354
354
|
const leadingSpaces = (line: string): number => line.length - line.replace(/^ */u, "").length
|
|
355
355
|
const YAML_BLOCK_SCALAR_CONTEXT = /(?:^[ \t]*(?:[-?:][ \t]+)*|:[ \t]+)(?:[&!][^\s]+[ \t]+)*$/u
|
|
356
356
|
const YAML_QUOTED_SCALAR_CONTEXT =
|
|
357
|
-
/(?:^[ \t]*(?:(?:---|\.\.\.)[ \t]+)?(?:[-?:][ \t]+)*|:[ \t]+|[
|
|
357
|
+
/(?:^[ \t]*(?:(?:---|\.\.\.)[ \t]+)?(?:[-?:][ \t]+)*|:[ \t]+|[[{,][ \t]*)(?:[&!][^\s,[\]{}]+[ \t]+)*$/u
|
|
358
358
|
|
|
359
359
|
const isYamlQuotedScalarStart = (line: string, index: number): boolean =>
|
|
360
360
|
YAML_QUOTED_SCALAR_CONTEXT.test(line.slice(0, index))
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { parser as htmlParser } from "@lezer/html"
|
|
2
|
+
import type { ExtractedComments, ProseBreak } from "./comments.ts"
|
|
3
|
+
import type { MarkdownHtmlComment } from "./markdown.ts"
|
|
4
|
+
|
|
5
|
+
// These elements hold code, markup, or preformatted content, never prose.
|
|
6
|
+
// `code` is also inline, so a semicolon in inline code stays quiet while the
|
|
7
|
+
// sentence around it still reads as one sentence.
|
|
8
|
+
const IGNORED_CONTENT_TAGS = new Set(["code", "pre", "script", "style", "textarea"])
|
|
9
|
+
|
|
10
|
+
// Phrasing elements that wrap words inside one sentence.
|
|
11
|
+
// They never start a prose block, so a sentence split across them counts as one sentence.
|
|
12
|
+
// Every other element starts a prose block.
|
|
13
|
+
// That rule separates a heading, a list item, a table cell, and bare text in a `div`.
|
|
14
|
+
const INLINE_TAGS = new Set([
|
|
15
|
+
"a",
|
|
16
|
+
"abbr",
|
|
17
|
+
"b",
|
|
18
|
+
"bdi",
|
|
19
|
+
"bdo",
|
|
20
|
+
"cite",
|
|
21
|
+
"code",
|
|
22
|
+
"data",
|
|
23
|
+
"del",
|
|
24
|
+
"dfn",
|
|
25
|
+
"em",
|
|
26
|
+
"i",
|
|
27
|
+
"img",
|
|
28
|
+
"ins",
|
|
29
|
+
"kbd",
|
|
30
|
+
"label",
|
|
31
|
+
"mark",
|
|
32
|
+
"q",
|
|
33
|
+
"rp",
|
|
34
|
+
"rt",
|
|
35
|
+
"ruby",
|
|
36
|
+
"s",
|
|
37
|
+
"samp",
|
|
38
|
+
"small",
|
|
39
|
+
"span",
|
|
40
|
+
"strong",
|
|
41
|
+
"sub",
|
|
42
|
+
"sup",
|
|
43
|
+
"time",
|
|
44
|
+
"u",
|
|
45
|
+
"var",
|
|
46
|
+
"wbr",
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find every single-line HTML comment in the document with the lezer parser.
|
|
51
|
+
* The markdown finder cannot see comments that markdown would read as indented code.
|
|
52
|
+
*/
|
|
53
|
+
export function htmlComments(source: string): readonly MarkdownHtmlComment[] {
|
|
54
|
+
const comments: MarkdownHtmlComment[] = []
|
|
55
|
+
const lineStarts = [0]
|
|
56
|
+
for (let offset = 0; offset < source.length; offset++) {
|
|
57
|
+
if (source.charCodeAt(offset) === 0x0a) lineStarts.push(offset + 1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
htmlParser.parse(source).iterate({
|
|
61
|
+
enter(ref) {
|
|
62
|
+
if (ref.name !== "Comment") return
|
|
63
|
+
const text = source.slice(ref.from, ref.to)
|
|
64
|
+
if (text.includes("\n")) return
|
|
65
|
+
|
|
66
|
+
let lineIndex = lineStarts.length - 1
|
|
67
|
+
while (lineIndex > 0 && (lineStarts[lineIndex] as number) > ref.from) lineIndex -= 1
|
|
68
|
+
const lineStart = lineStarts[lineIndex] as number
|
|
69
|
+
comments.push({
|
|
70
|
+
line: lineIndex + 1,
|
|
71
|
+
startColumn: ref.from - lineStart,
|
|
72
|
+
endColumn: ref.to - lineStart,
|
|
73
|
+
text,
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return comments
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface OpenElement {
|
|
82
|
+
readonly tag: string
|
|
83
|
+
readonly inline: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Keep the text nodes of an HTML document and blank every other byte with a space.
|
|
88
|
+
* Attribute values, comments, entity references, tags, and ignored element content all go.
|
|
89
|
+
* Each prose block becomes one prose break, so sentences never join across a block edge.
|
|
90
|
+
* The masked lines keep the width of the source lines, so positions stay exact.
|
|
91
|
+
*/
|
|
92
|
+
export function extractHtmlProse(source: string): ExtractedComments {
|
|
93
|
+
const keep = new Uint8Array(source.length)
|
|
94
|
+
const blockEdges: number[] = []
|
|
95
|
+
const open: OpenElement[] = []
|
|
96
|
+
let ignoreDepth = 0
|
|
97
|
+
|
|
98
|
+
htmlParser.parse(source).iterate({
|
|
99
|
+
enter(ref) {
|
|
100
|
+
if (ref.name === "Element") {
|
|
101
|
+
const tagName = ref.node.firstChild?.getChild("TagName")
|
|
102
|
+
const tag =
|
|
103
|
+
tagName === null || tagName === undefined
|
|
104
|
+
? ""
|
|
105
|
+
: source.slice(tagName.from, tagName.to).toLowerCase()
|
|
106
|
+
const inline = INLINE_TAGS.has(tag)
|
|
107
|
+
if (!inline) blockEdges.push(ref.from)
|
|
108
|
+
if (IGNORED_CONTENT_TAGS.has(tag)) ignoreDepth += 1
|
|
109
|
+
open.push({ tag, inline })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (ref.name === "Text" && ignoreDepth === 0) keep.fill(1, ref.from, ref.to)
|
|
113
|
+
},
|
|
114
|
+
leave(ref) {
|
|
115
|
+
if (ref.name !== "Element") return
|
|
116
|
+
const element = open.pop()
|
|
117
|
+
if (element === undefined) return
|
|
118
|
+
if (IGNORED_CONTENT_TAGS.has(element.tag)) ignoreDepth -= 1
|
|
119
|
+
if (!element.inline) blockEdges.push(ref.to)
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
blockEdges.sort((left, right) => left - right)
|
|
124
|
+
|
|
125
|
+
const lines = source.split("\n")
|
|
126
|
+
const contentStarts: number[] = []
|
|
127
|
+
const proseBreaks: ProseBreak[] = []
|
|
128
|
+
let lineStart = 0
|
|
129
|
+
let edgeIndex = 0
|
|
130
|
+
let blockIndex = -1
|
|
131
|
+
|
|
132
|
+
const maskedLines = lines.map((line, lineIndex) => {
|
|
133
|
+
const characters = new Array<string>(line.length).fill(" ")
|
|
134
|
+
let contentStart = -1
|
|
135
|
+
|
|
136
|
+
for (let column = 0; column < line.length; column += 1) {
|
|
137
|
+
const offset = lineStart + column
|
|
138
|
+
while (edgeIndex < blockEdges.length && (blockEdges[edgeIndex] as number) <= offset) {
|
|
139
|
+
edgeIndex += 1
|
|
140
|
+
}
|
|
141
|
+
if (keep[offset] === 0) continue
|
|
142
|
+
|
|
143
|
+
const character = source[offset] as string
|
|
144
|
+
characters[column] = character
|
|
145
|
+
if (/\s/u.test(character)) continue
|
|
146
|
+
if (contentStart === -1) contentStart = column
|
|
147
|
+
if (edgeIndex === blockIndex) continue
|
|
148
|
+
if (blockIndex !== -1) proseBreaks.push({ line: lineIndex, column })
|
|
149
|
+
blockIndex = edgeIndex
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
lineStart += line.length + 1
|
|
153
|
+
contentStarts.push(contentStart === -1 ? line.length : contentStart)
|
|
154
|
+
return characters.join("")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return { lines: maskedLines, contentStarts, proseBreaks, lineComments: [] }
|
|
158
|
+
}
|
package/src/engine/kinds.ts
CHANGED
|
@@ -24,10 +24,28 @@ const SLASH_EXTENSIONS = new Set([
|
|
|
24
24
|
const JAVASCRIPT_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"])
|
|
25
25
|
const NESTED_SLASH_EXTENSIONS = new Set(["rs", "swift", "kt", "scala"])
|
|
26
26
|
const HASH_EXTENSIONS = new Set(["sh", "bash", "zsh", "py", "rb", "yaml", "yml", "toml", "pl"])
|
|
27
|
+
const HTML_EXTENSIONS = new Set(["html", "htm"])
|
|
28
|
+
|
|
29
|
+
// Data and style extensions carry rules and structured data rather than prose,
|
|
30
|
+
// so the writing rules would misread them as sentences (HUF-308).
|
|
31
|
+
const SKIP_EXTENSIONS = new Set([
|
|
32
|
+
"css",
|
|
33
|
+
"scss",
|
|
34
|
+
"less",
|
|
35
|
+
"json",
|
|
36
|
+
"jsonc",
|
|
37
|
+
"svg",
|
|
38
|
+
"xml",
|
|
39
|
+
"typ",
|
|
40
|
+
"csv",
|
|
41
|
+
"tsv",
|
|
42
|
+
"lock",
|
|
43
|
+
])
|
|
27
44
|
|
|
28
45
|
export interface PathClassification {
|
|
29
46
|
readonly kind: LintKind
|
|
30
47
|
readonly sourceDialect: SourceDialect
|
|
48
|
+
readonly skipped?: true
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
export const classifyPath = (path: string): PathClassification => {
|
|
@@ -36,6 +54,12 @@ export const classifyPath = (path: string): PathClassification => {
|
|
|
36
54
|
return { kind: "prose-file", sourceDialect: "general" }
|
|
37
55
|
}
|
|
38
56
|
const extension = path.slice(dot + 1).toLowerCase()
|
|
57
|
+
if (SKIP_EXTENSIONS.has(extension)) {
|
|
58
|
+
return { kind: "prose-file", sourceDialect: "general", skipped: true }
|
|
59
|
+
}
|
|
60
|
+
if (HTML_EXTENSIONS.has(extension)) {
|
|
61
|
+
return { kind: "html", sourceDialect: "general" }
|
|
62
|
+
}
|
|
39
63
|
if (SLASH_EXTENSIONS.has(extension)) {
|
|
40
64
|
const sourceDialect = JAVASCRIPT_EXTENSIONS.has(extension)
|
|
41
65
|
? "javascript"
|
package/src/engine/lint.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { BUNDLED_RULE_DATA } from "../dictionary/bundled-rule-data.ts"
|
|
2
2
|
import type { RuleData } from "../dictionary/rule-data.ts"
|
|
3
3
|
import {
|
|
4
|
-
type LineCommentSpan,
|
|
5
|
-
type ProseBreak,
|
|
6
4
|
extractHashComments,
|
|
7
5
|
extractSlashComments,
|
|
6
|
+
type LineCommentSpan,
|
|
7
|
+
type ProseBreak,
|
|
8
8
|
} from "./comments.ts"
|
|
9
|
-
import { type ScopedViolation, type ViolationScope, newFindings } from "./diff-match.ts"
|
|
10
9
|
import { changedText } from "./diff.ts"
|
|
10
|
+
import { newFindings, type ScopedViolation, type ViolationScope } from "./diff-match.ts"
|
|
11
|
+
import { extractHtmlProse } from "./html.ts"
|
|
11
12
|
import { blankIdentifiers } from "./identifiers.ts"
|
|
12
13
|
import { blankMarkdownForLint } from "./markdown.ts"
|
|
13
14
|
import { type Paragraph, segmentParagraphs } from "./paragraphs.ts"
|
|
@@ -21,7 +22,7 @@ import { semicolon } from "./rules/semicolon.ts"
|
|
|
21
22
|
import { sentenceLength } from "./rules/sentence-length.ts"
|
|
22
23
|
import { verbForm } from "./rules/verb-form.ts"
|
|
23
24
|
import { type Sentence, segmentSentences } from "./sentences.ts"
|
|
24
|
-
import { type SuppressionRange
|
|
25
|
+
import { analyzeSuppressions, type SuppressionRange } from "./suppression.ts"
|
|
25
26
|
import type { Tagger } from "./tagger.ts"
|
|
26
27
|
import type { LintKind, LintOptions, LintReport, Violation } from "./types.ts"
|
|
27
28
|
|
|
@@ -121,6 +122,7 @@ const splitProseRuns = (extracted: ExtractedProse): readonly ProseRun[] => {
|
|
|
121
122
|
const extract = (kind: LintKind, text: string, options: LintOptions): ExtractedProse => {
|
|
122
123
|
if (kind === "slash-source") return extractSlashComments(text, options.sourceDialect)
|
|
123
124
|
if (kind === "hash-source") return extractHashComments(text, options.sourceDialect)
|
|
125
|
+
if (kind === "html") return extractHtmlProse(text)
|
|
124
126
|
return wholeText(text)
|
|
125
127
|
}
|
|
126
128
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LineCommentSpan } from "./comments.ts"
|
|
2
2
|
import type { ScopedViolation } from "./diff-match.ts"
|
|
3
|
-
import {
|
|
3
|
+
import { htmlComments } from "./html.ts"
|
|
4
|
+
import { type MarkdownHtmlComment, markdownHtmlComments } from "./markdown.ts"
|
|
4
5
|
import { type RuleId, ruleIds } from "./rules/registry.ts"
|
|
5
6
|
import type { LintKind } from "./types.ts"
|
|
6
7
|
|
|
@@ -54,8 +55,10 @@ const sourceCandidates = (
|
|
|
54
55
|
]
|
|
55
56
|
})
|
|
56
57
|
|
|
57
|
-
const
|
|
58
|
-
|
|
58
|
+
const commentCandidates = (
|
|
59
|
+
comments: readonly MarkdownHtmlComment[],
|
|
60
|
+
): readonly DirectiveCandidate[] =>
|
|
61
|
+
comments.flatMap((comment) => {
|
|
59
62
|
const match = comment.text.match(markdownDirective)
|
|
60
63
|
if (match === null) return []
|
|
61
64
|
|
|
@@ -139,10 +142,12 @@ export function analyzeSuppressions(
|
|
|
139
142
|
const lines = text.split("\n")
|
|
140
143
|
const candidates =
|
|
141
144
|
kind === "prose-file"
|
|
142
|
-
?
|
|
143
|
-
: kind === "
|
|
144
|
-
?
|
|
145
|
-
:
|
|
145
|
+
? commentCandidates(markdownHtmlComments(text))
|
|
146
|
+
: kind === "html"
|
|
147
|
+
? commentCandidates(htmlComments(text))
|
|
148
|
+
: kind === "slash-source" || kind === "hash-source"
|
|
149
|
+
? sourceCandidates(lines, lineComments)
|
|
150
|
+
: []
|
|
146
151
|
const directives = candidates.map(parseDirective)
|
|
147
152
|
const offsets = offsetsForLines(lines)
|
|
148
153
|
const ruleIdsByTargetLine = new Map<number, Set<RuleId>>()
|
package/src/engine/types.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { DictionaryData } from "../dictionary/schema.ts"
|
|
|
3
3
|
import type { RuleId } from "./rules/registry.ts"
|
|
4
4
|
import type { Tagger } from "./tagger.ts"
|
|
5
5
|
|
|
6
|
-
export type LintKind = "prose-file" | "slash-source" | "hash-source" | "commit-message"
|
|
6
|
+
export type LintKind = "prose-file" | "slash-source" | "hash-source" | "html" | "commit-message"
|
|
7
7
|
|
|
8
8
|
export type Severity = "hard" | "soft"
|
|
9
9
|
|
package/src/extension/index.ts
CHANGED
|
@@ -2,15 +2,15 @@ import { constants } from "node:fs"
|
|
|
2
2
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises"
|
|
3
3
|
import { dirname } from "node:path"
|
|
4
4
|
import {
|
|
5
|
+
createBashToolDefinition,
|
|
6
|
+
createEditToolDefinition,
|
|
7
|
+
createWriteToolDefinition,
|
|
5
8
|
type ExtensionAPI,
|
|
6
9
|
type ExtensionContext,
|
|
7
10
|
ExtensionRunner,
|
|
8
11
|
type MessageStartEvent,
|
|
9
12
|
type MessageUpdateEvent,
|
|
10
13
|
type ToolCallEventResult,
|
|
11
|
-
createBashToolDefinition,
|
|
12
|
-
createEditToolDefinition,
|
|
13
|
-
createWriteToolDefinition,
|
|
14
14
|
} from "@earendil-works/pi-coding-agent"
|
|
15
15
|
import type { AutocompleteItem } from "@earendil-works/pi-tui"
|
|
16
16
|
import { Effect } from "effect"
|
|
@@ -353,6 +353,9 @@ function lintProposedText(
|
|
|
353
353
|
previousText?: string,
|
|
354
354
|
): ToolCallEventResult | undefined {
|
|
355
355
|
const classification = classifyPath(path)
|
|
356
|
+
if (classification.skipped) {
|
|
357
|
+
return undefined
|
|
358
|
+
}
|
|
356
359
|
const report = lint(classification.kind, text, {
|
|
357
360
|
...state.config,
|
|
358
361
|
dictionary: state.dictionary,
|