simple_english 0.4.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6c725be18b4e942fa4e7fb13ca015901ceeae450aafe4c00eb27dfffabaf4ed
4
- data.tar.gz: c9d1d0a62c474d3335fca783889a32610aa436f4e127d96a6aee336569d5ba41
3
+ metadata.gz: 8021741a0615e3d936a8a9bdabe7579fb1fa9641ce5061bf2d3a0e2bb24dd4d9
4
+ data.tar.gz: 87fb729f7e72ef9af5d3c63b305fdff9bea3b3aec034429ac52eaffb54639559
5
5
  SHA512:
6
- metadata.gz: 97a9eea4051c526be80f2577cc2f835a7e89a1230891f222016ab1f02f490d7d8ed08212bf346ef8505a3a96d378d3f29a7548b03d33cd96211c236206b5cf49
7
- data.tar.gz: f959023348c68024eef0e0533201c4f4802321ac71b90867f57c56e15f6d3c112589d0dc8892b5a43997c750b0084b15b6c8298df02a9dfba173e561daff49b4
6
+ metadata.gz: '058e1303247e2ca2aee046e1ff1c9341ff96fae26f4c4fcbb67410cd7652d0d806be163c0abb7c393ce81a725bc8b94a0a4faaeee09c89d8f04daf738a7dd677'
7
+ data.tar.gz: 57e8431a0c71e31a3f14d3ab6f776dfe10e0f49f60a279c7b0d4a53f7d432594a4d79af76700cc6fbc45b26767258eca3a957817cd8574f05fd64c4cff09f223
data/README.md CHANGED
@@ -214,6 +214,19 @@ verifies. Closing the Vale gaps needs scripts or an external
214
214
  tagger, and that erases Vale's main advantage: one binary with no
215
215
  service behind it.
216
216
 
217
+ ## Agent integrations
218
+
219
+ Pi, Claude Code, and Codex can lint the prose they write. The adapters
220
+ live under `integrations/`, unreleased. Try them from a checkout:
221
+
222
+ ```bash
223
+ claude --plugin-dir integrations/claude-code # the hook feeds findings back
224
+ pi install integrations/pi -l # the se_lint tool
225
+ se mcp # an MCP server, for any MCP client
226
+ ```
227
+
228
+ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full story.
229
+
217
230
  ## Develop
218
231
 
219
232
  To change the linter, add rules, or run the tests, read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
@@ -76,6 +76,12 @@ module SimpleEnglish
76
76
  0
77
77
  end
78
78
 
79
+ desc "mcp", "Run the MCP stdio server (one JSON-RPC message per line)"
80
+ def mcp
81
+ SimpleEnglish::ModelContextProtocol.run
82
+ 0
83
+ end
84
+
79
85
  desc "serve", "Run the lint daemon in the foreground"
80
86
  method_option :port, type: :numeric, default: SimpleEnglish::Client::DEFAULT_PORT,
81
87
  desc: "Port to listen on"
@@ -132,11 +138,22 @@ module SimpleEnglish
132
138
  # "-" stays as-is for stdin. Directories expand to all lintable files.
133
139
  # Glob output keeps a "./" prefix when the argument is ".". Strip it so
134
140
  # paths and config ignore globs always see the same form.
141
+ # These trees hold third-party prose, not the user's. The tool lints
142
+ # what the user owns. Skipped only during directory expansion. An
143
+ # explicit path always lints.
144
+ # Dir.glob never returns dotfiles, so ".git" is inert today. It
145
+ # guards a future FNM_DOTMATCH from turning git internals back on.
146
+ # .gitignore is not read. Users put ignore: globs in the config for
147
+ # build output and generated files.
148
+ SKIPPED_DIRS = [".git", "node_modules", "vendor"].freeze
149
+
135
150
  def self.expand_paths(argv)
136
151
  extensions = (SimpleEnglish::Extractor::EXTENSION_LANGUAGES.keys.map { |e| e.delete_prefix(".") } + ["md"]).uniq.join(",")
137
152
  argv.flat_map do |path|
138
153
  if File.directory?(path)
139
- Dir.glob(File.join(path, "**/*.{#{extensions}}")).sort
154
+ Dir.glob(File.join(path, "**/*.{#{extensions}}"))
155
+ .reject { |p| p.split(File::SEPARATOR).any? { |s| SKIPPED_DIRS.include?(s) } }
156
+ .sort
140
157
  else
141
158
  path
142
159
  end
@@ -179,16 +196,7 @@ module SimpleEnglish
179
196
  )
180
197
  else
181
198
  results.each do |path, finding|
