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.
@@ -11,7 +11,7 @@
11
11
  {
12
12
  "name": "simple-english",
13
13
  "description": "Apply technical and house-style writing rules to writes, edits, and git commit messages.",
14
- "version": "0.3.0",
14
+ "version": "0.4.0",
15
15
  "author": {
16
16
  "name": "JIA YI"
17
17
  },
@@ -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.3.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 `summary` fields.
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 Markdown comment form in prose files:
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.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": ["pi-package", "simplified-technical-english", "linter"],
18
+ "keywords": [
19
+ "pi-package",
20
+ "simplified-technical-english",
21
+ "linter"
22
+ ],
19
23
  "pi": {
20
- "extensions": ["./src/extension/index.ts"]
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": "^1.9.4",
69
- "@earendil-works/pi-coding-agent": "0.83.0",
70
- "@types/node": "^22.0.0",
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": "^3.0.0"
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[] = ["prose-file", "slash-source", "hash-source", "commit-message"]
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 = (reports: readonly { path: string; report: LintReport }[]): CliReport => {
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
- return report.violations
141
- .map((v) => `${v.file}:${v.line}:${v.column} [${v.severity}] ${v.ruleId} ${v.message}`)
142
- .join("\n")
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
- inputs.map(({ path, text }) => {
230
- const classification = classifyPath(path)
231
- return {
232
- path,
233
- report: lint(kind ?? classification.kind, text, {
234
- ...config,
235
- dictionary,
236
- ruleData,
237
- tagger,
238
- sourceDialect: classification.sourceDialect,
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, readFile, readdir } from "node:fs/promises"
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."))
@@ -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
- ...state,
155
- enabled,
156
- strict: enabled ? state.strict : false,
157
- ...(enabled || state.pendingFeedback === undefined ? {} : { pendingFeedback: undefined }),
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
 
@@ -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, decodeConfig } from "./schema.ts"
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
@@ -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]+|[\[{,][ \t]*)(?:[&!][^\s,[\]{}]+[ \t]+)*$/u
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
+ }
@@ -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"
@@ -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, analyzeSuppressions } from "./suppression.ts"
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 { markdownHtmlComments } from "./markdown.ts"
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 markdownCandidates = (text: string): readonly DirectiveCandidate[] =>
58
- markdownHtmlComments(text).flatMap((comment) => {
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
- ? markdownCandidates(text)
143
- : kind === "slash-source" || kind === "hash-source"
144
- ? sourceCandidates(lines, lineComments)
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>>()
@@ -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
 
@@ -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,