docbridge 0.11.0 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +56 -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 +1761 -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 +104 -17
  34. package/docs/user/getting-started.md +1 -1
  35. package/docs/user/linking.md +79 -10
  36. package/docs/user/troubleshooting.md +22 -9
  37. package/package.json +1 -1
  38. package/schemas/common-output.schema.json +11 -1
  39. package/schemas/context-output.schema.json +1 -1
  40. package/schemas/docbridge.schema.json +95 -2
  41. package/schemas/graph-output.schema.json +1 -1
  42. package/schemas/scanner-worker.schema.json +1 -1
  43. package/templates/skills/docbridge/SKILL.md +12 -2
@@ -0,0 +1,105 @@
1
+ """Worker protocol: one JSON request on stdin, one JSON response on stdout.
2
+
3
+ ``--probe`` prints one JSON line describing the runtime and exits 0 whether or
4
+ not the runtime is usable, so this module stays importable on older Pythons:
5
+ it uses no 3.10-only syntax and imports the scanner only for a scan.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import platform
12
+ import sys
13
+
14
+ SCHEMA_VERSION = 1
15
+ LANGUAGE = "python"
16
+ RUNTIME_FLOOR = (3, 10)
17
+
18
+
19
+ class ProtocolError(Exception):
20
+ """The request cannot be scanned; the worker exits non-zero."""
21
+
22
+
23
+ def probe() -> dict[str, object]:
24
+ """Describe the interpreter: CPython identity and the 3.10 floor."""
25
+ implementation = platform.python_implementation()
26
+ version = platform.python_version()
27
+ if implementation != "CPython":
28
+ return {"ok": False, "reason": f"expected CPython, found {implementation} {version}"}
29
+ if sys.version_info < RUNTIME_FLOOR:
30
+ floor = ".".join(str(part) for part in RUNTIME_FLOOR)
31
+ return {"ok": False, "reason": f"CPython {version} is below the {floor} floor"}
32
+ return {"ok": True, "runtime": "cpython", "version": version}
33
+
34
+
35
+ def parse_request(raw: bytes) -> dict[str, object]:
36
+ """Decode and validate the request shape the scanner relies on."""
37
+ try:
38
+ request = json.loads(raw.decode("utf-8"))
39
+ except (UnicodeDecodeError, ValueError) as error:
40
+ raise ProtocolError(f"request is not valid JSON: {error}") from error
41
+ if not isinstance(request, dict):
42
+ raise ProtocolError("request must be a JSON object")
43
+ if request.get("schemaVersion") != SCHEMA_VERSION:
44
+ raise ProtocolError(
45
+ f"unsupported schemaVersion {request.get('schemaVersion')!r}; expected {SCHEMA_VERSION}"
46
+ )
47
+ files = request.get("files")
48
+ if not isinstance(files, list):
49
+ raise ProtocolError("request.files must be an array")
50
+ for index, file in enumerate(files):
51
+ if (
52
+ not isinstance(file, dict)
53
+ or not isinstance(file.get("filePath"), str)
54
+ or not isinstance(file.get("content"), str)
55
+ ):
56
+ raise ProtocolError(f"request.files[{index}] must hold filePath and content strings")
57
+ options = request.get("options")
58
+ if options is not None and not isinstance(options, dict):
59
+ raise ProtocolError("request.options must be an object")
60
+ visibility = (options or {}).get("visibility")
61
+ if visibility is not None and (
62
+ not isinstance(visibility, list) or not all(isinstance(v, str) for v in visibility)
63
+ ):
64
+ raise ProtocolError("request.options.visibility must be an array of strings")
65
+ return request
66
+
67
+
68
+ def scan_request(request: dict[str, object]) -> dict[str, object]:
69
+ """Scan every requested file in request order."""
70
+ from .scanner import scan_file
71
+
72
+ options = request.get("options") or {}
73
+ visibility = options.get("visibility") if isinstance(options, dict) else None
74
+ files = request.get("files")
75
+ scanned = [
76
+ scan_file(file["filePath"], file["content"], visibility)
77
+ for file in (files if isinstance(files, list) else [])
78
+ ]
79
+ return {
80
+ "schemaVersion": SCHEMA_VERSION,
81
+ "requestId": request.get("requestId"),
82
+ "language": LANGUAGE,
83
+ "files": scanned,
84
+ }
85
+
86
+
87
+ def _write_json_line(payload: dict[str, object]) -> None:
88
+ sys.stdout.buffer.write(json.dumps(payload, ensure_ascii=False).encode("utf-8") + b"\n")
89
+ sys.stdout.buffer.flush()
90
+
91
+
92
+ def main(argv: list[str]) -> int:
93
+ if argv == ["--probe"]:
94
+ _write_json_line(probe())
95
+ return 0
96
+ if argv:
97
+ sys.stderr.write("docbridge-python-scanner: usage: [--probe] < request.json\n")
98
+ return 2
99
+ try:
100
+ request = parse_request(sys.stdin.buffer.read())
101
+ except ProtocolError as error:
102
+ sys.stderr.write(f"docbridge-python-scanner: {error}\n")
103
+ return 1
104
+ _write_json_line(scan_request(request))
105
+ return 0
@@ -0,0 +1,214 @@
1
+ """Per-file scanning: parse, collect declarations, and build the response file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import tokenize
7
+
8
+ from .comments import TokenIndex
9
+ from .declarations import DeclarationCollector, DeclarationEntry, Entry, Member, UnsupportedEntry
10
+ from .links import INVALID_LINK_TARGET_MESSAGE, DocTarget, is_valid_link_target
11
+ from .positions import LineTable, make_location
12
+
13
+ LANGUAGE = "python"
14
+ DEFAULT_VISIBILITY = ("public",)
15
+
16
+ ResponseFile = dict[str, object]
17
+
18
+
19
+ def _empty_response(file_path: str) -> ResponseFile:
20
+ return {
21
+ "filePath": file_path,
22
+ "symbols": [],
23
+ "undocumentedSymbols": [],
24
+ "links": [],
25
+ "diagnostics": [],
26
+ }
27
+
28
+
29
+ def _sentence(message: str) -> str:
30
+ return message if message.endswith(".") else message + "."
31
+
32
+
33
+ def _parse_error(file_path: str, message: str, position: dict[str, int]) -> ResponseFile:
34
+ response = _empty_response(file_path)
35
+ response["diagnostics"] = [
36
+ {
37
+ "severity": "error",
38
+ "code": "code_parse_error",
39
+ "target": file_path,
40
+ "language": LANGUAGE,
41
+ "message": "Python parse error: " + _sentence(message),
42
+ "location": make_location(file_path, position),
43
+ }
44
+ ]
45
+ return response
46
+
47
+
48
+ def _syntax_error_position(error: SyntaxError, table: LineTable) -> dict[str, int]:
49
+ if error.lineno is None or error.offset is None:
50
+ return {"line": 1, "column": 1}
51
+ return {"line": error.lineno, "column": table.column_from_offset(error.lineno, error.offset)}
52
+
53
+
54
+ def _tokenize_error_position(error: tokenize.TokenError, table: LineTable) -> dict[str, int]:
55
+ if len(error.args) >= 2 and isinstance(error.args[1], tuple) and len(error.args[1]) == 2:
56
+ row, col = error.args[1]
57
+ return table.position_from_chars(row, col)
58
+ return {"line": 1, "column": 1}
59
+
60
+
61
+ def scan_file(file_path: str, content: str, visibility: list[str] | None) -> ResponseFile:
62
+ """Scan one file. ``None`` visibility means the default ``["public"]``."""
63
+ table = LineTable(content)
64
+ try:
65
+ module = ast.parse(table.parse_text, file_path, type_comments=False)
66
+ except SyntaxError as error:
67
+ return _parse_error(
68
+ file_path, error.msg or str(error), _syntax_error_position(error, table)
69
+ )
70
+ except (ValueError, RecursionError) as error:
71
+ return _parse_error(file_path, str(error), {"line": 1, "column": 1})
72
+ try:
73
+ tokens = TokenIndex.from_text(table.parse_text)
74
+ except tokenize.TokenError as error:
75
+ message = error.args[0] if error.args else str(error)
76
+ return _parse_error(file_path, str(message), _tokenize_error_position(error, table))
77
+ except SyntaxError as error:
78
+ return _parse_error(
79
+ file_path, error.msg or str(error), _syntax_error_position(error, table)
80
+ )
81
+
82
+ entries = DeclarationCollector(table, tokens).collect(module)
83
+ visible = set(DEFAULT_VISIBILITY if visibility is None else visibility)
84
+ return ResponseBuilder(file_path, visible).build(entries)
85
+
86
+
87
+ class ResponseBuilder:
88
+ """Turns collected entries into the worker's per-file response."""
89
+
90
+ def __init__(self, file_path: str, visible: set[str]) -> None:
91
+ self.file_path = file_path
92
+ self.visible = visible
93
+ self.response = _empty_response(file_path)
94
+ self.symbols: list[dict[str, object]] = []
95
+ self.undocumented: list[dict[str, object]] = []
96
+ self.links: list[dict[str, object]] = []
97
+ self.diagnostics: list[dict[str, object]] = []
98
+
99
+ def build(self, entries: list[Entry]) -> ResponseFile:
100
+ for entry in entries:
101
+ if isinstance(entry, UnsupportedEntry):
102
+ self._unsupported(entry.range)
103
+ else:
104
+ self._declaration(entry)
105
+ self.response["symbols"] = self.symbols
106
+ self.response["undocumentedSymbols"] = self.undocumented
107
+ self.response["links"] = self.links
108
+ self.response["diagnostics"] = self.diagnostics
109
+ return self.response
110
+
111
+ def _unsupported(self, name_range: dict[str, dict[str, int]]) -> None:
112
+ self.diagnostics.append(
113
+ {
114
+ "severity": "warning",
115
+ "code": "unsupported_declaration",
116
+ "target": self.file_path,
117
+ "language": LANGUAGE,
118
+ "message": "Python declaration annotated with @doc is not supported.",
119
+ "location": make_location(self.file_path, name_range["start"]),
120
+ "range": name_range,
121
+ }
122
+ )
123
+
124
+ def _declaration(self, entry: DeclarationEntry) -> None:
125
+ visibility_class = "private" if entry.private else "public"
126
+ if visibility_class not in self.visible:
127
+ for member in entry.members:
128
+ if member.targets:
129
+ self._unsupported(member.name_range)
130
+ return
131
+
132
+ endpoint = f"{self.file_path}#{entry.canonical_id}"
133
+ symbol = self._symbol(entry, entry.members[0], endpoint)
134
+ # A group's members document the endpoint together. The first annotated
135
+ # declaration owns the links; the endpoint is reported once, at the
136
+ # first annotated repeat, and later repeats are dropped silently.
137
+ annotated = [declaration for declaration in entry.declarations if declaration.targets]
138
+ if not annotated:
139
+ self.undocumented.append(symbol)
140
+ return
141
+ self.symbols.append(symbol)
142
+ self._links(endpoint, annotated[0].targets)
143
+ if len(annotated) > 1:
144
+ self._duplicate_code_symbol(endpoint, annotated[1].first_annotated_member)
145
+
146
+ def _symbol(self, entry: DeclarationEntry, member: Member, endpoint: str) -> dict[str, object]:
147
+ return {
148
+ "kind": "code",
149
+ "language": LANGUAGE,
150
+ "filePath": self.file_path,
151
+ "symbolName": entry.symbol_name,
152
+ "canonicalId": entry.canonical_id,
153
+ "endpoint": endpoint,
154
+ "location": make_location(self.file_path, member.name_range["start"]),
155
+ "nameRange": member.name_range,
156
+ "declarationRange": member.declaration_range,
157
+ "signatureRange": member.signature_range,
158
+ }
159
+
160
+ def _duplicate_code_symbol(self, endpoint: str, member: Member) -> None:
161
+ self.diagnostics.append(
162
+ {
163
+ "severity": "error",
164
+ "code": "duplicate_code_symbol",
165
+ "target": endpoint,
166
+ "language": LANGUAGE,
167
+ "message": "Duplicate Python code symbol endpoint: " + endpoint,
168
+ "location": make_location(self.file_path, member.name_range["start"]),
169
+ "range": member.name_range,
170
+ }
171
+ )
172
+
173
+ def _links(self, endpoint: str, targets: list[DocTarget]) -> None:
174
+ seen: set[str] = set()
175
+ for target in targets:
176
+ location = make_location(self.file_path, target.start)
177
+ target_range = {"start": target.start, "end": target.end}
178
+ if not is_valid_link_target(target.target, self.file_path):
179
+ self.diagnostics.append(
180
+ {
181
+ "severity": "error",
182
+ "code": "invalid_link_target",
183
+ "target": target.target,
184
+ "language": LANGUAGE,
185
+ "source": endpoint,
186
+ "message": INVALID_LINK_TARGET_MESSAGE,
187
+ "location": location,
188
+ "range": target_range,
189
+ }
190
+ )
191
+ continue
192
+ if target.target in seen:
193
+ self.diagnostics.append(
194
+ {
195
+ "severity": "warning",
196
+ "code": "duplicate_link",
197
+ "target": target.target,
198
+ "language": LANGUAGE,
199
+ "source": endpoint,
200
+ "message": f"Duplicate @doc link from {endpoint} to {target.target}.",
201
+ "location": location,
202
+ "range": target_range,
203
+ }
204
+ )
205
+ continue
206
+ seen.add(target.target)
207
+ self.links.append(
208
+ {
209
+ "source": endpoint,
210
+ "target": target.target,
211
+ "location": location,
212
+ "targetRange": target_range,
213
+ }
214
+ )
@@ -0,0 +1,16 @@
1
+ """DocBridge Python scanner worker entrypoint.
2
+
3
+ The core runs this file as ``python3 -I -S <absolute path>``. Isolated mode
4
+ drops the script directory from ``sys.path``, so the directory is inserted
5
+ explicitly before the package import; nothing else is ever added.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ from docbridge_python_scanner.protocol import main
14
+
15
+ if __name__ == "__main__":
16
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DocBridge Ruby scanner worker entrypoint. The core always starts it as
4
+ # `ruby --disable=gems,did_you_mean,error_highlight -W0 <this file>`, so it
5
+ # adds its own lib/ to the load path instead of relying on RubyGems.
6
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
7
+
8
+ require "docbridge_ruby_scanner/cli"
9
+
10
+ exit(DocbridgeRubyScanner::CLI.run(ARGV, $stdin, $stdout, $stderr))
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "docbridge_ruby_scanner/probe"
5
+
6
+ module DocbridgeRubyScanner
7
+ # The executable's behaviour: `--probe` prints one JSON line and exits 0;
8
+ # otherwise one request is read from stdin and one response is written to
9
+ # stdout. Stderr carries only error text.
10
+ module CLI
11
+ module_function
12
+
13
+ def run(argv, stdin, stdout, stderr)
14
+ stdout.binmode
15
+ if argv.include?("--probe")
16
+ stdout.write("#{JSON.generate(Probe.result)}\n")
17
+ return 0
18
+ end
19
+
20
+ require "docbridge_ruby_scanner/protocol"
21
+ stdin.binmode
22
+ request = JSON.parse(stdin.read.force_encoding(Encoding::UTF_8))
23
+ stdout.write("#{JSON.generate(Protocol.scan_request(request))}\n")
24
+ 0
25
+ rescue JSON::ParserError, KeyError, TypeError, NoMethodError => error
26
+ stderr.write("docbridge-ruby-scanner: invalid request: #{error.message}\n")
27
+ 1
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "docbridge_ruby_scanner/compat"
4
+ require "docbridge_ruby_scanner/links"
5
+
6
+ module DocbridgeRubyScanner
7
+ # The contiguous run of full-line `#` comments that ends on the line directly
8
+ # above a declaration. Trailing comments (code before the `#` on the same
9
+ # line), `=begin`/`=end` blocks, and blank lines break the run.
10
+ CommentBlock = Struct.new(:start_offset, :targets)
11
+
12
+ class CommentIndex
13
+ def initialize(parse_result, converter)
14
+ @converter = converter
15
+ @by_line = {}
16
+ content = converter.content
17
+ parse_result.comments.each do |comment|
18
+ next unless Compat.inline_comment?(comment, content)
19
+
20
+ location = comment.location
21
+ next unless full_line?(location.start_offset)
22
+
23
+ @by_line[converter.position(location.start_offset)[:line]] = location
24
+ end
25
+ end
26
+
27
+ # The comment block attached to a declaration that starts at
28
+ # `start_offset`, or nil when the line above it is not a comment.
29
+ def block_above(start_offset)
30
+ line = @converter.position(start_offset)[:line] - 1
31
+ return nil unless @by_line.key?(line)
32
+
33
+ line -= 1 while @by_line.key?(line - 1)
34
+ lines = []
35
+ while (location = @by_line[line])
36
+ lines << location
37
+ line += 1
38
+ end
39
+ targets = lines.flat_map do |location|
40
+ body_start = location.start_offset + 1
41
+ Links.doc_targets(@converter.slice(body_start, location.end_offset), body_start)
42
+ end
43
+ CommentBlock.new(lines.first.start_offset, targets)
44
+ end
45
+
46
+ private
47
+
48
+ def full_line?(offset)
49
+ content = @converter.content
50
+ line_start = content.byterindex("\n", offset)
51
+ line_start = line_start.nil? ? 0 : line_start + 1
52
+ @converter.slice(line_start, offset).match?(/\A[ \t]*\z/)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module DocbridgeRubyScanner
6
+ # Every Prism accessor whose shape differs between the Prism bundled with
7
+ # CRuby 3.3 (0.19) and the 1.x series bundled with 3.4 and later lives here,
8
+ # so the rest of the worker reads one stable API.
9
+ #
10
+ # Prism 0.19 exposes the last segment of a constant path as
11
+ # `ConstantPathNode#child` (a ConstantReadNode); 0.28 replaced it with
12
+ # `#name` and `#name_loc` and 1.x deprecates `#child`.
13
+ module Compat
14
+ module_function
15
+
16
+ # The name of the last segment of a constant path, or nil when the parser
17
+ # recovered from a missing segment.
18
+ def constant_path_name(node)
19
+ if node.respond_to?(:name_loc)
20
+ node.name
21
+ else
22
+ child = node.child
23
+ child.respond_to?(:name) ? child.name : nil
24
+ end
25
+ end
26
+
27
+ # The location of the last segment of a constant path.
28
+ def constant_path_name_loc(node)
29
+ node.respond_to?(:name_loc) ? node.name_loc : node.child.location
30
+ end
31
+
32
+ # True for a `#` comment; `=begin`/`=end` blocks and `__END__` data are
33
+ # not. The class exists in both versions, but checking the source byte is
34
+ # independent of the comment class hierarchy.
35
+ def inline_comment?(comment, content)
36
+ content.byteslice(comment.location.start_offset, 1) == "#"
37
+ end
38
+ end
39
+ end