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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +2 -3
- package/src/adapter/feedback.ts +9 -1
- package/src/cli/hook.ts +22 -104
- package/src/cli/main.ts +15 -15
- package/src/cli/observation-log.ts +1 -6
- package/src/cli/session-command.ts +10 -10
- package/src/cli/session-state.ts +4 -19
- package/src/cli/try-async.ts +7 -0
- package/src/config/load.ts +2 -4
- package/src/config/schema.ts +37 -41
- package/src/dictionary/load.ts +12 -11
- package/src/dictionary/schema.ts +9 -12
- package/src/engine/case-fold.ts +3 -2
- package/src/engine/lint.ts +10 -2
- package/src/engine/markdown.ts +49 -79
- package/src/engine/paragraphs.ts +33 -121
- package/src/engine/rules/dictionary.ts +10 -111
- package/src/engine/rules/phrasal-verb.ts +1 -1
- package/src/engine/sentences.ts +3 -37
- package/src/engine/types.ts +0 -1
- package/src/extension/index.ts +22 -5
- package/src/fs-error.ts +5 -0
- package/src/schema/parse-error.ts +32 -0
- package/src/schema/primitives.ts +7 -0
- package/src/tagger/wink.ts +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "simple-english",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.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.
|
|
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": "
|
|
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
|
},
|
package/src/adapter/feedback.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
})
|
|
435
|
+
tryAsync("cannot update session state", () =>
|
|
436
|
+
setReplyFeedback(sessionId, replyIdentity, feedback),
|
|
437
|
+
)
|
|
507
438
|
|
|
508
439
|
const takePendingFeedback = (sessionId: string) =>
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
223
|
+
const loadedDictionary = yield* Effect.result(
|
|
224
224
|
loadConfiguredDictionary(config, process.cwd(), process.env.SIMPLE_ENGLISH_DICTIONARY),
|
|
225
225
|
)
|
|
226
|
-
const loadedRuleData = yield* Effect.
|
|
227
|
-
if (
|
|
228
|
-
return yield* Effect.fail(loadedDictionary.
|
|
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 =
|
|
231
|
-
const ruleData =
|
|
232
|
-
if (
|
|
233
|
-
yield* Effect.sync(() => console.error(loadedDictionary.
|
|
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 (
|
|
236
|
-
yield* Effect.sync(() => console.error(loadedRuleData.
|
|
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.
|
|
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,
|
|
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.
|
|
52
|
-
if (
|
|
53
|
-
return formatFailedStatusSummary(modeName(control), configResult.
|
|
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.
|
|
56
|
+
const dictionaryResult = yield* Effect.result(
|
|
57
57
|
Effect.all({
|
|
58
58
|
dictionary: loadConfiguredDictionary(
|
|
59
|
-
configResult.
|
|
59
|
+
configResult.success,
|
|
60
60
|
cwd,
|
|
61
61
|
dictionaryPath === undefined ? undefined : resolve(cwd, dictionaryPath),
|
|
62
62
|
),
|
|
63
|
-
ruleData: loadRuleData(configResult.
|
|
63
|
+
ruleData: loadRuleData(configResult.success.ruleDataExtensions, cwd),
|
|
64
64
|
}),
|
|
65
65
|
)
|
|
66
|
-
const dictionary: DictionaryState =
|
|
66
|
+
const dictionary: DictionaryState = Result.isSuccess(dictionaryResult)
|
|
67
67
|
? "loaded"
|
|
68
|
-
: `failed (${dictionaryResult.
|
|
69
|
-
return formatStatusSummary(configResult.
|
|
68
|
+
: `failed (${dictionaryResult.failure.message})`
|
|
69
|
+
return formatStatusSummary(configResult.success, modeName(control), dictionary)
|
|
70
70
|
})
|
|
71
71
|
}
|
|
72
72
|
|
package/src/cli/session-state.ts
CHANGED
|
@@ -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)
|
package/src/config/load.ts
CHANGED
|
@@ -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 &&
|
|
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) =>
|
package/src/config/schema.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { 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.
|
|
15
|
-
|
|
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.
|
|
22
|
-
|
|
24
|
+
const RulesSchema = Schema.Struct(
|
|
25
|
+
Object.fromEntries(ruleIds.map((id) => [id, Schema.optionalKey(RuleSettingSchema)])),
|
|
23
26
|
)
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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.
|
|
40
|
-
Schema.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
50
|
-
maxSentenceWords: Schema.
|
|
51
|
-
exemptBlockQuotes: Schema.
|
|
52
|
-
ruleDataExtensions: Schema.
|
|
53
|
-
approvedWordsPath: Schema.
|
|
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.
|
|
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:
|
|
66
|
-
|
|
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
|
-
: ` ${
|
|
77
|
+
: ` ${formatParseErrorTree(error)}`
|
|
82
78
|
return `invalid config in ${source}:\n${detail}`
|
|
83
79
|
}
|
|
84
80
|
|