182
- location = "#{path}:#{finding.line}"
183
- if finding.column
184
- location += ":#{finding.column}"
185
- if finding.end_line && finding.end_column
186
- finish = (finding.end_line == finding.line) ? finding.end_column :
187
- "#{finding.end_line}:#{finding.end_column}"
188
- location += "-#{finish}"
189
- end
190
- end
191
- puts "#{location}: [#{finding.rule}] #{finding.message}"
199
+ puts finding.to_line(path)
192
200
  end
193
201
  end
194
202
  end
@@ -5,5 +5,20 @@
5
5
  # daemon can have a start column without an end position.
6
6
 
7
7
  module SimpleEnglish
8
- Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message)
8
+ Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message) do
9
+ # One finding as a text line: `path:line:col-end: [rule] message`.
10
+ # The CLI text output, the MCP tool, and the hook feedback all share
11
+ # this format. Keep them in step by rendering through this method.
12
+ def to_line(path)
13
+ location = "#{path}:#{line}"
14
+ if column
15
+ location += ":#{column}"
16
+ if end_line && end_column
17
+ finish = (end_line == line) ? end_column : "#{end_line}:#{end_column}"
18
+ location += "-#{finish}"
19
+ end
20
+ end
21
+ "#{location}: [#{rule}] #{message}"
22
+ end
23
+ end
9
24
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # MCP JSON-RPC server for the lint engine. `respond` is pure: request
6
+ # Hash in, response Hash or nil (notifications) out. `run` drives it
7
+ # over stdio, one JSON-RPC message per line. Protocol facts:
8
+ # integrations/verification.md.
9
+
10
+ module SimpleEnglish
11
+ module ModelContextProtocol
12
+ SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"].freeze
13
+ LATEST_VERSION = "2025-06-18"
14
+ SERVER_NAME = "simple-english"
15
+
16
+ module_function
17
+
18
+ def run(io: $stdin, out: $stdout, linter: method(:default_linter))
19
+ # A piped stdout buffers until 4 KB or EOF. An MCP client keeps
20
+ # stdin open while it waits for the answer, so an unflushed
21
+ # response never arrives and the client drops the server.
22
+ out.sync = true
23
+ while (line = io.gets)
24
+ line = line.strip
25
+ next if line.empty?
26
+ begin
27
+ request = JSON.parse(line)
28
+ rescue JSON::ParserError
29
+ next
30
+ end
31
+ next unless request.is_a?(Hash)
32
+ response =
33
+ begin
34
+ respond(request, linter: linter)
35
+ rescue => e
36
+ # A broken tool call (unreadable file, daemon hiccup) must not
37
+ # kill the loop: answer it, then serve the next line.
38
+ request.key?("id") ? error(request["id"], -32603, "lint failed: #{e.class}: #{e.message}") : nil
39
+ end
40
+ out.puts JSON.generate(response) if response
41
+ end
42
+ end
43
+
44
+ # The CLI's lint pipeline as a callable: .simple-english.yml decides
45
+ # which paths and rules count, then the daemon lints the file.
46
+ def default_linter(path)
47
+ config = SimpleEnglish::Config.load
48
+ return [] if SimpleEnglish::Config.ignore?(config, path)
49
+ findings = SimpleEnglish.lint_file(path)
50
+ findings.nil? ? nil : SimpleEnglish::Config.filter(config, path, findings)
51
+ end
52
+
53
+ def respond(request, linter:)
54
+ return nil unless request.key?("id")
55
+ id = request.fetch("id")
56
+ method = request["method"]
57
+ return error(id, -32600, "Invalid Request") unless method.is_a?(String)
58
+ params = request["params"]
59
+ params = {} unless params.is_a?(Hash)
60
+ case method
61
+ when "initialize" then ok(id, initialize_result(params["protocolVersion"]))
62
+ when "tools/list" then ok(id, {"tools" => [lint_tool]})
63
+ when "tools/call" then tools_call(id, params, linter)
64
+ else error(id, -32601, "Method not found: #{method}")
65
+ end
66
+ end
67
+
68
+ def initialize_result(requested_version)
69
+ version = SUPPORTED_VERSIONS.include?(requested_version) ? requested_version : LATEST_VERSION
70
+ {
71
+ "protocolVersion" => version,
72
+ "capabilities" => {"tools" => {}},
73
+ "serverInfo" => {"name" => SERVER_NAME, "version" => SimpleEnglish::VERSION}
74
+ }
75
+ end
76
+
77
+ def lint_tool
78
+ {
79
+ "name" => "lint",
80
+ "description" =>
81
+ "Lint Markdown prose and code comments against plain-English rules " \
82
+ "(short sentences, active voice, no jargon, no filler). " \
83
+ "Input: a file path. Returns one line per finding, or 'clean' when " \
84
+ "nothing is flagged. Fix the findings, then call again until clean.",
85
+ "inputSchema" => {
86
+ "type" => "object",
87
+ "properties" => {"path" => {"type" => "string", "description" => "File to lint"}},
88
+ "required" => ["path"]
89
+ }
90
+ }
91
+ end
92
+
93
+ def tools_call(id, params, linter)
94
+ tool = params["name"]
95
+ return error(id, -32602, "Unknown tool: #{tool}") unless tool == "lint"
96
+ arguments = params["arguments"]
97
+ arguments = {} unless arguments.is_a?(Hash)
98
+ path = arguments["path"]
99
+ return ok(id, tool_error("the path argument is required.")) if path.nil? || path.to_s.empty?
100
+ return ok(id, tool_error("#{path} is a directory. Give one file.")) if File.directory?(path)
101
+ return ok(id, tool_error("file not found: #{path}")) unless File.exist?(path)
102
+ findings =
103
+ begin
104
+ linter.call(path)
105
+ rescue SimpleEnglish::Config::ConfigError => e
106
+ return ok(id, tool_error("invalid .simple-english.yml: #{e.message}"))
107
+ end
108
+ if findings.nil?
109
+ # A tool error, not a protocol error: clients show the text to the
110
+ # model, and this message is the one that tells it how to recover.
111
+ ok(id, tool_error("the se daemon did not answer. Run `se serve --detached` and retry."))
112
+ else
113
+ ok(id, {"content" => [{"type" => "text", "text" => render(path, findings)}], "isError" => false})
114
+ end
115
+ end
116
+
117
+ def render(path, findings)
118
+ return "clean" if findings.empty?
119
+ findings.map { |finding| finding.to_line(path) }.join("\n")
120
+ end
121
+
122
+ def tool_error(text)
123
+ {"content" => [{"type" => "text", "text" => text}], "isError" => true}
124
+ end
125
+
126
+ def ok(id, result)
127
+ {"jsonrpc" => "2.0", "id" => id, "result" => result}
128
+ end
129
+
130
+ def error(id, code, message)
131
+ {"jsonrpc" => "2.0", "id" => id, "error" => {"code" => code, "message" => message}}
132
+ end
133
+ end
134
+ end
@@ -22,8 +22,19 @@ module SimpleEnglish
22
22
  raise ConfigError, ".simple-english.yml: #{e.message}"
