docbridge 0.10.1 → 0.12.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.
- package/CHANGELOG.md +74 -1
- package/README.md +16 -9
- package/dist/bin/darwin-arm64/SHA256SUMS +3 -2
- package/dist/bin/darwin-arm64/docbridge-go-scanner +0 -0
- package/dist/bin/darwin-arm64/docbridge-swift-scanner +0 -0
- package/dist/bin/darwin-arm64/docbridge_dart_scanner +0 -0
- package/dist/bin/linux-x64/SHA256SUMS +3 -2
- package/dist/bin/linux-x64/docbridge-go-scanner +0 -0
- package/dist/bin/linux-x64/docbridge-swift-scanner +1 -1
- package/dist/bin/linux-x64/docbridge_dart_scanner +0 -0
- package/dist/index.js +1773 -668
- package/dist/workers/java/docbridge-java-scanner.jar +0 -0
- package/dist/workers/python/docbridge_python_scanner/__init__.py +6 -0
- package/dist/workers/python/docbridge_python_scanner/comments.py +177 -0
- package/dist/workers/python/docbridge_python_scanner/declarations.py +318 -0
- package/dist/workers/python/docbridge_python_scanner/links.py +62 -0
- package/dist/workers/python/docbridge_python_scanner/positions.py +75 -0
- package/dist/workers/python/docbridge_python_scanner/protocol.py +105 -0
- package/dist/workers/python/docbridge_python_scanner/scanner.py +214 -0
- package/dist/workers/python/docbridge_python_scanner.py +16 -0
- package/dist/workers/ruby/bin/docbridge-ruby-scanner +10 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/cli.rb +30 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/comments.rb +55 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/compat.rb +39 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/declarations.rb +230 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/links.rb +38 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/positions.rb +50 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/probe.rb +28 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/protocol.rb +161 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner.rb +21 -0
- package/docs/README.md +10 -3
- package/docs/user/automation.md +8 -4
- package/docs/user/commands.md +11 -3
- package/docs/user/configuration.md +105 -13
- package/docs/user/getting-started.md +1 -1
- package/docs/user/linking.md +105 -10
- package/docs/user/troubleshooting.md +23 -10
- package/package.json +1 -1
- package/schemas/common-output.schema.json +11 -1
- package/schemas/context-output.schema.json +1 -1
- package/schemas/docbridge.schema.json +118 -2
- package/schemas/graph-output.schema.json +1 -1
- package/schemas/scanner-worker.schema.json +1 -1
- package/templates/skills/docbridge/SKILL.md +12 -2
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "docbridge_ruby_scanner/compat"
|
|
4
|
+
require "docbridge_ruby_scanner/comments"
|
|
5
|
+
|
|
6
|
+
module DocbridgeRubyScanner
|
|
7
|
+
# One declaration found in a file, in source order. An unsupported
|
|
8
|
+
# declaration is recorded only when its comment block carries `@doc`, so it
|
|
9
|
+
# can be reported; supported declarations are recorded whether or not they
|
|
10
|
+
# are documented, so audit mode can list them.
|
|
11
|
+
Declaration = Struct.new(
|
|
12
|
+
:kind, :symbol_name, :canonical_id, :visibility, :unsupported,
|
|
13
|
+
:name_start, :name_end, :decl_start, :decl_end, :sig_start, :sig_end, :targets,
|
|
14
|
+
keyword_init: true,
|
|
15
|
+
) do
|
|
16
|
+
def container?
|
|
17
|
+
kind == :container
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The lexical state of one class, module, or `class << self` body.
|
|
22
|
+
Scope = Struct.new(:container, :singleton, :dynamic, :default_visibility, keyword_init: true) do
|
|
23
|
+
# The canonical ID a method named `name` gets in this scope.
|
|
24
|
+
def method_id(name, singleton_method)
|
|
25
|
+
prefix = container ? "#{container}." : ""
|
|
26
|
+
singleton_method ? "#{prefix}self.#{name}" : "#{prefix}#{name}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def constant_id(name)
|
|
30
|
+
container ? "#{container}::#{name}" : name.to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Walks the Prism tree and collects declarations from the top level and
|
|
35
|
+
# class or module bodies; method bodies, blocks, and control flow are never
|
|
36
|
+
# entered.
|
|
37
|
+
class DeclarationCollector
|
|
38
|
+
VISIBILITY_CALLS = { private: "private", protected: "protected", public: "public" }.freeze
|
|
39
|
+
CLASS_METHOD_CALLS = { private_class_method: "private", public_class_method: "public" }.freeze
|
|
40
|
+
UNSUPPORTED_CALLS = %i[attr_reader attr_writer attr_accessor alias_method define_method].freeze
|
|
41
|
+
|
|
42
|
+
def self.collect(parse_result, converter)
|
|
43
|
+
collector = new(parse_result, converter)
|
|
44
|
+
collector.visit_body(parse_result.value.statements, Scope.new(default_visibility: "public"))
|
|
45
|
+
collector.merge_containers
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(parse_result, converter)
|
|
49
|
+
@comments = CommentIndex.new(parse_result, converter)
|
|
50
|
+
@declarations = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def visit_body(body, scope)
|
|
54
|
+
statements = case body
|
|
55
|
+
when Prism::StatementsNode then body.body
|
|
56
|
+
when Prism::BeginNode then body.statements&.body || []
|
|
57
|
+
else []
|
|
58
|
+
end
|
|
59
|
+
statements.each { |node| visit_statement(node, scope) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Reopenings of one container become one declaration whose annotations
|
|
63
|
+
# combine, located at the first declaration.
|
|
64
|
+
def merge_containers
|
|
65
|
+
first_by_id = {}
|
|
66
|
+
@declarations.select do |declaration|
|
|
67
|
+
next true unless declaration.container?
|
|
68
|
+
|
|
69
|
+
first = first_by_id[declaration.canonical_id]
|
|
70
|
+
if first
|
|
71
|
+
first.targets.concat(declaration.targets)
|
|
72
|
+
false
|
|
73
|
+
else
|
|
74
|
+
first_by_id[declaration.canonical_id] = declaration
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def visit_statement(node, scope)
|
|
83
|
+
case node
|
|
84
|
+
when Prism::ClassNode, Prism::ModuleNode then visit_container(node, scope)
|
|
85
|
+
when Prism::SingletonClassNode then visit_singleton_class(node, scope)
|
|
86
|
+
when Prism::DefNode then visit_def(node, scope, nil)
|
|
87
|
+
when Prism::ConstantWriteNode then visit_constant(node, node.name, node.name_loc, scope)
|
|
88
|
+
when Prism::ConstantPathWriteNode then visit_constant_path(node, scope)
|
|
89
|
+
when Prism::CallNode then visit_call(node, scope)
|
|
90
|
+
when Prism::AliasMethodNode then unsupported_if_annotated(node, node.new_name.location)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def visit_container(node, scope)
|
|
95
|
+
path = node.constant_path
|
|
96
|
+
name_loc = path.is_a?(Prism::ConstantPathNode) ? Compat.constant_path_name_loc(path) : path.location
|
|
97
|
+
id = scope.dynamic || scope.singleton ? nil : qualify(path, scope)
|
|
98
|
+
return unsupported_if_annotated(node, name_loc) if id.nil?
|
|
99
|
+
|
|
100
|
+
add(node, kind: :container, symbol_name: last_segment(id), canonical_id: id, visibility: "public",
|
|
101
|
+
name_loc: name_loc, sig_end: node.location.end_offset)
|
|
102
|
+
visit_body(node.body, Scope.new(container: id, default_visibility: "public"))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# `class << self` hosts singleton methods; any other receiver is dynamic.
|
|
106
|
+
def visit_singleton_class(node, scope)
|
|
107
|
+
unsupported_if_annotated(node, node.class_keyword_loc)
|
|
108
|
+
dynamic = scope.dynamic || scope.singleton || !node.expression.is_a?(Prism::SelfNode)
|
|
109
|
+
visit_body(node.body, Scope.new(container: scope.container, singleton: true, dynamic: dynamic,
|
|
110
|
+
default_visibility: "public"))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def visit_def(node, scope, explicit_visibility)
|
|
114
|
+
receiver = node.receiver
|
|
115
|
+
dynamic = scope.dynamic || (scope.singleton && receiver) || (receiver && !receiver.is_a?(Prism::SelfNode))
|
|
116
|
+
return unsupported_if_annotated(node, node.name_loc) if dynamic
|
|
117
|
+
|
|
118
|
+
singleton = scope.singleton || !receiver.nil?
|
|
119
|
+
visibility = explicit_visibility || (receiver ? "public" : scope.default_visibility)
|
|
120
|
+
add(node, kind: :method, symbol_name: node.name.to_s, canonical_id: scope.method_id(node.name, singleton),
|
|
121
|
+
visibility: visibility, name_loc: node.name_loc, sig_end: signature_end(node))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def signature_end(node)
|
|
125
|
+
return node.rparen_loc.end_offset if node.rparen_loc
|
|
126
|
+
return node.parameters.location.end_offset if node.parameters
|
|
127
|
+
|
|
128
|
+
node.name_loc.end_offset
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def visit_constant(node, name, name_loc, scope)
|
|
132
|
+
return unsupported_if_annotated(node, name_loc) if scope.dynamic || scope.singleton
|
|
133
|
+
|
|
134
|
+
id = scope.constant_id(name)
|
|
135
|
+
add(node, kind: :constant, symbol_name: name.to_s, canonical_id: id, visibility: "public",
|
|
136
|
+
name_loc: name_loc, sig_end: node.location.end_offset)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def visit_constant_path(node, scope)
|
|
140
|
+
name_loc = Compat.constant_path_name_loc(node.target)
|
|
141
|
+
id = scope.dynamic || scope.singleton ? nil : qualify(node.target, scope)
|
|
142
|
+
return unsupported_if_annotated(node, name_loc) if id.nil?
|
|
143
|
+
|
|
144
|
+
add(node, kind: :constant, symbol_name: last_segment(id), canonical_id: id, visibility: "public",
|
|
145
|
+
name_loc: name_loc, sig_end: node.location.end_offset)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def visit_call(node, scope)
|
|
149
|
+
return unless node.receiver.nil?
|
|
150
|
+
|
|
151
|
+
name = node.name.to_sym
|
|
152
|
+
if VISIBILITY_CALLS.key?(name)
|
|
153
|
+
visibility_call(node, scope, VISIBILITY_CALLS[name], scope.singleton)
|
|
154
|
+
elsif CLASS_METHOD_CALLS.key?(name)
|
|
155
|
+
visibility_call(node, scope, CLASS_METHOD_CALLS[name], true)
|
|
156
|
+
elsif UNSUPPORTED_CALLS.include?(name)
|
|
157
|
+
unsupported_if_annotated(node, node.message_loc || node.location)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A bare call switches the body's default; arguments name the methods it
|
|
162
|
+
# applies to, either inline `def`s or already defined methods.
|
|
163
|
+
def visit_call_arguments(node)
|
|
164
|
+
node.arguments ? node.arguments.arguments : []
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def visit_call_target(argument)
|
|
168
|
+
case argument
|
|
169
|
+
when Prism::SymbolNode, Prism::StringNode then argument.unescaped
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def visibility_call(node, scope, visibility, singleton)
|
|
174
|
+
arguments = visit_call_arguments(node)
|
|
175
|
+
if arguments.empty?
|
|
176
|
+
scope.default_visibility = visibility unless singleton && !scope.singleton
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
arguments.each do |argument|
|
|
180
|
+
if argument.is_a?(Prism::DefNode)
|
|
181
|
+
visit_def(argument, scope, visibility)
|
|
182
|
+
elsif (name = visit_call_target(argument))
|
|
183
|
+
id = scope.method_id(name, singleton)
|
|
184
|
+
@declarations.each { |d| d.visibility = visibility if d.kind == :method && d.canonical_id == id }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Lexical qualification: a leading `::` resets to the top level; a parent
|
|
190
|
+
# that is not a constant makes the path dynamic (nil).
|
|
191
|
+
def qualify(path, scope)
|
|
192
|
+
case path
|
|
193
|
+
when Prism::ConstantReadNode
|
|
194
|
+
scope.constant_id(path.name)
|
|
195
|
+
when Prism::ConstantPathNode
|
|
196
|
+
name = Compat.constant_path_name(path)
|
|
197
|
+
return nil if name.nil?
|
|
198
|
+
|
|
199
|
+
parent = path.parent
|
|
200
|
+
return name.to_s if parent.nil?
|
|
201
|
+
|
|
202
|
+
prefix = qualify(parent, scope)
|
|
203
|
+
prefix && "#{prefix}::#{name}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def last_segment(id)
|
|
208
|
+
id.split("::").last
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def unsupported_if_annotated(node, name_loc)
|
|
212
|
+
block = @comments.block_above(node.location.start_offset)
|
|
213
|
+
return if block.nil? || block.targets.empty?
|
|
214
|
+
|
|
215
|
+
@declarations << Declaration.new(kind: :unsupported, unsupported: true, name_start: name_loc.start_offset,
|
|
216
|
+
name_end: name_loc.end_offset, targets: block.targets)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def add(node, kind:, symbol_name:, canonical_id:, visibility:, name_loc:, sig_end:)
|
|
220
|
+
block = @comments.block_above(node.location.start_offset)
|
|
221
|
+
decl_start = block ? block.start_offset : node.location.start_offset
|
|
222
|
+
@declarations << Declaration.new(
|
|
223
|
+
kind: kind, symbol_name: symbol_name, canonical_id: canonical_id, visibility: visibility,
|
|
224
|
+
unsupported: false, name_start: name_loc.start_offset, name_end: name_loc.end_offset,
|
|
225
|
+
decl_start: decl_start, decl_end: node.location.end_offset, sig_start: decl_start, sig_end: sig_end,
|
|
226
|
+
targets: block ? block.targets : [],
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocbridgeRubyScanner
|
|
4
|
+
# One `@doc` annotation with the byte offsets of its target in the content.
|
|
5
|
+
DocTarget = Struct.new(:target, :start_offset, :end_offset)
|
|
6
|
+
|
|
7
|
+
# `@doc\s+(\S+)` matching and target validation shared with the other
|
|
8
|
+
# workers.
|
|
9
|
+
module Links
|
|
10
|
+
DOC_PATTERN = /@doc[ \t\r\n\f\v]+([^ \t\r\n\f\v]+)/
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Finds every `@doc <target>` in a comment body that starts at
|
|
15
|
+
# `body_offset` in the content. Offsets are bytes into the content.
|
|
16
|
+
def doc_targets(body, body_offset)
|
|
17
|
+
targets = []
|
|
18
|
+
body.scan(DOC_PATTERN) do
|
|
19
|
+
match = Regexp.last_match
|
|
20
|
+
start_offset = body_offset + match.byteoffset(1)[0]
|
|
21
|
+
target = match[1]
|
|
22
|
+
targets << DocTarget.new(target, start_offset, start_offset + target.bytesize)
|
|
23
|
+
end
|
|
24
|
+
targets
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A project-root-relative `file#fragment` that is not the source file.
|
|
28
|
+
def valid_target?(target, source_file_path)
|
|
29
|
+
file_path, fragment, *rest = target.split("#", -1)
|
|
30
|
+
return false unless rest.empty? && fragment && !file_path.empty? && !fragment.empty?
|
|
31
|
+
return false if file_path.start_with?("/", "./", "../")
|
|
32
|
+
return false if file_path.include?("\\") || file_path.match?(/[ \t\r\n]/) || fragment.match?(/[ \t\r\n]/)
|
|
33
|
+
return false if file_path.split("/").include?("..")
|
|
34
|
+
|
|
35
|
+
file_path != source_file_path
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocbridgeRubyScanner
|
|
4
|
+
# Converts Prism byte offsets into 1-based lines and UTF-16 columns over the
|
|
5
|
+
# original content. Lines split on "\n" only, so a CRLF file keeps its "\r"
|
|
6
|
+
# inside the line, and a leading byte order mark counts as one column on
|
|
7
|
+
# line 1, as the other workers report it.
|
|
8
|
+
class PositionConverter
|
|
9
|
+
attr_reader :content
|
|
10
|
+
|
|
11
|
+
def initialize(content)
|
|
12
|
+
@content = content
|
|
13
|
+
@line_starts = [0]
|
|
14
|
+
cursor = 0
|
|
15
|
+
while (newline = content.byteindex("\n", cursor))
|
|
16
|
+
@line_starts << newline + 1
|
|
17
|
+
cursor = newline + 1
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def position(offset)
|
|
22
|
+
offset = offset.clamp(0, @content.bytesize)
|
|
23
|
+
line_index = @line_starts.bsearch_index { |start| start > offset }
|
|
24
|
+
line_index = line_index.nil? ? @line_starts.size - 1 : line_index - 1
|
|
25
|
+
line_start = @line_starts[line_index]
|
|
26
|
+
column = 1
|
|
27
|
+
@content.byteslice(line_start, offset - line_start).each_codepoint do |codepoint|
|
|
28
|
+
column += codepoint > 0xFFFF ? 2 : 1
|
|
29
|
+
end
|
|
30
|
+
{ line: line_index + 1, column: column }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def location(file_path, offset)
|
|
34
|
+
{ filePath: file_path }.merge(position(offset))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def range(start_offset, end_offset)
|
|
38
|
+
{ start: position(start_offset), end: position(end_offset) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The content between two byte offsets, clamped to the content.
|
|
42
|
+
def slice(start_offset, end_offset)
|
|
43
|
+
start_offset = [start_offset, 0].max
|
|
44
|
+
end_offset = [end_offset, @content.bytesize].min
|
|
45
|
+
return "" if start_offset >= end_offset
|
|
46
|
+
|
|
47
|
+
@content.byteslice(start_offset, end_offset - start_offset)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The `--probe` answer. This file must load on any Ruby that can start the
|
|
4
|
+
# entrypoint, so it requires nothing but the JSON that reports the result and
|
|
5
|
+
# checks the runtime floor before touching Prism.
|
|
6
|
+
module DocbridgeRubyScanner
|
|
7
|
+
module Probe
|
|
8
|
+
MINIMUM_VERSION = [3, 3].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def result(ruby_version: RUBY_VERSION, engine: RUBY_ENGINE)
|
|
13
|
+
return { ok: false, reason: "DocBridge requires CRuby, found #{engine}." } unless engine == "ruby"
|
|
14
|
+
|
|
15
|
+
version = ruby_version.split(".").map(&:to_i)
|
|
16
|
+
if (version <=> MINIMUM_VERSION).negative?
|
|
17
|
+
return { ok: false, reason: "DocBridge requires CRuby 3.3 or later, found #{ruby_version}." }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
require "prism"
|
|
22
|
+
rescue LoadError => error
|
|
23
|
+
return { ok: false, reason: "The bundled Prism gem cannot be loaded: #{error.message}" }
|
|
24
|
+
end
|
|
25
|
+
{ ok: true, runtime: "cruby", version: ruby_version, prism: Prism::VERSION }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "docbridge_ruby_scanner/compat"
|
|
4
|
+
require "docbridge_ruby_scanner/declarations"
|
|
5
|
+
require "docbridge_ruby_scanner/links"
|
|
6
|
+
require "docbridge_ruby_scanner/positions"
|
|
7
|
+
|
|
8
|
+
module DocbridgeRubyScanner
|
|
9
|
+
LANGUAGE = "ruby"
|
|
10
|
+
DEFAULT_VISIBILITY = ["public"].freeze
|
|
11
|
+
|
|
12
|
+
# Builds schemaVersion 1 responses. Hash keys are symbols in the order the
|
|
13
|
+
# other workers emit them; optional fields are omitted, never nil.
|
|
14
|
+
module Protocol
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def scan_request(request)
|
|
18
|
+
visibility = request.fetch("options", {})["visibility"]
|
|
19
|
+
files = request.fetch("files").map do |file|
|
|
20
|
+
scan_file(file.fetch("filePath"), file.fetch("content"), visibility)
|
|
21
|
+
end
|
|
22
|
+
{ schemaVersion: 1, requestId: request.fetch("requestId"), language: LANGUAGE, files: files }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A nil visibility means the default, public-only set; an empty list
|
|
26
|
+
# includes nothing.
|
|
27
|
+
def scan_file(file_path, content, visibility)
|
|
28
|
+
content = content.dup.force_encoding(Encoding::UTF_8)
|
|
29
|
+
converter = PositionConverter.new(content)
|
|
30
|
+
parse_result = Prism.parse(content, filepath: file_path)
|
|
31
|
+
return parse_error_response(file_path, parse_result.errors, converter) unless parse_result.errors.empty?
|
|
32
|
+
|
|
33
|
+
declarations = DeclarationCollector.collect(parse_result, converter)
|
|
34
|
+
ResponseBuilder.new(file_path, converter, visibility || DEFAULT_VISIBILITY).build(declarations)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_error_response(file_path, errors, converter)
|
|
38
|
+
first = errors.min_by.with_index { |error, index| [error.location.start_offset, index] }
|
|
39
|
+
{
|
|
40
|
+
filePath: file_path, symbols: [], undocumentedSymbols: [], links: [],
|
|
41
|
+
diagnostics: [{
|
|
42
|
+
severity: "error", code: "code_parse_error", target: file_path, language: LANGUAGE,
|
|
43
|
+
message: "Ruby parse error: #{sentence(first.message)}",
|
|
44
|
+
location: converter.location(file_path, first.location.start_offset),
|
|
45
|
+
}],
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def sentence(message)
|
|
50
|
+
message.end_with?(".") ? message : "#{message}."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Turns collected declarations into one file response, applying the
|
|
55
|
+
# visibility filter and the duplicate rules.
|
|
56
|
+
class ResponseBuilder
|
|
57
|
+
def initialize(file_path, converter, visibility)
|
|
58
|
+
@file_path = file_path
|
|
59
|
+
@converter = converter
|
|
60
|
+
@visible = visibility.to_a
|
|
61
|
+
@response = { filePath: file_path, symbols: [], undocumentedSymbols: [], links: [], diagnostics: [] }
|
|
62
|
+
@seen_endpoints = {}
|
|
63
|
+
@duplicate_endpoints = {}
|
|
64
|
+
@undocumented_endpoints = {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build(declarations)
|
|
68
|
+
# An endpoint that any emitted declaration documents is never also
|
|
69
|
+
# undocumented, whichever comes first in the file.
|
|
70
|
+
documented = declarations.select { |declaration| emitted?(declaration) && !declaration.targets.empty? }
|
|
71
|
+
@documented_ids = documented.to_h { |declaration| [declaration.canonical_id, true] }
|
|
72
|
+
declarations.each { |declaration| add(declaration) }
|
|
73
|
+
@response
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def emitted?(declaration)
|
|
79
|
+
!declaration.unsupported && @visible.include?(declaration.visibility)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add(declaration)
|
|
83
|
+
unless emitted?(declaration)
|
|
84
|
+
unsupported(declaration) unless declaration.targets.empty?
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
symbol = make_symbol(declaration)
|
|
89
|
+
endpoint = symbol[:endpoint]
|
|
90
|
+
if declaration.targets.empty?
|
|
91
|
+
return if @documented_ids[declaration.canonical_id] || @undocumented_endpoints[endpoint]
|
|
92
|
+
|
|
93
|
+
@undocumented_endpoints[endpoint] = true
|
|
94
|
+
@response[:undocumentedSymbols] << symbol
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if @seen_endpoints[endpoint]
|
|
99
|
+
duplicate_symbol(declaration, symbol) unless @duplicate_endpoints[endpoint]
|
|
100
|
+
@duplicate_endpoints[endpoint] = true
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
@seen_endpoints[endpoint] = true
|
|
104
|
+
@response[:symbols] << symbol
|
|
105
|
+
add_links(declaration, endpoint)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_links(declaration, endpoint)
|
|
109
|
+
seen_targets = {}
|
|
110
|
+
declaration.targets.each do |target|
|
|
111
|
+
location = @converter.location(@file_path, target.start_offset)
|
|
112
|
+
target_range = @converter.range(target.start_offset, target.end_offset)
|
|
113
|
+
if !Links.valid_target?(target.target, @file_path)
|
|
114
|
+
@response[:diagnostics] << {
|
|
115
|
+
severity: "error", code: "invalid_link_target", target: target.target, language: LANGUAGE,
|
|
116
|
+
source: endpoint,
|
|
117
|
+
message: "Link target must be a project-root-relative file path and fragment in file#fragment form.",
|
|
118
|
+
location: location, range: target_range,
|
|
119
|
+
}
|
|
120
|
+
elsif seen_targets[target.target]
|
|
121
|
+
@response[:diagnostics] << {
|
|
122
|
+
severity: "warning", code: "duplicate_link", target: target.target, language: LANGUAGE,
|
|
123
|
+
source: endpoint, message: "Duplicate @doc link from #{endpoint} to #{target.target}.",
|
|
124
|
+
location: location, range: target_range,
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
seen_targets[target.target] = true
|
|
128
|
+
@response[:links] << { source: endpoint, target: target.target, location: location, targetRange: target_range }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def unsupported(declaration)
|
|
134
|
+
@response[:diagnostics] << {
|
|
135
|
+
severity: "warning", code: "unsupported_declaration", target: @file_path, language: LANGUAGE,
|
|
136
|
+
message: "Ruby declaration annotated with @doc is not supported.",
|
|
137
|
+
location: @converter.location(@file_path, declaration.name_start),
|
|
138
|
+
range: @converter.range(declaration.name_start, declaration.name_end),
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def duplicate_symbol(declaration, symbol)
|
|
143
|
+
@response[:diagnostics] << {
|
|
144
|
+
severity: "error", code: "duplicate_code_symbol", target: symbol[:endpoint], language: LANGUAGE,
|
|
145
|
+
message: "Duplicate Ruby code symbol endpoint: #{symbol[:endpoint]}",
|
|
146
|
+
location: @converter.location(@file_path, declaration.name_start), range: symbol[:nameRange],
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def make_symbol(declaration)
|
|
151
|
+
{
|
|
152
|
+
kind: "code", language: LANGUAGE, filePath: @file_path, symbolName: declaration.symbol_name,
|
|
153
|
+
canonicalId: declaration.canonical_id, endpoint: "#{@file_path}##{declaration.canonical_id}",
|
|
154
|
+
location: @converter.location(@file_path, declaration.name_start),
|
|
155
|
+
nameRange: @converter.range(declaration.name_start, declaration.name_end),
|
|
156
|
+
declarationRange: @converter.range(declaration.decl_start, declaration.decl_end),
|
|
157
|
+
signatureRange: @converter.range(declaration.sig_start, declaration.sig_end),
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# DocBridge Ruby scanner worker: Prism-based `@doc` extraction for
|
|
4
|
+
# schemaVersion 1 of the shared worker protocol. `Protocol` and the
|
|
5
|
+
# declaration collector do the work; `CLI` wraps them for the executable.
|
|
6
|
+
require "docbridge_ruby_scanner/protocol"
|
|
7
|
+
require "docbridge_ruby_scanner/cli"
|
|
8
|
+
|
|
9
|
+
module DocbridgeRubyScanner
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Scans one parsed worker request (string keys) and returns the response.
|
|
13
|
+
def scan_request(request)
|
|
14
|
+
Protocol.scan_request(request)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Scans one file; a nil visibility means the default `["public"]`.
|
|
18
|
+
def scan_file(file_path, content, visibility = nil)
|
|
19
|
+
Protocol.scan_file(file_path, content, visibility)
|
|
20
|
+
end
|
|
21
|
+
end
|
package/docs/README.md
CHANGED
|
@@ -23,6 +23,10 @@ through `docbridge docs show <name>`:
|
|
|
23
23
|
- [Troubleshooting](user/troubleshooting.md) — recover from configuration,
|
|
24
24
|
scanner, parsing, and link diagnostics
|
|
25
25
|
|
|
26
|
+
Runnable showcases, one per supported language, live under
|
|
27
|
+
[`examples/`](../examples/). Release history is in the
|
|
28
|
+
[Changelog](../CHANGELOG.md).
|
|
29
|
+
|
|
26
30
|
## Automate and integrate
|
|
27
31
|
|
|
28
32
|
- [Claude Code](integrations/claude-code.md)
|
|
@@ -55,13 +59,16 @@ with the specification for the interface whose exact contract you need:
|
|
|
55
59
|
- [Writing guidelines](contributing/writing.md)
|
|
56
60
|
- [Documentation guidelines](contributing/documentation.md)
|
|
57
61
|
- [Testing](contributing/testing.md)
|
|
62
|
+
- [Adding a language](contributing/adding-a-language.md)
|
|
58
63
|
- [Commit messages](contributing/commits.md)
|
|
59
64
|
- [Pull requests](contributing/pull-requests.md)
|
|
60
65
|
- [Releasing](contributing/releasing.md)
|
|
61
66
|
- [Self-audit baseline](contributing/self-audit.md)
|
|
62
|
-
|
|
67
|
+
|
|
68
|
+
## Historical records
|
|
63
69
|
|
|
64
70
|
Architectural choices are recorded in [Decisions](decisions/README.md).
|
|
65
71
|
Completed delivery records are archived under
|
|
66
|
-
[Completed plans](plans/done/README.md)
|
|
67
|
-
|
|
72
|
+
[Completed plans](plans/done/README.md), and dated evidence reports such as the
|
|
73
|
+
[Dogfooding review, September 2026](reports/2026-09-dogfooding-review.md) live
|
|
74
|
+
under `reports/`. They are historical context, not current user instructions.
|
package/docs/user/automation.md
CHANGED
|
@@ -45,10 +45,11 @@ Before editing a linked file, read its counterpart:
|
|
|
45
45
|
docbridge context path/to/file
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
After editing, inspect the change set:
|
|
48
|
+
After editing, inspect the change set, including staged and untracked files:
|
|
49
49
|
|
|
50
50
|
```sh
|
|
51
|
-
git diff --name-only
|
|
51
|
+
{ git diff --name-only HEAD; git ls-files --others --exclude-standard; } |
|
|
52
|
+
docbridge related --stdin --gate
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
For every reported counterpart, make one explicit judgment:
|
|
@@ -64,10 +65,13 @@ Never remove a valid link just to silence the gate.
|
|
|
64
65
|
|
|
65
66
|
Put shared checks in repository hooks rather than one agent's configuration.
|
|
66
67
|
A pre-commit hook can run `docbridge check` as a blocking validity gate and
|
|
67
|
-
then report related files from the staged set
|
|
68
|
+
then report related files from the staged set. `|| exit $?` stops the hook
|
|
69
|
+
when `check` fails, even without `set -e`. The gate exits `1` on a violation,
|
|
70
|
+
so `|| true` keeps that stage from blocking the commit:
|
|
68
71
|
|
|
69
72
|
```sh
|
|
70
|
-
|
|
73
|
+
docbridge check || exit $?
|
|
74
|
+
git diff --cached --name-only | docbridge related --stdin --gate || true
|
|
71
75
|
```
|
|
72
76
|
|
|
73
77
|
Add `docbridge context --stdin` when the report should include counterpart
|
package/docs/user/commands.md
CHANGED
|
@@ -4,7 +4,9 @@ description: Choose between check, related, context, graph, docs, and upgrade.
|
|
|
4
4
|
|
|
5
5
|
# Commands
|
|
6
6
|
|
|
7
|
-
Use the narrowest command for the question you need to answer.
|
|
7
|
+
Use the narrowest command for the question you need to answer. Every option,
|
|
8
|
+
output shape, and exit status is specified in the
|
|
9
|
+
[CLI specification](https://github.com/salan70/docbridge/blob/main/docs/specs/cli.md).
|
|
8
10
|
|
|
9
11
|
<!-- @code src/cli/index.ts#run -->
|
|
10
12
|
|
|
@@ -26,10 +28,12 @@ Markdown sections. Add `--json` for machine-readable diagnostics.
|
|
|
26
28
|
|
|
27
29
|
### `related`: find counterpart files
|
|
28
30
|
|
|
29
|
-
Pass changed paths to learn which linked files may also need attention
|
|
31
|
+
Pass changed paths to learn which linked files may also need attention. This
|
|
32
|
+
input covers staged, unstaged, and untracked files:
|
|
30
33
|
|
|
31
34
|
```sh
|
|
32
|
-
git diff --name-only
|
|
35
|
+
{ git diff --name-only HEAD; git ls-files --others --exclude-standard; } |
|
|
36
|
+
docbridge related --stdin
|
|
33
37
|
```
|
|
34
38
|
|
|
35
39
|
`--gate` exits `1` when a linked counterpart is absent from the supplied change
|
|
@@ -95,6 +99,10 @@ passed, and a non-interactive run without `--yes` fails instead of replacing
|
|
|
95
99
|
local edits. Configuration, hooks, CI recipes, and your own files are never
|
|
96
100
|
touched.
|
|
97
101
|
|
|
102
|
+
A copied skill does not update with the package. A skill copied from 0.8.0
|
|
103
|
+
calls guide names that newer versions reject, so refresh it before relying on
|
|
104
|
+
it.
|
|
105
|
+
|
|
98
106
|
## Update notification
|
|
99
107
|
|
|
100
108
|
On an interactive terminal, DocBridge prints one line to stderr when a newer
|