agent-simple-english 0.1.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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +23 -0
  2. package/.claude-plugin/plugin.json +12 -0
  3. package/LICENSE +21 -0
  4. package/README.md +435 -0
  5. package/THIRD_PARTY_NOTICES.md +13 -0
  6. package/commands/ste.md +13 -0
  7. package/hooks/hooks.json +58 -0
  8. package/package.json +64 -0
  9. package/src/adapter/commit-message.ts +472 -0
  10. package/src/adapter/feedback.ts +40 -0
  11. package/src/adapter/rule-summary.ts +79 -0
  12. package/src/cli/hook.ts +681 -0
  13. package/src/cli/main.ts +201 -0
  14. package/src/cli/session-command.ts +78 -0
  15. package/src/cli/session-state.ts +214 -0
  16. package/src/config/load.ts +85 -0
  17. package/src/config/merge.ts +20 -0
  18. package/src/config/schema.ts +69 -0
  19. package/src/dictionary/README.md +18 -0
  20. package/src/dictionary/data/pi-ste.json +200 -0
  21. package/src/dictionary/form.ts +6 -0
  22. package/src/dictionary/load.ts +54 -0
  23. package/src/dictionary/schema.ts +28 -0
  24. package/src/engine/comments.ts +386 -0
  25. package/src/engine/diff.ts +328 -0
  26. package/src/engine/identifiers.ts +20 -0
  27. package/src/engine/kinds.ts +43 -0
  28. package/src/engine/lint.ts +530 -0
  29. package/src/engine/markdown.ts +338 -0
  30. package/src/engine/paragraphs.ts +105 -0
  31. package/src/engine/rules/contraction.ts +19 -0
  32. package/src/engine/rules/dictionary.ts +281 -0
  33. package/src/engine/rules/hedging.ts +27 -0
  34. package/src/engine/rules/marketing.ts +71 -0
  35. package/src/engine/rules/paragraph-length.ts +23 -0
  36. package/src/engine/rules/phrasal-verb.ts +57 -0
  37. package/src/engine/rules/registry.ts +15 -0
  38. package/src/engine/rules/semicolon.ts +14 -0
  39. package/src/engine/rules/sentence-length.ts +24 -0
  40. package/src/engine/rules/verb-form.ts +76 -0
  41. package/src/engine/scan.ts +15 -0
  42. package/src/engine/sentences.ts +285 -0
  43. package/src/engine/tagger.ts +8 -0
  44. package/src/engine/tokens.ts +2 -0
  45. package/src/engine/types.ts +45 -0
  46. package/src/extension/index.ts +755 -0
  47. package/src/tagger/wink.ts +44 -0
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env bun
2
+ import { readFile } from "node:fs/promises"
3
+ import { Effect, Either } from "effect"
4
+ import { loadConfig } from "../config/load.ts"
5
+ import { loadDictionary } from "../dictionary/load.ts"
6
+ import { classifyPath } from "../engine/kinds.ts"
7
+ import { lint } from "../engine/lint.ts"
8
+ import type { LintKind, LintReport } from "../engine/types.ts"
9
+ import { TaggerService, WinkTaggerLive } from "../tagger/wink.ts"
10
+ import { hookInternalFailure, runHookMode } from "./hook.ts"
11
+ import { runSessionCommand } from "./session-command.ts"
12
+
13
+ const KINDS: readonly LintKind[] = ["prose-file", "slash-source", "hash-source", "commit-message"]
14
+
15
+ interface CliArgs {
16
+ readonly json: boolean
17
+ readonly configPath: string | undefined
18
+ readonly kind: string | undefined
19
+ readonly kindMissingValue: boolean
20
+ readonly paths: readonly string[]
21
+ }
22
+
23
+ const parseArgs = (args: readonly string[]): Effect.Effect<CliArgs, Error> =>
24
+ Effect.gen(function* () {
25
+ let json = false
26
+ let configPath: string | undefined
27
+ let kind: string | undefined
28
+ let kindMissingValue = false
29
+ const paths: string[] = []
30
+ for (let i = 0; i < args.length; i++) {
31
+ const arg = args[i] as string
32
+ if (arg === "--json") {
33
+ json = true
34
+ } else if (arg === "--config") {
35
+ configPath = args[++i]
36
+ if (configPath === undefined) {
37
+ yield* Effect.fail(new Error("--config requires a file path"))
38
+ }
39
+ } else if (arg === "--kind") {
40
+ const value = args[i + 1]
41
+ if (value === undefined || value.startsWith("--")) {
42
+ kindMissingValue = true
43
+ } else {
44
+ kind = value
45
+ i++
46
+ }
47
+ } else if (arg.startsWith("--kind=")) {
48
+ kind = arg.slice("--kind=".length)
49
+ } else {
50
+ paths.push(arg)
51
+ }
52
+ }
53
+ return { json, configPath, kind, kindMissingValue, paths }
54
+ })
55
+
56
+ const isLintKind = (value: string): value is LintKind =>
57
+ (KINDS as readonly string[]).includes(value)
58
+
59
+ interface FileViolation {
60
+ readonly file: string
61
+ readonly ruleId: string
62
+ readonly severity: string
63
+ readonly message: string
64
+ readonly suggestions?: readonly string[]
65
+ readonly line: number
66
+ readonly column: number
67
+ readonly suggestion?: string
68
+ }
69
+
70
+ interface CliReport {
71
+ readonly violations: readonly FileViolation[]
72
+ readonly summary: { readonly total: number; readonly hard: number }
73
+ }
74
+
75
+ const readStdin = Effect.promise(async () => {
76
+ const chunks: Buffer[] = []
77
+ for await (const chunk of process.stdin) {
78
+ chunks.push(chunk as Buffer)
79
+ }
80
+ return Buffer.concat(chunks).toString("utf8")
81
+ })
82
+
83
+ const readInput = (path: string) =>
84
+ path === "-"
85
+ ? readStdin.pipe(Effect.map((text) => ({ path: "<stdin>", text })))
86
+ : Effect.tryPromise({
87
+ try: () => readFile(path, "utf8"),
88
+ catch: (cause) => new Error(`cannot read ${path}: ${cause}`),
89
+ }).pipe(Effect.map((text) => ({ path, text })))
90
+
91
+ const toCliReport = (reports: readonly { path: string; report: LintReport }[]): CliReport => {
92
+ const violations = reports.flatMap(({ path, report }) =>
93
+ report.violations.map((violation) => ({ file: path, ...violation })),
94
+ )
95
+ return {
96
+ violations,
97
+ summary: {
98
+ total: violations.length,
99
+ hard: violations.filter((violation) => violation.severity === "hard").length,
100
+ },
101
+ }
102
+ }
103
+
104
+ const render = (report: CliReport, json: boolean): string => {
105
+ if (json) {
106
+ return JSON.stringify(report, null, 2)
107
+ }
108
+ return report.violations
109
+ .map((v) => `${v.file}:${v.line}:${v.column} [${v.severity}] ${v.ruleId} ${v.message}`)
110
+ .join("\n")
111
+ }
112
+
113
+ const args = process.argv.slice(2)
114
+
115
+ const hookProgram = Effect.gen(function* () {
116
+ const output = yield* runHookMode(yield* readStdin).pipe(Effect.provide(WinkTaggerLive))
117
+ console.log(JSON.stringify(output))
118
+ return 0
119
+ }).pipe(
120
+ Effect.catchAllCause((cause) =>
121
+ Effect.sync(() => {
122
+ console.log(JSON.stringify(hookInternalFailure(cause)))
123
+ return 0
124
+ }),
125
+ ),
126
+ )
127
+
128
+ const sessionProgram = Effect.gen(function* () {
129
+ console.log(yield* runSessionCommand(args.slice(1)))
130
+ return 0
131
+ })
132
+
133
+ const lintProgram = Effect.gen(function* () {
134
+ const tagger = yield* TaggerService
135
+ const { json, configPath, kind, kindMissingValue, paths } = yield* parseArgs(args)
136
+ if (kindMissingValue) {
137
+ return yield* Effect.fail(
138
+ new Error(`--kind requires a value; expected one of: ${KINDS.join(", ")}`),
139
+ )
140
+ }
141
+ if (kind !== undefined && !isLintKind(kind)) {
142
+ return yield* Effect.fail(
143
+ new Error(`unknown kind "${kind}"; expected one of: ${KINDS.join(", ")}`),
144
+ )
145
+ }
146
+ const config = yield* loadConfig(configPath)
147
+ const loadedDictionary = yield* Effect.either(
148
+ loadDictionary(process.env.SIMPLE_ENGLISH_DICTIONARY),
149
+ )
150
+ const dictionary = Either.getOrUndefined(loadedDictionary)
151
+ if (Either.isLeft(loadedDictionary)) {
152
+ yield* Effect.sync(() => console.error(loadedDictionary.left.message))
153
+ }
154
+ const inputs =
155
+ paths.length === 0
156
+ ? [{ path: "<stdin>", text: yield* readStdin }]
157
+ : yield* Effect.forEach(paths, readInput)
158
+
159
+ const report = toCliReport(
160
+ inputs.map(({ path, text }) => {
161
+ const classification = classifyPath(path)
162
+ return {
163
+ path,
164
+ report: lint(kind ?? classification.kind, text, {
165
+ ...config,
166
+ dictionary,
167
+ tagger,
168
+ sourceDialect: classification.sourceDialect,
169
+ }),
170
+ }
171
+ }),
172
+ )
173
+
174
+ const output = render(report, json)
175
+ if (output !== "") {
176
+ console.log(output)
177
+ }
178
+ return report.summary.hard > 0 ? 1 : 0
179
+ })
180
+
181
+ const program: Effect.Effect<number, Error> =
182
+ args[0] === "hook"
183
+ ? hookProgram
184
+ : args[0] === "session"
185
+ ? sessionProgram
186
+ : lintProgram.pipe(Effect.provide(WinkTaggerLive))
187
+
188
+ const handled = program.pipe(
189
+ Effect.catchAll((error) =>
190
+ Effect.sync(() => {
191
+ console.error(error.message)
192
+ return 2
193
+ }),
194
+ ),
195
+ )
196
+
197
+ const exitCode = await Effect.runPromise(handled).catch((error) => {
198
+ console.error(String(error))
199
+ return 2
200
+ })
201
+ process.exit(exitCode)
@@ -0,0 +1,78 @@
1
+ import { resolve } from "node:path"
2
+ import { Effect, Either } from "effect"
3
+ import { formatFailedStatusSummary, formatStatusSummary } from "../adapter/rule-summary.ts"
4
+ import { loadConfig } from "../config/load.ts"
5
+ import { loadDictionary } from "../dictionary/load.ts"
6
+ import {
7
+ type SessionControl,
8
+ getSessionControl,
9
+ setSessionEnabled,
10
+ setSessionStrict,
11
+ } from "./session-state.ts"
12
+
13
+ const USAGE = "Usage: /ste [on|off|status|strict|strict off]"
14
+
15
+ type DictionaryState = "loaded" | "not loaded" | `failed (${string})`
16
+
17
+ function modeName(control: SessionControl): "disabled" | "enabled" | "strict" {
18
+ if (!control.enabled) return "disabled"
19
+ return control.strict ? "strict" : "enabled"
20
+ }
21
+
22
+ const readControl = (sessionId: string) =>
23
+ Effect.tryPromise({
24
+ try: () => getSessionControl(sessionId),
25
+ catch: (cause) => new Error(`cannot read session state: ${cause}`),
26
+ })
27
+
28
+ const updateEnabled = (sessionId: string, enabled: boolean) =>
29
+ Effect.tryPromise({
30
+ try: () => setSessionEnabled(sessionId, enabled),
31
+ catch: (cause) => new Error(`cannot update session state: ${cause}`),
32
+ })
33
+
34
+ const updateStrict = (sessionId: string, strict: boolean) =>
35
+ Effect.tryPromise({
36
+ try: () => setSessionStrict(sessionId, strict),
37
+ catch: (cause) => new Error(`cannot update session state: ${cause}`),
38
+ })
39
+
40
+ function status(sessionId: string, cwd: string): Effect.Effect<string, Error> {
41
+ return Effect.gen(function* () {
42
+ const control = yield* readControl(sessionId)
43
+ const configResult = yield* Effect.either(loadConfig(undefined, cwd))
44
+ if (Either.isLeft(configResult)) {
45
+ return formatFailedStatusSummary(modeName(control), configResult.left.message)
46
+ }
47
+ const dictionaryPath = process.env.SIMPLE_ENGLISH_DICTIONARY
48
+ const dictionaryResult = yield* Effect.either(
49
+ loadDictionary(dictionaryPath === undefined ? undefined : resolve(cwd, dictionaryPath)),
50
+ )
51
+ const dictionary: DictionaryState = Either.isRight(dictionaryResult)
52
+ ? "loaded"
53
+ : `failed (${dictionaryResult.left.message})`
54
+ return formatStatusSummary(configResult.right, modeName(control), dictionary)
55
+ })
56
+ }
57
+
58
+ export function runSessionCommand(args: readonly string[]): Effect.Effect<string, Error> {
59
+ const [sessionId, cwd, ...commandParts] = args
60
+ if (sessionId === undefined || sessionId.length === 0 || cwd === undefined || cwd.length === 0) {
61
+ return Effect.fail(new Error(USAGE))
62
+ }
63
+ const command = commandParts.join(" ").trim().toLowerCase()
64
+ if (command === "status") return status(sessionId, cwd)
65
+ if (command === "on") {
66
+ return updateEnabled(sessionId, true).pipe(Effect.as("STE enforcement enabled."))
67
+ }
68
+ if (command === "off") {
69
+ return updateEnabled(sessionId, false).pipe(Effect.as("STE enforcement disabled."))
70
+ }
71
+ if (command === "strict" || command === "strict on") {
72
+ return updateStrict(sessionId, true).pipe(Effect.as("STE strict mode enabled."))
73
+ }
74
+ if (command === "strict off") {
75
+ return updateStrict(sessionId, false).pipe(Effect.as("STE strict mode disabled."))
76
+ }
77
+ return Effect.fail(new Error(USAGE))
78
+ }
@@ -0,0 +1,214 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
3
+ import { homedir } from "node:os"
4
+ import { isAbsolute, join } from "node:path"
5
+
6
+ export interface SessionControl {
7
+ readonly enabled: boolean
8
+ readonly strict: boolean
9
+ }
10
+
11
+ interface SessionState extends SessionControl {
12
+ readonly version: 3
13
+ readonly lastProcessedReply?: string
14
+ readonly pendingFeedback?: string
15
+ }
16
+
17
+ const DEFAULT_CONTROL: SessionControl = { enabled: true, strict: false }
18
+ const LOCK_STALE_MILLISECONDS = 10_000
19
+ const LOCK_RETRY_MILLISECONDS = 5
20
+ const LOCK_RETRIES = 200
21
+
22
+ const isFileError = (cause: unknown, code: string): boolean =>
23
+ typeof cause === "object" && cause !== null && (cause as { code?: string }).code === code
24
+
25
+ const stateRoot = (): string => {
26
+ const configured = process.env.XDG_STATE_HOME
27
+ return configured && isAbsolute(configured) ? configured : join(homedir(), ".local", "state")
28
+ }
29
+
30
+ const sessionsDirectory = (): string => join(stateRoot(), "simple-english", "sessions")
31
+
32
+ const sessionKey = (sessionId: string): string =>
33
+ createHash("sha256").update(sessionId).digest("hex")
34
+
35
+ const sessionPath = (sessionId: string): string =>
36
+ join(sessionsDirectory(), `${sessionKey(sessionId)}.json`)
37
+
38
+ const lockPath = (sessionId: string): string =>
39
+ join(sessionsDirectory(), `.${sessionKey(sessionId)}.lock`)
40
+
41
+ function optionalString(state: Record<string, unknown>, name: string): string | undefined {
42
+ const value = state[name]
43
+ if (value !== undefined && typeof value !== "string") {
44
+ throw new Error(`${name} must be a string`)
45
+ }
46
+ return value as string | undefined
47
+ }
48
+
49
+ function decodeState(text: string, path: string): SessionState {
50
+ const value = JSON.parse(text) as unknown
51
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
52
+ throw new Error(`invalid session state in ${path}`)
53
+ }
54
+ const state = value as Record<string, unknown>
55
+ const pendingFeedback = optionalString(state, "pendingFeedback")
56
+ if (state.version === 1 && typeof pendingFeedback === "string") {
57
+ return { version: 3, ...DEFAULT_CONTROL, pendingFeedback }
58
+ }
59
+ const lastProcessedReply = optionalString(state, "lastProcessedReply")
60
+ if (state.version === 2 && lastProcessedReply !== undefined && lastProcessedReply.length > 0) {
61
+ return {
62
+ version: 3,
63
+ ...DEFAULT_CONTROL,
64
+ lastProcessedReply,
65
+ ...(pendingFeedback === undefined ? {} : { pendingFeedback }),
66
+ }
67
+ }
68
+ if (
69
+ state.version !== 3 ||
70
+ typeof state.enabled !== "boolean" ||
71
+ typeof state.strict !== "boolean" ||
72
+ (state.strict && !state.enabled) ||
73
+ lastProcessedReply === ""
74
+ ) {
75
+ throw new Error(`invalid session state in ${path}`)
76
+ }
77
+ return {
78
+ version: 3,
79
+ enabled: state.enabled,
80
+ strict: state.strict,
81
+ ...(lastProcessedReply === undefined ? {} : { lastProcessedReply }),
82
+ ...(pendingFeedback === undefined ? {} : { pendingFeedback }),
83
+ }
84
+ }
85
+
86
+ async function readState(sessionId: string): Promise<SessionState | undefined> {
87
+ const path = sessionPath(sessionId)
88
+ try {
89
+ return decodeState(await readFile(path, "utf8"), path)
90
+ } catch (cause) {
91
+ if (isFileError(cause, "ENOENT")) return undefined
92
+ throw cause
93
+ }
94
+ }
95
+
96
+ async function writeState(sessionId: string, state: SessionState): Promise<void> {
97
+ const directory = sessionsDirectory()
98
+ await mkdir(directory, { recursive: true, mode: 0o700 })
99
+ const temporaryPath = join(directory, `.${sessionKey(sessionId)}.${randomUUID()}.tmp`)
100
+ try {
101
+ await writeFile(temporaryPath, JSON.stringify(state), { encoding: "utf8", mode: 0o600 })
102
+ await rename(temporaryPath, sessionPath(sessionId))
103
+ } finally {
104
+ await rm(temporaryPath, { force: true })
105
+ }
106
+ }
107
+
108
+ const wait = (milliseconds: number): Promise<void> =>
109
+ new Promise((resolve) => setTimeout(resolve, milliseconds))
110
+
111
+ async function acquireLock(sessionId: string): Promise<() => Promise<void>> {
112
+ const directory = sessionsDirectory()
113
+ const path = lockPath(sessionId)
114
+ await mkdir(directory, { recursive: true, mode: 0o700 })
115
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) {
116
+ try {
117
+ await mkdir(path, { mode: 0o700 })
118
+ return () => rm(path, { recursive: true, force: true })
119
+ } catch (cause) {
120
+ if (!isFileError(cause, "EEXIST")) throw cause
121
+ try {
122
+ const lockStat = await stat(path)
123
+ if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MILLISECONDS) {
124
+ await rm(path, { recursive: true, force: true })
125
+ continue
126
+ }
127
+ } catch (statCause) {
128
+ if (!isFileError(statCause, "ENOENT")) throw statCause
129
+ }
130
+ await wait(LOCK_RETRY_MILLISECONDS)
131
+ }
132
+ }
133
+ throw new Error(`timed out while reading session ${sessionId}`)
134
+ }
135
+
136
+ async function withStateLock<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
137
+ const release = await acquireLock(sessionId)
138
+ try {
139
+ return await operation()
140
+ } finally {
141
+ await release()
142
+ }
143
+ }
144
+
145
+ const currentState = (state: SessionState | undefined): SessionState =>
146
+ state ?? { version: 3, ...DEFAULT_CONTROL }
147
+
148
+ export async function getSessionControl(sessionId: string): Promise<SessionControl> {
149
+ return withStateLock(sessionId, async () => {
150
+ const { enabled, strict } = currentState(await readState(sessionId))
151
+ return { enabled, strict }
152
+ })
153
+ }
154
+
155
+ export async function setSessionEnabled(sessionId: string, enabled: boolean): Promise<void> {
156
+ await withStateLock(sessionId, async () => {
157
+ const state = currentState(await readState(sessionId))
158
+ await writeState(sessionId, {
159
+ ...state,
160
+ enabled,
161
+ strict: enabled ? state.strict : false,
162
+ ...(enabled || state.pendingFeedback === undefined ? {} : { pendingFeedback: undefined }),
163
+ })
164
+ })
165
+ }
166
+
167
+ export async function setSessionStrict(sessionId: string, strict: boolean): Promise<void> {
168
+ await withStateLock(sessionId, async () => {
169
+ const state = currentState(await readState(sessionId))
170
+ await writeState(sessionId, {
171
+ ...state,
172
+ enabled: strict ? true : state.enabled,
173
+ strict,
174
+ })
175
+ })
176
+ }
177
+
178
+ export async function hasProcessedReply(
179
+ sessionId: string,
180
+ replyIdentity: string,
181
+ ): Promise<boolean> {
182
+ return withStateLock(sessionId, async () => {
183
+ const state = await readState(sessionId)
184
+ return state?.lastProcessedReply === replyIdentity
185
+ })
186
+ }
187
+
188
+ export async function setReplyFeedback(
189
+ sessionId: string,
190
+ replyIdentity: string,
191
+ pendingFeedback: string | undefined,
192
+ ): Promise<SessionControl | undefined> {
193
+ return withStateLock(sessionId, async () => {
194
+ const state = currentState(await readState(sessionId))
195
+ if (!state.enabled || state.lastProcessedReply === replyIdentity) return undefined
196
+ await writeState(sessionId, {
197
+ ...state,
198
+ lastProcessedReply: replyIdentity,
199
+ ...(state.strict || pendingFeedback === undefined
200
+ ? { pendingFeedback: undefined }
201
+ : { pendingFeedback }),
202
+ })
203
+ return { enabled: state.enabled, strict: state.strict }
204
+ })
205
+ }
206
+
207
+ export async function consumePendingFeedback(sessionId: string): Promise<string | undefined> {
208
+ return withStateLock(sessionId, async () => {
209
+ const state = await readState(sessionId)
210
+ if (state?.pendingFeedback === undefined) return undefined
211
+ await writeState(sessionId, { ...state, pendingFeedback: undefined })
212
+ return state.pendingFeedback
213
+ })
214
+ }
@@ -0,0 +1,85 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { isAbsolute, join, resolve } from "node:path"
4
+ import { Effect } from "effect"
5
+ import { mergeConfigs } from "./merge.ts"
6
+ import { ConfigError, type SteConfig, decodeConfig } from "./schema.ts"
7
+
8
+ const legacyAgentConfigDirectory = (cwd: string): string => {
9
+ const configured = process.env.PI_CODING_AGENT_DIR
10
+ if (!configured) return join(homedir(), ".pi", "agent")
11
+ if (configured === "~") return homedir()
12
+ const expanded =
13
+ configured.startsWith("~/") || (process.platform === "win32" && configured.startsWith("~\\"))
14
+ ? join(homedir(), configured.slice(2))
15
+ : configured
16
+ return isAbsolute(expanded) ? expanded : resolve(cwd, expanded)
17
+ }
18
+
19
+ const xdgConfigDirectory = (): string => {
20
+ const configured = process.env.XDG_CONFIG_HOME
21
+ return configured && isAbsolute(configured) ? configured : join(homedir(), ".config")
22
+ }
23
+
24
+ export const globalConfigPath = (): string =>
25
+ join(xdgConfigDirectory(), "simple-english", "config.json")
26
+
27
+ export const projectConfigPath = (cwd: string): string => join(cwd, ".simple-english.json")
28
+
29
+ export const legacyGlobalConfigPath = (cwd = process.cwd()): string =>
30
+ join(legacyAgentConfigDirectory(cwd), "simple-english.json")
31
+
32
+ export const legacyProjectConfigPath = (cwd: string): string =>
33
+ join(cwd, ".pi", "simple-english.json")
34
+
35
+ const isMissingFile = (cause: unknown): boolean =>
36
+ typeof cause === "object" && cause !== null && (cause as { code?: string }).code === "ENOENT"
37
+
38
+ const readConfigFile = (
39
+ path: string,
40
+ optional: boolean,
41
+ ): Effect.Effect<SteConfig | undefined, ConfigError> =>
42
+ Effect.tryPromise({
43
+ try: () => readFile(path, "utf8"),
44
+ catch: (cause) => cause,
45
+ }).pipe(
46
+ Effect.matchEffect({
47
+ onFailure: (cause) =>
48
+ optional && isMissingFile(cause)
49
+ ? Effect.succeed(undefined)
50
+ : Effect.fail(new ConfigError(`cannot read config file ${path}: ${cause}`)),
51
+ onSuccess: (text) =>
52
+ Effect.try({
53
+ try: () => JSON.parse(text) as unknown,
54
+ catch: (cause) => new ConfigError(`invalid JSON in ${path}: ${cause}`),
55
+ }).pipe(Effect.flatMap((json) => decodeConfig(json, path))),
56
+ }),
57
+ )
58
+
59
+ const readConfigWithFallback = (
60
+ path: string,
61
+ fallbackPath: string,
62
+ ): Effect.Effect<SteConfig, ConfigError> =>
63
+ readConfigFile(path, true).pipe(
64
+ Effect.flatMap((config) =>
65
+ config === undefined
66
+ ? readConfigFile(fallbackPath, true).pipe(Effect.map((fallback) => fallback ?? {}))
67
+ : Effect.succeed(config),
68
+ ),
69
+ )
70
+
71
+ export const loadConfig = (
72
+ explicitPath?: string,
73
+ cwd = process.cwd(),
74
+ includeProjectConfig = true,
75
+ ): Effect.Effect<SteConfig, ConfigError> => {
76
+ if (explicitPath !== undefined) {
77
+ return readConfigFile(explicitPath, false).pipe(Effect.map((config) => config ?? {}))
78
+ }
79
+ const globalConfig = readConfigWithFallback(globalConfigPath(), legacyGlobalConfigPath(cwd))
80
+ if (!includeProjectConfig) return globalConfig
81
+ const projectConfig = readConfigWithFallback(projectConfigPath(cwd), legacyProjectConfigPath(cwd))
82
+ return Effect.all([globalConfig, projectConfig]).pipe(
83
+ Effect.map(([global, project]) => mergeConfigs(global, project)),
84
+ )
85
+ }
@@ -0,0 +1,20 @@
1
+ import type { SteConfig } from "./schema.ts"
2
+
3
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
4
+ typeof value === "object" && value !== null && !Array.isArray(value)
5
+
6
+ const deepMerge = (
7
+ base: Record<string, unknown>,
8
+ over: Record<string, unknown>,
9
+ ): Record<string, unknown> => {
10
+ const merged = { ...base }
11
+ for (const [key, value] of Object.entries(over)) {
12
+ const existing = merged[key]
13
+ merged[key] =
14
+ isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value
15
+ }
16
+ return merged
17
+ }
18
+
19
+ export const mergeConfigs = (global: SteConfig, project: SteConfig): SteConfig =>
20
+ deepMerge(global as Record<string, unknown>, project as Record<string, unknown>) as SteConfig
@@ -0,0 +1,69 @@
1
+ import { Effect, ParseResult, Schema } from "effect"
2
+ import { type RuleId, ruleIds } from "../engine/rules/registry.ts"
3
+ import type { RuleSetting } from "../engine/types.ts"
4
+
5
+ export interface SteConfig {
6
+ readonly rules?: Partial<Readonly<Record<RuleId, RuleSetting>>>
7
+ readonly maxSentenceWords?: number
8
+ }
9
+
10
+ const RuleSettingSchema = Schema.Literal("hard", "soft", "off").annotations({
11
+ message: (issue) => ({
12
+ message: `must be "hard", "soft", or "off", got ${JSON.stringify(issue.actual)}`,
13
+ override: true,
14
+ }),
15
+ })
16
+
17
+ const RulesSchema = Schema.partial(
18
+ Schema.Struct(Object.fromEntries(ruleIds.map((id) => [id, RuleSettingSchema]))),
19
+ )
20
+
21
+ const MaxSentenceWordsSchema = Schema.Int.pipe(Schema.positive()).annotations({
22
+ message: (issue) => ({
23
+ message: `must be a positive integer, got ${JSON.stringify(issue.actual)}`,
24
+ override: true,
25
+ }),
26
+ })
27
+
28
+ const SteConfigSchema = Schema.Struct({
29
+ rules: Schema.optional(RulesSchema),
30
+ maxSentenceWords: Schema.optional(MaxSentenceWordsSchema),
31
+ })
32
+
33
+ const decodeUnknown = Schema.decodeUnknown(SteConfigSchema, {
34
+ onExcessProperty: "error",
35
+ errors: "all",
36
+ })
37
+
38
+ export class ConfigError extends Error {
39
+ readonly _tag = "ConfigError"
40
+ }
41
+
42
+ const formatError = (error: ParseResult.ParseError, source: string): string => {
43
+ // Optional fields decode as `T | undefined` unions, so every failure also
44
+ // reports a useless "Expected undefined" branch; drop those.
45
+ const issues = ParseResult.ArrayFormatter.formatErrorSync(error).filter(
46
+ (issue) => !issue.message.startsWith("Expected undefined"),
47
+ )
48
+ const lines = [
49
+ ...new Set(
50
+ issues.map(
51
+ (issue) => `${issue.path.length > 0 ? issue.path.join(".") : "config"}: ${issue.message}`,
52
+ ),
53
+ ),
54
+ ]
55
+ const detail =
56
+ lines.length > 0
57
+ ? lines.map((line) => ` ${line}`).join("\n")
58
+ : ` ${ParseResult.TreeFormatter.formatErrorSync(error)}`
59
+ return `invalid config in ${source}:\n${detail}`
60
+ }
61
+
62
+ export const decodeConfig = (
63
+ input: unknown,
64
+ source: string,
65
+ ): Effect.Effect<SteConfig, ConfigError> =>
66
+ decodeUnknown(input).pipe(
67
+ Effect.mapError((error) => new ConfigError(formatError(error, source))),
68
+ Effect.map((config) => config as SteConfig),
69
+ )
@@ -0,0 +1,18 @@
1
+ # Dictionary data format
2
+
3
+ `data/pi-ste.json` uses the package-owned format that `schema.ts` defines and Effect Schema validates.
4
+
5
+ - `formatVersion` identifies incompatible format changes.
6
+ - `source` records the source name and pins the repository, commit, and path from which the data was converted.
7
+ - `entries[].unapproved` lists exact case-insensitive word forms or phrases.
8
+ Forms can contain letters, numbers, internal apostrophes, internal hyphens, and horizontal whitespace between words.
9
+ - `entries[].suggestions` lists approved alternatives.
10
+ - `entries[].partsOfSpeech`, when present, lists the POS tags for which the forms are unapproved.
11
+
12
+ Unknown properties and unsupported form syntax cause dictionary validation to fail.
13
+ Matching is token based, and a hyphenated form matches only that exact hyphenated token.
14
+ A phrase can span horizontal whitespace or a soft line break in the same Markdown paragraph, but it cannot span Markdown block boundaries or hard line breaks.
15
+ Fenced and indented Markdown code is excluded from matching.
16
+ The rule checks an entry with `partsOfSpeech` only when an injected tagger returns one of those tags for the form's first token.
17
+ The rule checks an entry without `partsOfSpeech` by word or phrase alone.
18
+ See [`THIRD_PARTY_NOTICES.md`](../../THIRD_PARTY_NOTICES.md) for the bundled data's exact source, conversion scope, and license details.