agent-simple-english 0.3.1 → 0.5.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.1",
14
+ "version": "0.5.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.1",
4
+ "version": "0.5.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.1",
3
+ "version": "0.5.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",
@@ -40,7 +46,7 @@
40
46
  "dependencies": {
41
47
  "@lezer/html": "^1.3.12",
42
48
  "@lezer/markdown": "^1.7.2",
43
- "effect": "^3.14.0",
49
+ "effect": "4.0.0-rc.112",
44
50
  "micromark": "^4.0.2",
45
51
  "micromark-extension-frontmatter": "^2.0.0",
46
52
  "micromark-extension-gfm-table": "^2.1.1",
@@ -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,
@@ -712,7 +715,7 @@ function recordEvaluation(event: HookEvent, evaluation: HookEvaluation): Effect.
712
715
  }),
713
716
  catch: () => undefined,
714
717
  }).pipe(
715
- Effect.catchAll(() => Effect.void),
718
+ Effect.catch(() => Effect.void),
716
719
  Effect.as(evaluation.output),
717
720
  )
718
721
  }
@@ -757,14 +760,14 @@ export function runHookMode(raw: string): Effect.Effect<HookOutput, never, Tagge
757
760
  return yield* recordEvaluation(event, evaluation)
758
761
  })
759
762
  }),
