agent-simple-english 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.4.0",
14
+ "version": "0.5.1",
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.4.0",
4
+ "version": "0.5.1",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-simple-english",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Technical and house-style English lint engine, CLI, and host adapters",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "@lezer/html": "^1.3.12",
48
48
  "@lezer/markdown": "^1.7.2",
49
- "effect": "^3.14.0",
49
+ "effect": "4.0.0-rc.112",
50
50
  "micromark": "^4.0.2",
51
51
  "micromark-extension-frontmatter": "^2.0.0",
52
52
  "micromark-extension-gfm-table": "^2.1.1",
@@ -54,7 +54,6 @@
54
54
  "micromark-util-html-tag-name": "^2.0.1",
55
55
  "micromark-util-normalize-identifier": "^2.0.1",
56
56
  "micromark-util-types": "^2.0.2",
57
- "unicode-case-folding": "^1.1.1",
58
57
  "wink-eng-lite-web-model": "^1.8.1",
59
58
  "wink-nlp": "^2.4.0"
60
59
  },
@@ -2,7 +2,6 @@ import type { RuleId } from "../engine/rules/registry.ts"
2
2
  import type { Violation } from "../engine/types.ts"
3
3
 
4
4
  function suggestedFix(violation: Violation): string {
5
- if (violation.suggestion !== undefined) return `Use "${violation.suggestion}".`
6
5
  if (violation.suggestions !== undefined && violation.suggestions.length > 0) {
7
6
  return `Use one of these approved alternatives: ${violation.suggestions.map((item) => `"${item}"`).join(", ")}.`
8
7
  }
@@ -39,3 +38,12 @@ export function formatViolations(
39
38
  ): string {
40
39
  return `${heading} ${path}:\n${violationDetails(violations)}`
41
40
  }
41
+
42
+ export function splitViolations<V extends Violation>(
43
+ violations: readonly V[],
44
+ ): { readonly hard: V[]; readonly soft: V[] } {
45
+ return {
46
+ hard: violations.filter((violation) => violation.severity === "hard"),
47
+ soft: violations.filter((violation) => violation.severity === "soft"),
48
+ }
49
+ }
package/src/cli/hook.ts CHANGED
@@ -3,7 +3,7 @@ import { open, readFile } from "node:fs/promises"
3
3
  import { resolve } from "node:path"
4
4
  import { Cause, Effect } from "effect"
5
5
  import { blankCommitMetadata, findCommitInvocations } from "../adapter/commit-message.ts"
6
- import { formatViolations } from "../adapter/feedback.ts"
6
+ import { formatViolations, splitViolations } from "../adapter/feedback.ts"
7
7
  import { ruleSummary } from "../adapter/rule-summary.ts"
8
8
  import { loadConfig } from "../config/load.ts"
9
9
  import { loadConfiguredDictionary } from "../dictionary/configured.ts"
@@ -24,6 +24,7 @@ import {
24
24
  hasProcessedReply,
25
25
  setReplyFeedback,
26
26
  } from "./session-state.ts"
27
+ import { tryAsync } from "./try-async.ts"
27
28
 
28
29
  interface CommonEvent {
29
30
  readonly cwd: string
@@ -244,49 +245,13 @@ function proposedEdit(
244
245
  }
245
246
 
246
247
  const readEditFile = (path: string) =>
247
- Effect.tryPromise({
248
- try: () => readFile(path, "utf8"),
249
- catch: (cause) => new Error(`cannot read edit file ${path}: ${cause}`),
250
- })
248
+ tryAsync(`cannot read edit file ${path}`, () => readFile(path, "utf8"))
251
249
 
252
250
  interface AssistantReply {
253
251
  readonly identity: string
254
252
  readonly text: string
255
253
  }
256
254
 
257
- function assistantReply(line: string, path: string, offset: number): AssistantReply | undefined {
258
- let value: unknown
259
- try {
260
- value = JSON.parse(line) as unknown
261
- } catch {
262
- return undefined
263
- }
264
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
265
- const entry = value as Record<string, unknown>
266
- if (entry.type !== "assistant") return undefined
267
- const message = record(entry.message, "assistant transcript message")
268
- const content = message.content
269
- if (!Array.isArray(content)) {
270
- throw new Error(`assistant transcript message in ${path} must contain content blocks`)
271
- }
272
- const uuid = entry.uuid
273
- const identity =
274
- typeof uuid === "string" && uuid.length > 0
275
- ? `uuid:${uuid}`
276
- : `offset:${offset}:${createHash("sha256").update(line).digest("hex")}`
277
- const text = content
278
- .filter(
279
- (block): block is { type: "text"; text: string } =>
280
- typeof block === "object" &&
281
- block !== null &&
282
- (block as Record<string, unknown>).type === "text" &&
283
- typeof (block as Record<string, unknown>).text === "string",
284
- )
285
- .map((block) => block.text)
286
- .join("\n")
287
- return { identity, text }
288
- }
289
-
290
255
  const TRANSCRIPT_CHUNK_SIZE = 64 * 1024
291
256
  const TRANSCRIPT_ENTRY_HEADER_SIZE = 64 * 1024
292
257
 
@@ -384,23 +349,6 @@ async function readTranscriptRange(
384
349
  return buffer
385
350
  }
386
351
 
387
- async function assistantReplyInRange(
388
- file: Awaited<ReturnType<typeof open>>,
389
- path: string,
390
- start: number,
391
- end: number,
392
- ): Promise<AssistantReply | undefined> {
393
- const length = end - start
394
- const headerLength = Math.min(length, TRANSCRIPT_ENTRY_HEADER_SIZE)
395
- const header = await readTranscriptRange(file, path, start, headerLength)
396
- if (transcriptEntryKind(header.toString("utf8")) !== "assistant") return undefined
397
- const line =
398
- headerLength === length
399
- ? header.toString("utf8")
400
- : (await readTranscriptRange(file, path, start, length)).toString("utf8")
401
- return assistantReply(line, path, start)
402
- }
403
-
404
352
  async function turnIdentityInRange(
405
353
  file: Awaited<ReturnType<typeof open>>,
406
354
  path: string,
@@ -464,12 +412,6 @@ async function latestTranscriptEntry<T>(
464
412
  }
465
413
  }
466
414
 
467
- async function assistantReplyFromTranscript(path: string): Promise<AssistantReply> {
468
- const reply = await latestTranscriptEntry(path, assistantReplyInRange)
469
- if (reply !== undefined) return reply
470
- throw new Error(`cannot find an assistant reply in ${path}`)
471
- }
472
-
473
415
  async function assistantReplyFromEvent(text: string, path: string): Promise<AssistantReply> {
474
416
  const turnIdentity = await latestTranscriptEntry(path, turnIdentityInRange)
475
417
  if (turnIdentity === undefined) throw new Error(`cannot find a reply turn in ${path}`)
@@ -477,45 +419,28 @@ async function assistantReplyFromEvent(text: string, path: string): Promise<Assi
477
419
  return { identity: `${turnIdentity}:reply:${textHash}`, text }
478
420
  }
479
421
 
480
- const readAssistantReply = (path: string) =>
481
- Effect.tryPromise({
482
- try: () => assistantReplyFromTranscript(path),
483
- catch: (cause) => new Error(`cannot read assistant reply from ${path}: ${cause}`),
484
- })
485
-
486
422
  const readEventAssistantReply = (text: string, path: string) =>
487
- Effect.tryPromise({
488
- try: () => assistantReplyFromEvent(text, path),
489
- catch: (cause) => new Error(`cannot read assistant reply turn from ${path}: ${cause}`),
490
- })
423
+ tryAsync(`cannot read assistant reply turn from ${path}`, () =>
424
+ assistantReplyFromEvent(text, path),
425
+ )
491
426
 
492
427
  const replyWasProcessed = (sessionId: string, replyIdentity: string) =>
493
- Effect.tryPromise({
494
- try: () => hasProcessedReply(sessionId, replyIdentity),
495
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
496
- })
428
+ tryAsync("cannot read session state", () => hasProcessedReply(sessionId, replyIdentity))
497
429
 
498
430
  const updateReplyFeedback = (
499
431
  sessionId: string,
500
432
  replyIdentity: string,
501
433
  feedback: string | undefined,
502
434
  ) =>
503
- Effect.tryPromise({
504
- try: () => setReplyFeedback(sessionId, replyIdentity, feedback),
505
- catch: (cause) => new Error(`cannot update session state: ${cause}`),
506
- })
435
+ tryAsync("cannot update session state", () =>
436
+ setReplyFeedback(sessionId, replyIdentity, feedback),
437
+ )
507
438
 
508
439
  const takePendingFeedback = (sessionId: string) =>
509
- Effect.tryPromise({
510
- try: () => consumePendingFeedback(sessionId),
511
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
512
- })
440
+ tryAsync("cannot read session state", () => consumePendingFeedback(sessionId))
513
441
 
514
442
  const readSessionControl = (sessionId: string) =>
515
- Effect.tryPromise({
516
- try: () => getSessionControl(sessionId),
517
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
518
- })
443
+ tryAsync("cannot read session state", () => getSessionControl(sessionId))
519
444
 
520
445
  const loadLintOptions = (cwd: string, tagger: Tagger) =>
521
446
  Effect.gen(function* () {
@@ -532,16 +457,6 @@ const loadLintOptions = (cwd: string, tagger: Tagger) =>
532
457
  return { ...config, dictionary, ruleData, tagger } satisfies LintOptions
533
458
  })
534
459
 
535
- function splitViolations(violations: readonly ReportViolation[]): {
536
- readonly hard: ReportViolation[]
537
- readonly soft: ReportViolation[]
538
- } {
539
- return {
540
- hard: violations.filter((violation) => violation.severity === "hard"),
541
- soft: violations.filter((violation) => violation.severity === "soft"),
542
- }
543
- }
544
-
545
460
  type EvaluationObservation = Omit<ObservationDraft, "sessionId" | "cwd">
546
461
 
547
462
  interface HookEvaluation {
@@ -603,15 +518,18 @@ function textDecision(
603
518
 
604
519
  function evaluateReply(event: StopEvent, tagger: Tagger): Effect.Effect<HookEvaluation, Error> {
605
520
  return Effect.gen(function* () {
606
- const reply = yield* event.lastAssistantMessage === undefined
607
- ? readAssistantReply(event.transcriptPath)
608
- : readEventAssistantReply(event.lastAssistantMessage, event.transcriptPath)
521
+ if (event.lastAssistantMessage === undefined) {
522
+ return yield* Effect.fail(
523
+ new Error(`last_assistant_message was not provided for ${event.transcriptPath}`),
524
+ )
525
+ }
526
+ const reply = yield* readEventAssistantReply(event.lastAssistantMessage, event.transcriptPath)
609
527
  if (yield* replyWasProcessed(event.sessionId, reply.identity)) {
610
528
  return { output: {} as Record<string, never> }
611
529
  }
612
530
  const options = yield* loadLintOptions(event.cwd, tagger)
613
531
  const violations = lint("prose-file", reply.text, options).violations
614
- const hard = violations.filter((violation) => violation.severity === "hard")
532
+ const { hard } = splitViolations(violations)
615
533
  const feedback =
616
534
  hard.length === 0
617
535
  ? undefined
@@ -715,7 +633,7 @@ function recordEvaluation(event: HookEvent, evaluation: HookEvaluation): Effect.
715
633
  }),
716
634
  catch: () => undefined,
717
635
  }).pipe(
718
- Effect.catchAll(() => Effect.void),
636
+ Effect.catch(() => Effect.void),
719
637
  Effect.as(evaluation.output),
720
638
  )
721
639
  }
@@ -760,14 +678,14 @@ export function runHookMode(raw: string): Effect.Effect<HookOutput, never, Tagge
760
678
  return yield* recordEvaluation(event, evaluation)
761
679
  })
762
680
  }),
763
- Effect.catchAll((error) =>
681
+ Effect.catch((error) =>
764
682
  Effect.succeed(
765
683
  event.hookEventName === "PreToolUse"
766
684
  ? nonBlockingWarning(error.message)
767
685
  : nonBlockingError(error.message),
768
686
  ),
769
687
  ),
770
- Effect.catchAllCause((cause) =>
688
+ Effect.catchCause((cause) =>
771
689
  Effect.succeed(
772
690
  event.hookEventName === "PreToolUse"
773
691
  ? hookInternalFailure(cause)
package/src/cli/main.ts CHANGED
@@ -1,7 +1,8 @@
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
+ import { splitViolations } from "../adapter/feedback.ts"
5
6
  import { loadConfig } from "../config/load.ts"
6
7
  import { loadConfiguredDictionary } from "../dictionary/configured.ts"
7
8
  import { loadRuleData } from "../dictionary/load.ts"
@@ -102,7 +103,6 @@ interface FileViolation {
102
103
  readonly suggestions?: readonly string[]
103
104
  readonly line: number
104
105
  readonly column: number
105
- readonly suggestion?: string
106
106
  }
107
107
 
108
108
  interface CliReport {
@@ -138,7 +138,7 @@ const toCliReport = (
138
138
  violations,
139
139
  summary: {
140
140
  total: violations.length,
141
- hard: violations.filter((violation) => violation.severity === "hard").length,
141
+ hard: splitViolations(violations).hard.length,
142
142
  },
143
143
  skipped,
144
144
  }
@@ -164,7 +164,7 @@ const hookProgram = Effect.gen(function* () {
164
164
  console.log(JSON.stringify(output))
165
165
  return 0
166
166
  }).pipe(
167
- Effect.catchAllCause((cause) =>
167
+ Effect.catchCause((cause) =>
168
168
  Effect.sync(() => {
169
169
  console.log(JSON.stringify(hookInternalFailure(cause)))
170
170
  return 0
@@ -220,20 +220,20 @@ const lintProgram = Effect.gen(function* () {
220
220
  )
221
221
  }
222
222
  const config = yield* loadConfig(configPath)
223
- const loadedDictionary = yield* Effect.either(
223
+ const loadedDictionary = yield* Effect.result(
224
224
  loadConfiguredDictionary(config, process.cwd(), process.env.SIMPLE_ENGLISH_DICTIONARY),
225
225
  )
226
- const loadedRuleData = yield* Effect.either(loadRuleData(config.ruleDataExtensions))
227
- if (Either.isLeft(loadedDictionary) && config.approvedWordsPath !== undefined) {
228
- 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)
229
229
  }
230
- const dictionary = Either.getOrUndefined(loadedDictionary)
231
- const ruleData = Either.getOrUndefined(loadedRuleData)
232
- if (Either.isLeft(loadedDictionary)) {
233
- 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))
234
234
  }
235
- if (Either.isLeft(loadedRuleData)) {
236
- yield* Effect.sync(() => console.error(loadedRuleData.left.message))
235
+ if (Result.isFailure(loadedRuleData)) {
236
+ yield* Effect.sync(() => console.error(loadedRuleData.failure.message))
237
237
  }
238
238
  const inputs =
239
239
  paths.length === 0
@@ -282,7 +282,7 @@ const program: Effect.Effect<number, Error> =
282
282
  : lintProgram.pipe(Effect.provide(WinkTaggerLive))
283
283
 
284
284
  const handled = program.pipe(
285
- Effect.catchAll((error) =>
285
+ Effect.catch((error) =>
286
286
  Effect.sync(() => {
287
287
  console.error(error.message)
288
288
  return 2
@@ -3,6 +3,7 @@ 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"
6
+ import { isFileError } from "../fs-error.ts"
6
7
  import { applicationStateDirectory } from "./state-directory.ts"
7
8
 
8
9
  export type ObservationEvent = "write" | "edit" | "commit-message" | "reply"
@@ -56,12 +57,6 @@ const observationsDirectory = (): string => join(applicationStateDirectory(), "o
56
57
 
57
58
  const verdictsPath = (): string => join(applicationStateDirectory(), "verdicts.jsonl")
58
59
 
59
- function isFileError(cause: unknown, code: string): boolean {
60
- return (
61
- typeof cause === "object" && cause !== null && (cause as NodeJS.ErrnoException).code === code
62
- )
63
- }
64
-
65
60
  async function appendJsonLine(path: string, value: unknown): Promise<void> {
66
61
  await mkdir(applicationStateDirectory(), { recursive: true, mode: 0o700 })
67
62
  const file = await open(path, "a", 0o600)
@@ -1,5 +1,5 @@
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"
@@ -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
 
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto"
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
+ import { setTimeout as wait } from "node:timers/promises"
5
+ import { isFileError } from "../fs-error.ts"
4
6
  import { applicationStateDirectory } from "./state-directory.ts"
5
7
 
6
8
  export interface SessionControl {
@@ -19,9 +21,6 @@ const LOCK_STALE_MILLISECONDS = 10_000
19
21
  const LOCK_RETRY_MILLISECONDS = 5
20
22
  const LOCK_RETRIES = 200
21
23
 
22
- const isFileError = (cause: unknown, code: string): boolean =>
23
- typeof cause === "object" && cause !== null && (cause as { code?: string }).code === code
24
-
25
24
  const sessionsDirectory = (): string => join(applicationStateDirectory(), "sessions")
26
25
 
27
26
  const sessionKey = (sessionId: string): string =>
@@ -41,27 +40,16 @@ function optionalString(state: Record<string, unknown>, name: string): string |
41
40
  return value as string | undefined
42
41
  }
43
42
 
44
- function decodeState(text: string, path: string): SessionState {
43
+ function decodeState(text: string, path: string): SessionState | undefined {
45
44
  const value = JSON.parse(text) as unknown
46
45
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
47
46
  throw new Error(`invalid session state in ${path}`)
48
47
  }
49
48
  const state = value as Record<string, unknown>
49
+ if (state.version !== 3) return undefined
50
50
  const pendingFeedback = optionalString(state, "pendingFeedback")
51
- if (state.version === 1 && typeof pendingFeedback === "string") {
52
- return { version: 3, ...DEFAULT_CONTROL, pendingFeedback }
53
- }
54
51
  const lastProcessedReply = optionalString(state, "lastProcessedReply")
55
- if (state.version === 2 && lastProcessedReply !== undefined && lastProcessedReply.length > 0) {
56
- return {
57
- version: 3,
58
- ...DEFAULT_CONTROL,
59
- lastProcessedReply,
60
- ...(pendingFeedback === undefined ? {} : { pendingFeedback }),
61
- }
62
- }
63
52
  if (
64
- state.version !== 3 ||
65
53
  typeof state.enabled !== "boolean" ||
66
54
  typeof state.strict !== "boolean" ||
67
55
  (state.strict && !state.enabled) ||
@@ -100,9 +88,6 @@ async function writeState(sessionId: string, state: SessionState): Promise<void>
100
88
  }
101
89
  }
102
90
 
103
- const wait = (milliseconds: number): Promise<void> =>
104
- new Promise((resolve) => setTimeout(resolve, milliseconds))
105
-
106
91
  async function acquireLock(sessionId: string): Promise<() => Promise<void>> {
107
92
  const directory = sessionsDirectory()
108
93
  const path = lockPath(sessionId)
@@ -0,0 +1,7 @@
1
+ import { Effect } from "effect"
2
+
3
+ export const tryAsync = <T>(label: string, run: () => Promise<T>): Effect.Effect<T, Error> =>
4
+ Effect.tryPromise({
5
+ try: run,
6
+ catch: (cause) => new Error(`${label}: ${cause}`),
7
+ })
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"
2
2
  import { homedir } from "node:os"
3
3
  import { isAbsolute, join, resolve } from "node:path"
4
4
  import { Effect } from "effect"
5
+ import { isFileError } from "../fs-error.ts"
5
6
  import { mergeConfigs } from "./merge.ts"
6
7
  import { ConfigError, decodeConfig, type SteConfig } from "./schema.ts"
7
8
 
@@ -32,9 +33,6 @@ export const legacyGlobalConfigPath = (cwd = process.cwd()): string =>
32
33
  export const legacyProjectConfigPath = (cwd: string): string =>
33
34
  join(cwd, ".pi", "simple-english.json")
34
35
 
35
- const isMissingFile = (cause: unknown): boolean =>
36
- typeof cause === "object" && cause !== null && (cause as { code?: string }).code === "ENOENT"
37
-
38
36
  const readConfigFile = (
39
37
  path: string,
40
38
  optional: boolean,
@@ -45,7 +43,7 @@ const readConfigFile = (
45
43
  }).pipe(
46
44
  Effect.matchEffect({
47
45
  onFailure: (cause) =>
48
- optional && isMissingFile(cause)
46
+ optional && isFileError(cause, "ENOENT")
49
47
  ? Effect.succeed(undefined)
50
48
  : Effect.fail(new ConfigError(`cannot read config file ${path}: ${cause}`)),
51
49
  onSuccess: (text) =>
@@ -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