docbridge 0.11.0 → 0.13.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 (43) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.md +16 -9
  3. package/dist/bin/darwin-arm64/SHA256SUMS +3 -3
  4. package/dist/bin/darwin-arm64/docbridge-go-scanner +0 -0
  5. package/dist/bin/darwin-arm64/docbridge-swift-scanner +0 -0
  6. package/dist/bin/darwin-arm64/docbridge_dart_scanner +0 -0
  7. package/dist/bin/linux-x64/SHA256SUMS +2 -2
  8. package/dist/bin/linux-x64/docbridge-go-scanner +0 -0
  9. package/dist/bin/linux-x64/docbridge-swift-scanner +1 -1
  10. package/dist/index.js +1771 -678
  11. package/dist/workers/java/docbridge-java-scanner.jar +0 -0
  12. package/dist/workers/python/docbridge_python_scanner/__init__.py +6 -0
  13. package/dist/workers/python/docbridge_python_scanner/comments.py +177 -0
  14. package/dist/workers/python/docbridge_python_scanner/declarations.py +318 -0
  15. package/dist/workers/python/docbridge_python_scanner/links.py +62 -0
  16. package/dist/workers/python/docbridge_python_scanner/positions.py +75 -0
  17. package/dist/workers/python/docbridge_python_scanner/protocol.py +105 -0
  18. package/dist/workers/python/docbridge_python_scanner/scanner.py +214 -0
  19. package/dist/workers/python/docbridge_python_scanner.py +16 -0
  20. package/dist/workers/ruby/bin/docbridge-ruby-scanner +10 -0
  21. package/dist/workers/ruby/lib/docbridge_ruby_scanner/cli.rb +30 -0
  22. package/dist/workers/ruby/lib/docbridge_ruby_scanner/comments.rb +55 -0
  23. package/dist/workers/ruby/lib/docbridge_ruby_scanner/compat.rb +39 -0
  24. package/dist/workers/ruby/lib/docbridge_ruby_scanner/declarations.rb +230 -0
  25. package/dist/workers/ruby/lib/docbridge_ruby_scanner/links.rb +38 -0
  26. package/dist/workers/ruby/lib/docbridge_ruby_scanner/positions.rb +50 -0
  27. package/dist/workers/ruby/lib/docbridge_ruby_scanner/probe.rb +28 -0
  28. package/dist/workers/ruby/lib/docbridge_ruby_scanner/protocol.rb +161 -0
  29. package/dist/workers/ruby/lib/docbridge_ruby_scanner.rb +21 -0
  30. package/docs/README.md +10 -3
  31. package/docs/user/automation.md +8 -4
  32. package/docs/user/commands.md +11 -3
  33. package/docs/user/configuration.md +111 -17
  34. package/docs/user/getting-started.md +1 -1
  35. package/docs/user/linking.md +106 -10
  36. package/docs/user/troubleshooting.md +26 -9
  37. package/package.json +1 -1
  38. package/schemas/common-output.schema.json +11 -1
  39. package/schemas/context-output.schema.json +11 -1
  40. package/schemas/docbridge.schema.json +118 -2
  41. package/schemas/graph-output.schema.json +11 -1
  42. package/schemas/scanner-worker.schema.json +1 -1
  43. 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
- - [Dogfooding review, September 2026](reports/2026-09-dogfooding-review.md)
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). They are historical context, not
67
- current user instructions.
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.
@@ -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 | docbridge related --stdin --gate
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
- git diff --cached --name-only | docbridge related --stdin --gate
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
@@ -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 | docbridge related --stdin
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