760
- Effect.catchAll((error) =>
763
+ Effect.catch((error) =>
761
764
  Effect.succeed(
762
765
  event.hookEventName === "PreToolUse"
763
766
  ? nonBlockingWarning(error.message)
764
767
  : nonBlockingError(error.message),
765
768
  ),
766
769
  ),
767
- Effect.catchAllCause((cause) =>
770
+ Effect.catchCause((cause) =>
768
771
  Effect.succeed(
769
772
  event.hookEventName === "PreToolUse"
770
773
  ? hookInternalFailure(cause)
package/src/cli/main.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { readFile } from "node:fs/promises"
3
- import { Effect, Either } from "effect"
3
+ import { Effect, Result } from "effect"
4
4
  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)
@@ -149,7 +164,7 @@ const hookProgram = Effect.gen(function* () {
149
164
  console.log(JSON.stringify(output))
150
165
  return 0
151
166
  }).pipe(
152
- Effect.catchAllCause((cause) =>
167
+ Effect.catchCause((cause) =>
153
168
  Effect.sync(() => {
154
169
  console.log(JSON.stringify(hookInternalFailure(cause)))
155
170
  return 0
@@ -205,40 +220,49 @@ const lintProgram = Effect.gen(function* () {
205
220
  )
206
221
  }
207
222
  const config = yield* loadConfig(configPath)
208
- const loadedDictionary = yield* Effect.either(
223
+ const loadedDictionary = yield* Effect.result(
209
224
  loadConfiguredDictionary(config, process.cwd(), process.env.SIMPLE_ENGLISH_DICTIONARY),
210
225
  )
211
- const loadedRuleData = yield* Effect.either(loadRuleData(config.ruleDataExtensions))
212
- if (Either.isLeft(loadedDictionary) && config.approvedWordsPath !== undefined) {
213
- return yield* Effect.fail(loadedDictionary.left)
226
+ const loadedRuleData = yield* Effect.result(loadRuleData(config.ruleDataExtensions))
227
+ if (Result.isFailure(loadedDictionary) && config.approvedWordsPath !== undefined) {
228
+ return yield* Effect.fail(loadedDictionary.failure)
214
229
  }
215
- const dictionary = Either.getOrUndefined(loadedDictionary)
216
- const ruleData = Either.getOrUndefined(loadedRuleData)
217
- if (Either.isLeft(loadedDictionary)) {
218
- yield* Effect.sync(() => console.error(loadedDictionary.left.message))
230
+ const dictionary = Result.getOrUndefined(loadedDictionary)
231
+ const ruleData = Result.getOrUndefined(loadedRuleData)
232
+ if (Result.isFailure(loadedDictionary)) {
233
+ yield* Effect.sync(() => console.error(loadedDictionary.failure.message))
219
234
  }
220
- if (Either.isLeft(loadedRuleData)) {
221
- yield* Effect.sync(() => console.error(loadedRuleData.left.message))
235
+ if (Result.isFailure(loadedRuleData)) {
236
+ yield* Effect.sync(() => console.error(loadedRuleData.failure.message))
222
237
  }
223
238
  const inputs =
224
239
  paths.length === 0
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)
@@ -258,7 +282,7 @@ const program: Effect.Effect<number, Error> =
258
282
  : lintProgram.pipe(Effect.provide(WinkTaggerLive))
259
283
 
260
284
  const handled = program.pipe(
261
- Effect.catchAll((error) =>
285
+ Effect.catch((error) =>
262
286
  Effect.sync(() => {
263
287
  console.error(error.message)
264
288
  return 2
@@ -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"
@@ -1,12 +1,12 @@
1
1
  import { resolve } from "node:path"
2
- import { Effect, Either } from "effect"
2
+ import { Effect, Result } from "effect"
3
3
  import { formatFailedStatusSummary, formatStatusSummary } from "../adapter/rule-summary.ts"
4
4
  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
12
  toggleSessionEnabled,
@@ -48,25 +48,25 @@ const updateStrict = (sessionId: string, strict: boolean) =>
48
48
  function status(sessionId: string, cwd: string): Effect.Effect<string, Error> {
49
49
  return Effect.gen(function* () {
50
50
  const control = yield* readControl(sessionId)
51
- const configResult = yield* Effect.either(loadConfig(undefined, cwd))
52
- if (Either.isLeft(configResult)) {
53
- return formatFailedStatusSummary(modeName(control), configResult.left.message)
51
+ const configResult = yield* Effect.result(loadConfig(undefined, cwd))
52
+ if (Result.isFailure(configResult)) {
53
+ return formatFailedStatusSummary(modeName(control), configResult.failure.message)
54
54
  }
55
55
  const dictionaryPath = process.env.SIMPLE_ENGLISH_DICTIONARY
56
- const dictionaryResult = yield* Effect.either(
56
+ const dictionaryResult = yield* Effect.result(
57
57
  Effect.all({
58
58
  dictionary: loadConfiguredDictionary(
59
- configResult.right,
59
+ configResult.success,
60
60
  cwd,
61
61
  dictionaryPath === undefined ? undefined : resolve(cwd, dictionaryPath),
62
62
  ),
63
- ruleData: loadRuleData(configResult.right.ruleDataExtensions, cwd),
63
+ ruleData: loadRuleData(configResult.success.ruleDataExtensions, cwd),
64
64
  }),
65
65
  )
66
- const dictionary: DictionaryState = Either.isRight(dictionaryResult)
66
+ const dictionary: DictionaryState = Result.isSuccess(dictionaryResult)
67
67
  ? "loaded"
68
- : `failed (${dictionaryResult.left.message})`
69
- return formatStatusSummary(configResult.right, modeName(control), dictionary)
68
+ : `failed (${dictionaryResult.failure.message})`
69
+ return formatStatusSummary(configResult.success, modeName(control), dictionary)
70
70
  })
71
71
  }
72
72
 
@@ -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
@@ -1,7 +1,13 @@
1
- import { Effect, ParseResult, Schema } from "effect"
1
+ import { Effect, Schema } from "effect"
2
2
  import type { RuleDataExtensions } from "../dictionary/rule-data.ts"
3
3
  import { type RuleId, ruleIds } from "../engine/rules/registry.ts"
4
4
  import type { RuleSetting } from "../engine/types.ts"
5
+ import {
6
+ formatParseErrorIssues,
7
+ formatParseErrorTree,
8
+ type ParseError,
9
+ } from "../schema/parse-error.ts"
10
+ import { NonEmptyTrimmedString } from "../schema/primitives.ts"
5
11
 
6
12
  export interface SteConfig {
7
13
  readonly rules?: Partial<Readonly<Record<RuleId, RuleSetting>>>
@@ -11,63 +17,53 @@ export interface SteConfig {
11
17
  readonly approvedWordsPath?: string
12
18
  }
13
19
 
14
- const RuleSettingSchema = Schema.Literal("hard", "soft", "off").annotations({
15
- message: (issue) => ({
16
- message: `must be "hard", "soft", or "off", got ${JSON.stringify(issue.actual)}`,
17
- override: true,
18
- }),
20
+ const RuleSettingSchema = Schema.Literals(["hard", "soft", "off"]).annotate({
21
+ expected: '"hard", "soft", or "off"',
19
22
  })
20
23
 
21
- const RulesSchema = Schema.partial(
22
- Schema.Struct(Object.fromEntries(ruleIds.map((id) => [id, RuleSettingSchema]))),
24
+ const RulesSchema = Schema.Struct(
25
+ Object.fromEntries(ruleIds.map((id) => [id, Schema.optionalKey(RuleSettingSchema)])),
23
26
  )
24
27
 
25
- const MaxSentenceWordsSchema = Schema.Int.pipe(Schema.positive()).annotations({
26
- message: (issue) => ({
27
- message: `must be a positive integer, got ${JSON.stringify(issue.actual)}`,
28
- override: true,
29
- }),
30
- })
28
+ // One refinement over Unknown, rather than Number plus two checks, so that a
29
+ // wrong type and a wrong number both report the same text and the value.
30
+ const MaxSentenceWordsSchema = Schema.Unknown.pipe(
31
+ Schema.refine(
32
+ (value): value is number => typeof value === "number" && Number.isInteger(value) && value > 0,
33
+ { expected: "a positive integer" },
34
+ ),
35
+ )
31
36
 
32
- const ExemptBlockQuotesSchema = Schema.Boolean.annotations({
33
- message: (issue) => ({
34
- message: `must be a boolean, got ${JSON.stringify(issue.actual)}`,
35
- override: true,
36
- }),
37
- })
37
+ const ExemptBlockQuotesSchema = Schema.Boolean
38
38
 
39
- const RuleDataExtensionsSchema = Schema.partial(
40
- Schema.Struct({
41
- "phrasal-verb": Schema.Array(Schema.NonEmptyTrimmedString),
42
- hedging: Schema.Array(Schema.NonEmptyTrimmedString),
43
- marketing: Schema.Array(Schema.NonEmptyTrimmedString),
44
- "adjectival-participle": Schema.Array(Schema.NonEmptyTrimmedString),
45
- }),
46
- )
39
+ const RuleDataExtensionsSchema = Schema.Struct({
40
+ "phrasal-verb": Schema.optionalKey(Schema.Array(NonEmptyTrimmedString)),
41
+ hedging: Schema.optionalKey(Schema.Array(NonEmptyTrimmedString)),
42
+ marketing: Schema.optionalKey(Schema.Array(NonEmptyTrimmedString)),
43
+ "adjectival-participle": Schema.optionalKey(Schema.Array(NonEmptyTrimmedString)),
44
+ })
47
45
 
48
46
  const SteConfigSchema = Schema.Struct({
49
- rules: Schema.optional(RulesSchema),
50
- maxSentenceWords: Schema.optional(MaxSentenceWordsSchema),
51
- exemptBlockQuotes: Schema.optional(ExemptBlockQuotesSchema),
52
- ruleDataExtensions: Schema.optional(RuleDataExtensionsSchema),
53
- approvedWordsPath: Schema.optional(Schema.NonEmptyTrimmedString),
47
+ rules: Schema.optionalKey(RulesSchema),
48
+ maxSentenceWords: Schema.optionalKey(MaxSentenceWordsSchema),
49
+ exemptBlockQuotes: Schema.optionalKey(ExemptBlockQuotesSchema),
50
+ ruleDataExtensions: Schema.optionalKey(RuleDataExtensionsSchema),
51
+ approvedWordsPath: Schema.optionalKey(NonEmptyTrimmedString),
54
52
  })
55
53
 
56
- const decodeUnknown = Schema.decodeUnknown(SteConfigSchema, {
54
+ const decodeUnknown = Schema.decodeUnknownEffect(SteConfigSchema, {
57
55
  onExcessProperty: "error",
58
56
  errors: "all",
57
+ // v4 omits the rejected value from an issue unless this option is on.
58
+ reportInput: true,
59
59
  })
60
60
 
61
61
  export class ConfigError extends Error {
62
62
  readonly _tag = "ConfigError"
63
63
  }
64
64
 
65
- const formatError = (error: ParseResult.ParseError, source: string): string => {
66
- // Optional fields decode as `T | undefined` unions, so every failure also
67
- // reports a useless "Expected undefined" branch; drop those.
68
- const issues = ParseResult.ArrayFormatter.formatErrorSync(error).filter(
69
- (issue) => !issue.message.startsWith("Expected undefined"),
70
- )
65
+ const formatError = (error: ParseError, source: string): string => {
66
+ const issues = formatParseErrorIssues(error)
71
67
  const lines = [
72
68
  ...new Set(
73
69
  issues.map(
@@ -78,7 +74,7 @@ const formatError = (error: ParseResult.ParseError, source: string): string => {
78
74
  const detail =
79
75
  lines.length > 0
80
76
  ? lines.map((line) => ` ${line}`).join("\n")
81
- : ` ${ParseResult.TreeFormatter.formatErrorSync(error)}`
77
+ : ` ${formatParseErrorTree(error)}`
82
78
  return `invalid config in ${source}:\n${detail}`
83
79
  }
84
80
 
@@ -1,7 +1,8 @@
1
1
  import { readFile } from "node:fs/promises"
2
2
  import { resolve } from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
- import { Effect, ParseResult, Schema } from "effect"
4
+ import { Effect, Schema } from "effect"
5
+ import { formatParseErrorIssues, type ParseError } from "../schema/parse-error.ts"
5
6
  import type { RuleData, RuleDataExtensions, RuleDataId } from "./rule-data.ts"
6
7
  import {
7
8
  type ApprovedWordList,
@@ -34,8 +35,8 @@ export class DictionaryLoadError extends Error {
34
35
  }
35
36
  }
36
37
 
37
- const formatParseError = (error: ParseResult.ParseError): string => {
38
- const issue = ParseResult.ArrayFormatter.formatErrorSync(error)[0]
38
+ const formatParseError = (error: ParseError): string => {
39
+ const issue = formatParseErrorIssues(error)[0]
39
40
  if (issue === undefined) {
40
41
  return "invalid dictionary data"
41
42
  }
@@ -48,15 +49,15 @@ const formatParseError = (error: ParseResult.ParseError): string => {
48
49
  return path === "" ? `invalid dictionary data: ${issue.message}` : `${path}: ${issue.message}`
49
50
  }
50
51
 
51
- const decodeDictionary = Schema.decode(Schema.parseJson(DictionarySchema), {
52
- onExcessProperty: "error",
53
- errors: "all",
54
- })
52
+ // v4 omits the rejected value from an issue unless reportInput is on.
53
+ const decodeOptions = { onExcessProperty: "error", errors: "all", reportInput: true } as const
55
54
 
56
- const decodeApprovedWordList = Schema.decode(Schema.parseJson(ApprovedWordListSchema), {
57
- onExcessProperty: "error",
58
- errors: "all",
59
- })
55
+ const decodeDictionary = Schema.decodeEffect(Schema.fromJsonString(DictionarySchema), decodeOptions)
56
+
57
+ const decodeApprovedWordList = Schema.decodeEffect(
58
+ Schema.fromJsonString(ApprovedWordListSchema),
59
+ decodeOptions,
60
+ )
60
61
 
61
62
  const readDictionaryFile = (
62
63
  path: string,
@@ -1,25 +1,22 @@
1
1
  import { Schema } from "effect"
2
+ import { NonEmptyTrimmedString } from "../schema/primitives.ts"
2
3
  import { DICTIONARY_FORM_PATTERN, DICTIONARY_WORD_PATTERN } from "./form.ts"
3
4
 
4
5
  const DictionarySourceSchema = Schema.Struct({
5
- name: Schema.NonEmptyTrimmedString,
6
- repository: Schema.NonEmptyTrimmedString,
7
- commit: Schema.NonEmptyTrimmedString,
8
- path: Schema.NonEmptyTrimmedString,
6
+ name: NonEmptyTrimmedString,
7
+ repository: NonEmptyTrimmedString,
8
+ commit: NonEmptyTrimmedString,
9
+ path: NonEmptyTrimmedString,
9
10
  })
10
11
 
11
- const DictionaryFormSchema = Schema.NonEmptyTrimmedString.pipe(
12
- Schema.pattern(DICTIONARY_FORM_PATTERN),
13
- )
12
+ const DictionaryFormSchema = NonEmptyTrimmedString.check(Schema.isPattern(DICTIONARY_FORM_PATTERN))
14
13
 
15
- const DictionaryWordSchema = Schema.NonEmptyTrimmedString.pipe(
16
- Schema.pattern(DICTIONARY_WORD_PATTERN),
17
- )
14
+ const DictionaryWordSchema = NonEmptyTrimmedString.check(Schema.isPattern(DICTIONARY_WORD_PATTERN))
18
15
 
19
16
  const DictionaryEntrySchema = Schema.Struct({
20
17
  unapproved: Schema.NonEmptyArray(DictionaryFormSchema),
21
- suggestions: Schema.NonEmptyArray(Schema.NonEmptyTrimmedString),
22
- partsOfSpeech: Schema.optional(Schema.NonEmptyArray(Schema.NonEmptyTrimmedString)),
18
+ suggestions: Schema.NonEmptyArray(NonEmptyTrimmedString),
19
+ partsOfSpeech: Schema.optional(Schema.NonEmptyArray(NonEmptyTrimmedString)),
23
20
  })
24
21
 
25
22
  export const DictionarySchema = Schema.Struct({
@@ -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,
@@ -0,0 +1,32 @@
1
+ import { type Schema, SchemaIssue } from "effect"
2
+
3
+ // This isolates the Effect v4 `SchemaIssue` formatter calls, so a later
4
+ // Schema change touches only this file.
5
+
6
+ export type ParseError = Schema.SchemaError
7
+
8
+ export interface ParseErrorIssue {
9
+ readonly path: ReadonlyArray<string | number>
10
+ readonly message: string
11
+ }
12
+
13
+ const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1()
14
+ const formatTree = SchemaIssue.makeFormatterDefault()
15
+
16
+ // A Standard Schema path segment is either a raw key or a `{ key }` wrapper,
17
+ // and symbol keys never appear in this package's schemas.
18
+ const pathSegments = (
19
+ path: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined,
20
+ ): ReadonlyArray<string | number> =>
21
+ (path ?? []).map((segment) => {
22
+ const key = typeof segment === "object" ? segment.key : segment
23
+ return typeof key === "number" ? key : String(key)
24
+ })
25
+
26
+ export const formatParseErrorIssues = (error: ParseError): ParseErrorIssue[] =>
27
+ formatIssues(error.issue).issues.map((issue) => ({
28
+ path: pathSegments(issue.path),
29
+ message: issue.message,
30
+ }))
31
+
32
+ export const formatParseErrorTree = (error: ParseError): string => formatTree(error.issue)
@@ -0,0 +1,7 @@
1
+ import { Schema } from "effect"
2
+
3
+ // Effect v4 has no NonEmptyTrimmedString schema.
4
+ // Every call site imports this alias, so the definition stays in one file.
5
+ export const NonEmptyTrimmedString = Schema.Trimmed.check(
6
+ Schema.isNonEmpty({ expected: "a non-empty string" }),
7
+ )
@@ -31,7 +31,7 @@ export function makeWinkTagger(): Tagger {
31
31
  }
32
32
  }
33
33
 
34
- export class TaggerService extends Context.Tag("TaggerService")<TaggerService, Tagger>() {}
34
+ export class TaggerService extends Context.Service<TaggerService, Tagger>()("TaggerService") {}
35
35
 
36
36
  const makeLazyWinkTagger = (): Tagger => {
37
37
  let tagger: Tagger | undefined