23
23
  end
24
24
 
25
+ # Hooks pass absolute paths while ignore globs are written
26
+ # relative to the repo root (the CWD). Match both forms.
27
+ # realpath because CWD may be a symlink-resolved path while the
28
+ # caller's string still carries the symlink (TMPDIR on macOS).
25
29
  def ignore?(config, path)
26
- config[:ignore].any? { |pattern| matches?(pattern, path) }
30
+ candidates = [path, relative_candidate(path)].compact
31
+ candidates.any? { |candidate| config[:ignore].any? { |pattern| matches?(pattern, candidate) } }
32
+ end
33
+
34
+ def relative_candidate(path)
35
+ pwd = Dir.pwd + "/"
36
+ resolved = File.exist?(path) ? File.realpath(path) : File.expand_path(path)
37
+ resolved.start_with?(pwd) ? resolved.delete_prefix(pwd) : nil
27
38
  end
28
39
 
29
40
  # File.fnmatch has no globstar: `**` never crosses directories.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleEnglish
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -21,6 +21,7 @@ require_relative "simple_english/client/language_tool"
21
21
  require_relative "simple_english/client/daemon"
22
22
  require_relative "simple_english/daemon/engine"
23
23
  require_relative "simple_english/daemon/http"
24
+ require_relative "simple_english/model_context_protocol"
24
25
  require_relative "simple_english/daemon/server"
25
26
 
26
27
  module SimpleEnglish
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_english
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TonyCTHsu
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-02 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: tree_sitter_language_pack
@@ -153,6 +153,7 @@ files:
153
153
  - lib/simple_english/lint/segment.rb
154
154
  - lib/simple_english/lint/span.rb
155
155
  - lib/simple_english/lint/suppressions.rb
156
+ - lib/simple_english/model_context_protocol.rb
156
157
  - lib/simple_english/setup/config.rb
157
158
  - lib/simple_english/setup/fingerprint.rb
158
159
  - lib/simple_english/setup/install.rb
@@ -165,7 +166,7 @@ licenses:
165
166
  metadata:
166
167
  homepage_uri: https://github.com/TonyCTHsu/simple-english
167
168
  source_code_uri: https://github.com/TonyCTHsu/simple-english
168
- changelog_uri: https://github.com/TonyCTHsu/simple-english/blob/v0.4.2/CHANGELOG.md
169
+ changelog_uri: https://github.com/TonyCTHsu/simple-english/blob/v0.5.0/CHANGELOG.md
169
170
  post_install_message:
170
171
  rdoc_options: []
171
172
  require_paths: