vibe-coding-master 0.8.12 → 0.9.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 (50) hide show
  1. package/README.md +7 -3
  2. package/dist/backend/cli/install-vcm-harness.js +57 -18
  3. package/dist/backend/gateway/gateway-service.js +5 -3
  4. package/dist/backend/runtime/terminal-submit.js +1 -0
  5. package/dist/backend/server.js +14 -4
  6. package/dist/backend/services/app-settings-service.js +3 -2
  7. package/dist/backend/services/auto-memory-service.js +6 -4
  8. package/dist/backend/services/claude-hook-service.js +6 -0
  9. package/dist/backend/services/claude-transcript-service.js +2 -13
  10. package/dist/backend/services/claude-version-check.js +46 -0
  11. package/dist/backend/services/gate-review-service.js +47 -8
  12. package/dist/backend/services/harness-service.js +41 -20
  13. package/dist/backend/services/message-service.js +30 -6
  14. package/dist/backend/services/pm-user-question.js +2 -1
  15. package/dist/backend/services/project-service.js +8 -2
  16. package/dist/backend/services/runtime-recovery-service.js +36 -9
  17. package/dist/backend/services/session-service.js +10 -19
  18. package/dist/backend/services/task-close-service.js +1 -0
  19. package/dist/backend/services/terminal-process-exit-service.js +4 -1
  20. package/dist/backend/services/workflow-control-service.js +55 -11
  21. package/dist/backend/templates/harness/architect-agent.js +2 -1
  22. package/dist/backend/templates/harness/architect-evidence-worker-agent.js +2 -1
  23. package/dist/backend/templates/harness/architect-scaffold-worker-agent.js +8 -2
  24. package/dist/backend/templates/harness/architect-validation-worker-agent.js +2 -1
  25. package/dist/backend/templates/harness/architect-worker-frontmatter.js +33 -0
  26. package/dist/backend/templates/harness/check-scaffold-ledger.js +480 -511
  27. package/dist/backend/templates/harness/coder-worker-agent.js +1 -0
  28. package/dist/backend/templates/harness/gate-review.js +676 -643
  29. package/dist/backend/templates/harness/node-tool-runtime.js +102 -0
  30. package/dist/backend/templates/harness/resolve-durable-doc-assignment.js +49 -55
  31. package/dist/backend/templates/harness/restart-architect-skill.js +49 -58
  32. package/dist/backend/templates/harness/vcm-ask-user-skill.js +57 -59
  33. package/dist/backend/templates/harness/vcm-propose-memory-skill.js +4 -0
  34. package/dist/backend/templates/harness/vcm-report-harness-issue-skill.js +6 -2
  35. package/dist/backend/templates/harness/vcm-task-state-skill.js +66 -72
  36. package/dist/shared/types/app-settings.js +31 -0
  37. package/dist-frontend/assets/index-CShYYQQa.css +32 -0
  38. package/dist-frontend/assets/{index-DqANomt1.js → index-DOvvS76Q.js} +41 -41
  39. package/dist-frontend/index.html +2 -2
  40. package/package.json +1 -1
  41. package/scripts/harness-tools/check-durable-docs +332 -298
  42. package/scripts/harness-tools/generate-module-index +1047 -512
  43. package/scripts/harness-tools/generate-public-surface +1231 -1069
  44. package/scripts/harness-tools/package.json +3 -0
  45. package/scripts/harness-tools/run-long-check +608 -531
  46. package/scripts/harness-tools/vcm-artifact +181 -113
  47. package/scripts/harness-tools/vcm-bash-guard +839 -605
  48. package/scripts/harness-tools/vcm-subagent-guard +67 -63
  49. package/scripts/harness-tools/watch-job +313 -238
  50. package/dist-frontend/assets/index-xgiQeUD5.css +0 -32
@@ -1,1077 +1,1239 @@
1
- #!/usr/bin/env python3
2
- import argparse
3
- import json
4
- import re
5
- import sys
6
- from dataclasses import dataclass
7
- from pathlib import Path
8
-
9
-
10
- IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
11
- ITEM_KINDS = {"fn": "fn", "struct": "struct", "enum": "enum", "trait": "trait"}
12
- ITEM_MODIFIERS = {"async", "const", "unsafe", "extern"}
13
- BLOCK_ITEM_KEYWORDS = {
14
- "fn",
15
- "struct",
16
- "enum",
17
- "trait",
18
- "impl",
19
- "mod",
20
- "const",
21
- "static",
22
- "type",
23
- "union",
24
- "macro_rules",
25
- }
26
- TS_SOURCE_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
27
- TS_EXPORT_DECLARATION = re.compile(
28
- r"\bexport\s+(?:default\s+)?(?:declare\s+)?(?:(?:async\s+)?function\s+(?P<function>[A-Za-z_$][A-Za-z0-9_$]*)|class\s+(?P<class>[A-Za-z_$][A-Za-z0-9_$]*)|interface\s+(?P<interface>[A-Za-z_$][A-Za-z0-9_$]*)|type\s+(?P<type>[A-Za-z_$][A-Za-z0-9_$]*)|enum\s+(?P<enum>[A-Za-z_$][A-Za-z0-9_$]*)|(?:const|let|var)\s+(?P<variable>[A-Za-z_$][A-Za-z0-9_$]*))"
29
- )
30
- TS_REEXPORT = re.compile(
31
- r"\bexport\s+(?:type\s+)?(?P<form>\*|\{[^}]*\})\s+from\s+(?P<quote>[\"'`])(?P<specifier>[^\"'`]+)(?P=quote)"
32
- )
33
- TS_ROUTE_CALL = re.compile(
34
- r"\.\s*(?P<method>get|post|put|patch|delete|options|head)\s*(?:<(?:(?!>\s*\()[\s\S])*>\s*)?\(\s*(?P<quote>[\"'`])(?P<path>[^\"'`]+)(?P=quote)",
35
- re.IGNORECASE,
36
- )
37
- TS_ROUTE_OBJECT = re.compile(r"\.route\s*\(\s*\{(?P<body>.*?)\}\s*\)", re.IGNORECASE | re.DOTALL)
38
- TS_ROUTE_METHOD = re.compile(r"\bmethod\s*:\s*(?P<quote>[\"'`])(?P<method>[A-Za-z]+)(?P=quote)", re.IGNORECASE)
39
- TS_ROUTE_URL = re.compile(r"\b(?:url|path)\s*:\s*(?P<quote>[\"'`])(?P<path>[^\"'`]+)(?P=quote)", re.IGNORECASE)
40
-
41
-
42
- @dataclass(frozen=True)
43
- class Token:
44
- value: str
45
- start: int
46
- end: int
47
- line: int
48
-
49
-
50
- @dataclass(frozen=True)
51
- class PublicDecl:
52
- kind: str
53
- name: str
54
- pub_index: int
55
- keyword_index: int
56
- name_index: int
57
-
58
-
59
- @dataclass(frozen=True)
60
- class UseExport:
61
- path: tuple[str, ...]
62
- alias: str | None = None
63
- wildcard: bool = False
64
-
65
-
66
- def load_module_index(root: Path) -> dict:
67
- path = root / ".ai/generated/module-index.json"
68
- if not path.is_file():
69
- raise SystemExit("Missing .ai/generated/module-index.json; run .ai/tools/generate-module-index first.")
70
- return json.loads(path.read_text())
71
-
72
-
73
- def load_optional_json(path: Path) -> dict:
74
- if not path.is_file():
75
- return {}
76
- return json.loads(path.read_text())
77
-
78
-
79
- def relative_path(path: Path, root: Path) -> str:
80
- try:
81
- rel = path.absolute().relative_to(root.absolute())
82
- except ValueError:
83
- rel = path.resolve().relative_to(root.resolve())
84
- return "." if str(rel) == "." else rel.as_posix()
85
-
86
-
87
- def line_number_from_offset(source: str, offset: int) -> int:
88
- return source.count("\n", 0, offset) + 1
89
-
90
-
91
- def raw_string_end(source: str, index: int) -> int | None:
92
- for prefix in ("br", "rb", "r"):
93
- if not source.startswith(prefix, index):
94
- continue
95
- cursor = index + len(prefix)
96
- hashes = 0
97
- while cursor < len(source) and source[cursor] == "#":
98
- hashes += 1
99
- cursor += 1
100
- if cursor >= len(source) or source[cursor] != '"':
101
- continue
102
- delimiter = '"' + ("#" * hashes)
103
- end = source.find(delimiter, cursor + 1)
104
- return len(source) if end == -1 else end + len(delimiter)
105
- return None
106
-
107
-
108
- def quoted_literal_end(source: str, index: int) -> int:
109
- quote = source[index]
110
- cursor = index + 1
111
- escaped = False
112
- while cursor < len(source):
113
- char = source[cursor]
114
- if escaped:
115
- escaped = False
116
- elif char == "\\":
117
- escaped = True
118
- elif char == quote:
119
- return cursor + 1
120
- cursor += 1
121
- return len(source)
122
-
123
-
124
- def block_comment_end(source: str, index: int) -> int:
125
- cursor = index + 2
126
- depth = 1
127
- while cursor < len(source) and depth:
128
- if source.startswith("/*", cursor):
129
- depth += 1
130
- cursor += 2
131
- elif source.startswith("*/", cursor):
132
- depth -= 1
133
- cursor += 2
134
- else:
135
- cursor += 1
136
- return cursor
137
-
138
-
139
- def scan_tokens(source: str) -> list[Token]:
140
- tokens: list[Token] = []
141
- cursor = 0
142
- line = 1
143
-
144
- while cursor < len(source):
145
- char = source[cursor]
146
-
147
- if char.isspace():
148
- if char == "\n":
149
- line += 1
150
- cursor += 1
151
- continue
152
-
153
- if source.startswith("//", cursor):
154
- end = source.find("\n", cursor + 2)
155
- cursor = len(source) if end == -1 else end
156
- continue
157
-
158
- if source.startswith("/*", cursor):
159
- end = block_comment_end(source, cursor)
160
- line += source[cursor:end].count("\n")
161
- cursor = end
162
- continue
163
-
164
- raw_end = raw_string_end(source, cursor)
165
- if raw_end is not None:
166
- line += source[cursor:raw_end].count("\n")
167
- cursor = raw_end
168
- continue
169
-
170
- if char == '"' or (char == "b" and cursor + 1 < len(source) and source[cursor + 1] == '"'):
171
- start = cursor + 1 if char == "b" else cursor
172
- end = quoted_literal_end(source, start)
173
- line += source[cursor:end].count("\n")
174
- cursor = end
175
- continue
176
-
177
- if char == "'" and cursor + 1 < len(source) and not source[cursor + 1].isalpha():
178
- end = quoted_literal_end(source, cursor)
179
- line += source[cursor:end].count("\n")
180
- cursor = end
181
- continue
182
-
183
- match = IDENTIFIER.match(source, cursor)
184
- if match:
185
- tokens.append(Token(match.group(0), cursor, match.end(), line))
186
- cursor = match.end()
187
- continue
188
-
189
- tokens.append(Token(char, cursor, cursor + 1, line))
190
- cursor += 1
191
-
192
- return tokens
193
-
194
-
195
- def remove_comments_preserve_literals(text: str) -> str:
196
- output: list[str] = []
197
- cursor = 0
198
- while cursor < len(text):
199
- if text.startswith("//", cursor):
200
- end = text.find("\n", cursor + 2)
201
- if end == -1:
202
- break
203
- output.append("\n")
204
- cursor = end + 1
205
- continue
206
-
207
- if text.startswith("/*", cursor):
208
- end = block_comment_end(text, cursor)
209
- output.append("\n" * text[cursor:end].count("\n"))
210
- cursor = end
211
- continue
212
-
213
- raw_end = raw_string_end(text, cursor)
214
- if raw_end is not None:
215
- output.append(text[cursor:raw_end])
216
- cursor = raw_end
217
- continue
218
-
219
- char = text[cursor]
220
- if char == '"' or (char == "b" and cursor + 1 < len(text) and text[cursor + 1] == '"'):
221
- start = cursor + 1 if char == "b" else cursor
222
- end = quoted_literal_end(text, start)
223
- output.append(text[cursor:end])
224
- cursor = end
225
- continue
226
-
227
- if char == "'" and cursor + 1 < len(text) and not text[cursor + 1].isalpha():
228
- end = quoted_literal_end(text, cursor)
229
- output.append(text[cursor:end])
230
- cursor = end
231
- continue
232
-
233
- output.append(char)
234
- cursor += 1
235
-
236
- return "".join(output)
237
-
238
-
239
- def normalize_signature(signature: str) -> str:
240
- return " ".join(remove_comments_preserve_literals(signature).split())
241
-
242
-
243
- def signature_from_source(source: str, start: int) -> str:
244
- cursor = start
245
- depth_angle = 0
246
- depth_paren = 0
247
- depth_bracket = 0
248
-
249
- while cursor < len(source):
250
- if source.startswith("//", cursor):
251
- end = source.find("\n", cursor + 2)
252
- cursor = len(source) if end == -1 else end
253
- continue
254
-
255
- if source.startswith("/*", cursor):
256
- cursor = block_comment_end(source, cursor)
257
- continue
258
-
259
- raw_end = raw_string_end(source, cursor)
260
- if raw_end is not None:
261
- cursor = raw_end
262
- continue
263
-
264
- char = source[cursor]
265
- if char == '"' or (char == "b" and cursor + 1 < len(source) and source[cursor + 1] == '"'):
266
- start_quote = cursor + 1 if char == "b" else cursor
267
- cursor = quoted_literal_end(source, start_quote)
268
- continue
269
-
270
- if char == "'" and cursor + 1 < len(source) and not source[cursor + 1].isalpha():
271
- cursor = quoted_literal_end(source, cursor)
272
- continue
273
-
274
- if char == "<":
275
- depth_angle += 1
276
- elif char == ">" and depth_angle:
277
- depth_angle -= 1
278
- elif char == "(":
279
- depth_paren += 1
280
- elif char == ")" and depth_paren:
281
- depth_paren -= 1
282
- elif char == "[":
283
- depth_bracket += 1
284
- elif char == "]" and depth_bracket:
285
- depth_bracket -= 1
286
-
287
- at_top_level = depth_angle == 0 and depth_paren == 0 and depth_bracket == 0
288
- if at_top_level and char in "{;":
289
- return normalize_signature(source[start:cursor])
290
-
291
- cursor += 1
292
-
293
- return normalize_signature(source[start:])
294
-
295
-
296
- def child_module_candidates(current_file: Path, module_name: str) -> list[Path]:
297
- if current_file.name in {"lib.rs", "main.rs", "mod.rs"}:
298
- base = current_file.parent
299
- else:
300
- base = current_file.with_suffix("")
301
- return [
302
- base / f"{module_name}.rs",
303
- base / module_name / "mod.rs",
304
- ]
305
-
306
-
307
- def is_bare_pub(tokens: list[Token], index: int) -> bool:
308
- return tokens[index].value == "pub" and not (
309
- index + 1 < len(tokens) and tokens[index + 1].value == "("
310
- )
311
-
312
-
313
- def public_decl_at(tokens: list[Token], index: int) -> PublicDecl | None:
314
- if not is_bare_pub(tokens, index):
315
- return None
316
-
317
- cursor = index + 1
318
- while cursor < len(tokens) and tokens[cursor].value in ITEM_MODIFIERS:
319
- cursor += 1
320
-
321
- if cursor >= len(tokens):
322
- return None
323
-
324
- kind = tokens[cursor].value
325
- if kind == "mod":
326
- if cursor + 1 < len(tokens) and IDENTIFIER.fullmatch(tokens[cursor + 1].value):
327
- return PublicDecl("mod", tokens[cursor + 1].value, index, cursor, cursor + 1)
328
- return None
329
-
330
- if kind in ITEM_KINDS and cursor + 1 < len(tokens):
331
- name = tokens[cursor + 1].value
332
- if IDENTIFIER.fullmatch(name):
333
- return PublicDecl(ITEM_KINDS[kind], name, index, cursor, cursor + 1)
334
-
335
- return None
336
-
337
-
338
- def skip_brace_block(tokens: list[Token], index: int) -> int:
339
- depth = 0
340
- cursor = index
341
- while cursor < len(tokens):
342
- if tokens[cursor].value == "{":
343
- depth += 1
344
- elif tokens[cursor].value == "}":
345
- depth -= 1
346
- if depth == 0:
347
- return cursor + 1
348
- cursor += 1
349
- return cursor
350
-
351
-
352
- def skip_item(tokens: list[Token], index: int) -> int:
353
- cursor = index
354
- while cursor < len(tokens):
355
- if tokens[cursor].value == ";":
356
- return cursor + 1
357
- if tokens[cursor].value == "{":
358
- return skip_brace_block(tokens, cursor)
359
- cursor += 1
360
- return cursor
361
-
362
-
363
- def file_module_parts(source_file: str, module: dict) -> list[str]:
364
- module_path = module.get("path", ".")
365
- prefix = "src/" if module_path == "." else f"{module_path}/src/"
366
- if source_file.endswith("/src/lib.rs") or source_file.endswith("/src/main.rs"):
367
- return []
368
- relative = source_file.removeprefix(prefix).removesuffix(".rs")
369
- return [part for part in relative.split("/") if part and part != "mod"]
370
-
371
-
372
- def public_path(parts: list[str], item_name: str) -> str:
373
- return "::".join([*parts, item_name])
374
-
375
-
376
- def join_path(parts: list[str] | tuple[str, ...]) -> str:
377
- return "::".join(parts)
378
-
379
-
380
- def is_path_separator(tokens: list[Token], index: int) -> bool:
381
- return (
382
- index + 1 < len(tokens)
383
- and tokens[index].value == ":"
384
- and tokens[index + 1].value == ":"
385
- )
386
-
387
-
388
- def public_file_module_names(source: str) -> list[str]:
389
- tokens = scan_tokens(source)
390
- names: list[str] = []
391
-
392
- for index, token in enumerate(tokens):
393
- if token.value != "pub":
394
- continue
395
- decl = public_decl_at(tokens, index)
396
- if decl is None or decl.kind != "mod":
397
- continue
398
- if decl.name_index + 1 < len(tokens) and tokens[decl.name_index + 1].value == ";":
399
- names.append(decl.name)
400
-
401
- return names
402
-
403
-
404
- def parse_pub_use_exports(source: str) -> list[UseExport]:
405
- tokens = scan_tokens(source)
406
- exports: list[UseExport] = []
407
-
408
- def parse_group(index: int, prefix: list[str]) -> int:
409
- cursor = index + 1
410
- while cursor < len(tokens):
411
- if tokens[cursor].value == "}":
412
- return cursor + 1
413
- if tokens[cursor].value == ",":
414
- cursor += 1
415
- continue
416
- cursor = parse_tree(cursor, prefix)
417
- if cursor < len(tokens) and tokens[cursor].value == ",":
418
- cursor += 1
419
- return cursor
420
-
421
- def parse_tree(index: int, prefix: list[str]) -> int:
422
- cursor = index
423
- parts = list(prefix)
424
-
425
- while cursor < len(tokens):
426
- token = tokens[cursor]
427
-
428
- if token.value == "{":
429
- return parse_group(cursor, parts)
430
-
431
- if token.value == "*":
432
- exports.append(UseExport(tuple(parts), wildcard=True))
433
- return cursor + 1
434
-
435
- if not IDENTIFIER.fullmatch(token.value):
436
- return cursor + 1
437
-
438
- name = token.value
439
- if name == "self":
440
- cursor += 1
441
- if is_path_separator(tokens, cursor):
442
- cursor += 2
443
- continue
444
- return cursor
445
-
446
- parts.append(name)
447
- cursor += 1
448
-
449
- if is_path_separator(tokens, cursor):
450
- cursor += 2
451
- if cursor < len(tokens) and tokens[cursor].value in {"{", "*"}:
452
- continue
453
- continue
454
-
455
- alias = None
456
- if cursor + 1 < len(tokens) and tokens[cursor].value == "as":
457
- if IDENTIFIER.fullmatch(tokens[cursor + 1].value):
458
- alias = tokens[cursor + 1].value
459
- cursor += 2
460
-
461
- exports.append(UseExport(tuple(parts), alias=alias))
462
- return cursor
463
-
464
- return cursor
465
-
466
- index = 0
467
- while index < len(tokens):
468
- if (
469
- tokens[index].value == "pub"
470
- and is_bare_pub(tokens, index)
471
- and index + 1 < len(tokens)
472
- and tokens[index + 1].value == "use"
473
- ):
474
- cursor = parse_tree(index + 2, [])
475
- while cursor < len(tokens) and tokens[cursor].value != ";":
476
- cursor += 1
477
- index = cursor + 1
478
- continue
479
- index += 1
480
-
481
- return exports
482
-
483
-
484
- def resolve_use_path(parts: tuple[str, ...], current_parts: list[str], definitions: dict[str, dict]) -> tuple[str, ...] | None:
485
- if not parts:
486
- return None
487
-
488
- if parts[0] == "crate":
489
- return parts[1:]
490
-
491
- if parts[0] == "self":
492
- return tuple([*current_parts, *parts[1:]])
493
-
494
- if parts[0] == "super":
495
- cursor = 0
496
- base = list(current_parts)
497
- while cursor < len(parts) and parts[cursor] == "super":
498
- if base:
499
- base.pop()
500
- cursor += 1
501
- return tuple([*base, *parts[cursor:]])
502
-
503
- candidate = parts
504
- if join_path(candidate) in definitions or any(path.startswith(f"{join_path(candidate)}::") for path in definitions):
505
- return candidate
506
-
507
- relative = tuple([*current_parts, *parts])
508
- if join_path(relative) in definitions or any(path.startswith(f"{join_path(relative)}::") for path in definitions):
509
- return relative
510
-
511
- return None
512
-
513
-
514
- def reexported_items_from_source(source: str, source_file: str, module: dict, definitions: dict[str, dict]) -> list[dict]:
515
- items: list[dict] = []
516
- current_parts = file_module_parts(source_file, module)
517
-
518
- for export in parse_pub_use_exports(source):
519
- target_parts = resolve_use_path(export.path, current_parts, definitions)
520
- if target_parts is None:
521
- continue
522
-
523
- target_path = join_path(target_parts)
524
- public_base = current_parts
525
-
526
- if export.wildcard:
527
- prefix = f"{target_path}::"
528
- for definition_path, definition in definitions.items():
529
- if not definition_path.startswith(prefix):
530
- continue
531
- alias_path = definition_path.removeprefix(prefix)
532
- if "::" in alias_path:
533
- continue
534
- items.append({**definition, "path": public_path(public_base, definition["name"])})
535
- continue
536
-
537
- definition = definitions.get(target_path)
538
- if definition is None:
539
- continue
540
- name = export.alias or definition["name"]
541
- items.append({**definition, "path": public_path(public_base, name), "name": name})
542
-
543
- return items
544
-
545
-
546
- def reachable_public_source_files(root: Path, module: dict) -> list[str]:
547
- indexed_sources = {
548
- (root / source_file).resolve(): source_file
549
- for source_file in module.get("files", {}).get("source", [])
550
- }
551
- module_path = module.get("path", ".")
552
- module_root = root if module_path == "." else root / module_path
553
- crate_roots = [
554
- module_root / "src/lib.rs",
555
- module_root / "src/main.rs",
556
- ]
557
-
558
- reachable: list[str] = []
559
- visited: set[Path] = set()
560
-
561
- def visit(path: Path) -> None:
562
- resolved = path.resolve()
563
- if resolved in visited or resolved not in indexed_sources:
564
- return
565
- visited.add(resolved)
566
- reachable.append(indexed_sources[resolved])
567
-
568
- for module_name in public_file_module_names(path.read_text()):
569
- for candidate in child_module_candidates(path, module_name):
570
- if candidate.resolve() in indexed_sources:
571
- visit(candidate)
572
- break
573
-
574
- for crate_root in crate_roots:
575
- if crate_root.resolve() in indexed_sources:
576
- visit(crate_root)
577
- break
578
-
579
- return reachable
580
-
581
-
582
- def extract_items_from_source(source: str, source_file: str, module: dict) -> list[dict]:
583
- tokens = scan_tokens(source)
584
- items: list[dict] = []
585
- base_parts = file_module_parts(source_file, module)
586
-
587
- def parse_scope(start_index: int, path_parts: list[str], stop_at_brace: bool) -> int:
588
- cursor = start_index
589
- while cursor < len(tokens):
590
- token = tokens[cursor]
591
-
592
- if stop_at_brace and token.value == "}":
593
- return cursor + 1
594
-
595
- if token.value == "pub":
596
- decl = public_decl_at(tokens, cursor)
597
- if decl is not None:
598
- after_name = decl.name_index + 1
599
- if decl.kind == "mod":
600
- if after_name < len(tokens) and tokens[after_name].value == "{":
601
- cursor = parse_scope(after_name + 1, [*path_parts, decl.name], True)
602
- continue
603
- cursor = skip_item(tokens, cursor)
604
- continue
605
-
606
- items.append(
607
- {
608
- "path": public_path(path_parts, decl.name),
609
- "kind": decl.kind,
610
- "name": decl.name,
611
- "source": {
612
- "path": source_file,
613
- "line": tokens[decl.pub_index].line,
614
- },
615
- "signature": signature_from_source(source, tokens[decl.pub_index].start),
616
- }
617
- )
618
- cursor = skip_item(tokens, cursor)
619
- continue
620
-
621
- if token.value in BLOCK_ITEM_KEYWORDS:
622
- cursor = skip_item(tokens, cursor)
623
- continue
624
-
625
- if token.value == "{":
626
- cursor = skip_brace_block(tokens, cursor)
627
- continue
628
-
629
- cursor += 1
630
-
631
- return cursor
632
-
633
- parse_scope(0, base_parts, False)
634
- return items
635
-
636
-
637
- def extract_public_items(root: Path, module: dict) -> list[dict]:
638
- definitions: dict[str, dict] = {}
639
- for source_file in module.get("files", {}).get("source", []):
640
- path = root / source_file
641
- if not path.is_file():
642
- raise SystemExit(f"Missing source file from module index: {source_file}")
643
- for item in extract_items_from_source(path.read_text(), source_file, module):
644
- definitions.setdefault(item["path"], item)
645
-
646
- items: list[dict] = []
647
-
648
- for source_file in reachable_public_source_files(root, module):
649
- path = root / source_file
650
- if not path.is_file():
651
- raise SystemExit(f"Missing source file from module index: {source_file}")
652
- source = path.read_text()
653
- items.extend(extract_items_from_source(source, source_file, module))
654
- items.extend(reexported_items_from_source(source, source_file, module, definitions))
655
-
656
- seen: set[tuple[str, str, str, int]] = set()
657
- unique_items: list[dict] = []
658
- for item in items:
659
- key = (item["path"], item["kind"], item["source"]["path"], item["source"]["line"])
660
- if key in seen:
661
- continue
662
- seen.add(key)
663
- unique_items.append(item)
664
-
665
- return unique_items
666
-
667
-
668
- def remove_ts_comments_preserve_literals(source: str) -> str:
669
- output: list[str] = []
670
- cursor = 0
671
- quote: str | None = None
672
- escaped = False
673
-
674
- while cursor < len(source):
675
- char = source[cursor]
676
-
677
- if quote is not None:
678
- output.append(char)
679
- if escaped:
680
- escaped = False
681
- elif char == "\\":
682
- escaped = True
683
- elif char == quote:
684
- quote = None
685
- cursor += 1
686
- continue
687
-
688
- if source.startswith("//", cursor):
689
- end = source.find("\n", cursor + 2)
690
- if end == -1:
691
- output.append(" " * (len(source) - cursor))
692
- break
693
- output.append(" " * (end - cursor))
694
- output.append("\n")
695
- cursor = end + 1
696
- continue
697
-
698
- if source.startswith("/*", cursor):
699
- end = source.find("*/", cursor + 2)
700
- end = len(source) if end == -1 else end + 2
701
- chunk = source[cursor:end]
702
- output.append("".join("\n" if item == "\n" else " " for item in chunk))
703
- cursor = end
704
- continue
705
-
706
- if char in {"'", '"', "`"}:
707
- quote = char
708
- escaped = False
709
- output.append(char)
710
- cursor += 1
711
- continue
712
-
713
- output.append(char)
714
- cursor += 1
715
-
716
- return "".join(output)
717
-
718
-
719
- def ts_signature_from_source(source: str, start: int) -> str:
720
- cursor = start
721
- depth_angle = 0
722
- depth_paren = 0
723
- depth_bracket = 0
724
-
725
- while cursor < len(source):
726
- char = source[cursor]
727
- if char in {"'", '"', "`"}:
728
- cursor = quoted_literal_end(source, cursor) if char != "`" else quoted_literal_end(source.replace("`", '"'), cursor)
729
- continue
730
-
731
- if char == "<":
732
- depth_angle += 1
733
- elif char == ">" and depth_angle:
734
- depth_angle -= 1
735
- elif char == "(":
736
- depth_paren += 1
737
- elif char == ")" and depth_paren:
738
- depth_paren -= 1
739
- elif char == "[":
740
- depth_bracket += 1
741
- elif char == "]" and depth_bracket:
742
- depth_bracket -= 1
743
-
744
- at_top_level = depth_angle == 0 and depth_paren == 0 and depth_bracket == 0
745
- if at_top_level and char in "{;":
746
- return " ".join(source[start:cursor].split())
747
- cursor += 1
748
-
749
- return " ".join(source[start:].split())
750
-
751
-
752
- def ts_item_kind(match: re.Match) -> tuple[str, str] | None:
753
- for kind in ("function", "class", "interface", "type", "enum", "variable"):
754
- name = match.group(kind)
755
- if name:
756
- return ("const" if kind == "variable" else kind, name)
757
- return None
758
-
759
-
760
- def extract_ts_exported_declarations(source: str, source_file: str) -> list[dict]:
761
- sanitized = remove_ts_comments_preserve_literals(source)
762
- items: list[dict] = []
763
-
764
- for match in TS_EXPORT_DECLARATION.finditer(sanitized):
765
- kind_and_name = ts_item_kind(match)
766
- if kind_and_name is None:
767
- continue
768
- kind, name = kind_and_name
769
- items.append(
770
- {
771
- "path": name,
772
- "kind": kind,
773
- "name": name,
774
- "source": {
775
- "path": source_file,
776
- "line": line_number_from_offset(sanitized, match.start()),
777
- },
778
- "signature": ts_signature_from_source(source, match.start()),
779
- }
780
- )
781
-
782
- return items
783
-
784
-
785
- def exported_names_from_group(group: str) -> list[tuple[str, str]]:
786
- names: list[tuple[str, str]] = []
787
- body = group.strip()[1:-1]
788
- for raw_part in body.split(","):
789
- part = raw_part.strip()
790
- if not part:
791
- continue
792
- if part.startswith("type "):
793
- part = part.removeprefix("type ").strip()
794
- pieces = [piece.strip() for piece in re.split(r"\s+as\s+", part, maxsplit=1)]
795
- source_name = pieces[0]
796
- public_name = pieces[1] if len(pieces) == 2 else source_name
797
- if source_name and source_name != "default":
798
- names.append((source_name, public_name))
799
- return names
800
-
801
-
802
- def resolve_ts_specifier(current_file: Path, specifier: str, indexed_sources: dict[Path, str]) -> str | None:
803
- if not specifier.startswith("."):
804
- return None
805
-
806
- base = (current_file.parent / specifier).resolve()
807
- candidates: list[Path] = []
808
-
809
- if base.suffix:
810
- candidates.append(base)
811
- for extension in TS_SOURCE_EXTENSIONS:
812
- candidates.append(base.with_suffix(extension))
813
- else:
814
- for extension in TS_SOURCE_EXTENSIONS:
815
- candidates.append(base.with_suffix(extension))
816
- for extension in TS_SOURCE_EXTENSIONS:
817
- candidates.append(base / f"index{extension}")
818
-
819
- for candidate in candidates:
820
- source_file = indexed_sources.get(candidate.resolve())
821
- if source_file is not None:
822
- return source_file
823
-
824
- return None
825
-
826
-
827
- def package_export_targets(value) -> list[str]:
828
- if isinstance(value, str):
829
- return [value]
830
- if isinstance(value, list):
831
- targets: list[str] = []
832
- for item in value:
833
- targets.extend(package_export_targets(item))
834
- return targets
835
- if isinstance(value, dict):
836
- targets: list[str] = []
837
- for key, item in value.items():
838
- if key in {"types", "import", "require", "default", "module"} or key.startswith("."):
839
- targets.extend(package_export_targets(item))
840
- return targets
841
- return []
842
-
843
-
844
- def ts_entrypoint_files(root: Path, module: dict) -> list[str]:
845
- indexed_sources = set(module.get("files", {}).get("source", []))
846
- module_path = module.get("path", ".")
847
- module_root = root if module_path == "." else root / module_path
848
- manifest = load_optional_json(root / module.get("manifest", ""))
849
- targets: list[str] = []
850
-
851
- for key in ("exports", "main", "module", "types"):
852
- targets.extend(package_export_targets(manifest.get(key)))
853
-
854
- candidates: list[str] = []
855
- for target in targets:
856
- if not isinstance(target, str):
857
- continue
858
- target_path = (module_root / target).resolve()
859
- for candidate in [target_path, *[target_path.with_suffix(ext) for ext in TS_SOURCE_EXTENSIONS]]:
860
- rel = relative_path(candidate, root)
861
- if rel in indexed_sources:
862
- candidates.append(rel)
863
-
864
- if not candidates:
865
- for name in ("index", "main", "app"):
866
- for extension in TS_SOURCE_EXTENSIONS:
867
- rel = relative_path(module_root / "src" / f"{name}{extension}", root)
868
- if rel in indexed_sources:
869
- candidates.append(rel)
870
-
871
- if not candidates and module_path.startswith("apps/"):
872
- candidates.extend(module.get("files", {}).get("source", []))
873
-
874
- return sorted(dict.fromkeys(candidates))
875
-
876
-
877
- def collect_ts_public_items(
878
- root: Path,
879
- source_file: str,
880
- module: dict,
881
- direct_definitions: dict[str, dict[str, dict]],
882
- indexed_sources: dict[Path, str],
883
- visited: set[str],
884
- ) -> list[dict]:
885
- if source_file in visited:
886
- return []
887
- visited.add(source_file)
888
-
889
- path = root / source_file
890
- source = path.read_text()
891
- sanitized = remove_ts_comments_preserve_literals(source)
892
- items = list(direct_definitions.get(source_file, {}).values())
893
-
894
- for match in TS_REEXPORT.finditer(sanitized):
895
- target_file = resolve_ts_specifier(path, match.group("specifier"), indexed_sources)
896
- if target_file is None:
897
- continue
898
-
899
- form = match.group("form")
900
- if form == "*":
901
- items.extend(collect_ts_public_items(root, target_file, module, direct_definitions, indexed_sources, visited))
902
- continue
903
-
904
- target_definitions = direct_definitions.get(target_file, {})
905
- for source_name, public_name in exported_names_from_group(form):
906
- definition = target_definitions.get(source_name)
907
- if definition is None:
908
- items.append(
909
- {
910
- "path": public_name,
911
- "kind": "re-export",
912
- "name": public_name,
913
- "source": {
914
- "path": source_file,
915
- "line": line_number_from_offset(sanitized, match.start()),
916
- },
917
- }
918
- )
919
- continue
920
- items.append({**definition, "path": public_name, "name": public_name})
921
-
922
- return items
923
-
924
-
925
- def extract_ts_routes(source: str, source_file: str) -> list[dict]:
926
- sanitized = remove_ts_comments_preserve_literals(source)
927
- items: list[dict] = []
928
-
929
- for match in TS_ROUTE_CALL.finditer(sanitized):
930
- method = match.group("method").upper()
931
- route_path = match.group("path")
932
- name = f"{method} {route_path}"
933
- items.append(
934
- {
935
- "path": name,
936
- "kind": "route",
937
- "name": name,
938
- "source": {
939
- "path": source_file,
940
- "line": line_number_from_offset(sanitized, match.start()),
941
- },
942
- }
943
- )
944
-
945
- for match in TS_ROUTE_OBJECT.finditer(sanitized):
946
- body = match.group("body")
947
- method_match = TS_ROUTE_METHOD.search(body)
948
- path_match = TS_ROUTE_URL.search(body)
949
- if method_match is None or path_match is None:
950
- continue
951
- name = f"{method_match.group('method').upper()} {path_match.group('path')}"
952
- items.append(
953
- {
954
- "path": name,
955
- "kind": "route",
956
- "name": name,
957
- "source": {
958
- "path": source_file,
959
- "line": line_number_from_offset(sanitized, match.start()),
960
- },
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // Generate .ai/generated/public-surface.json from Rust or TypeScript source files.
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+
8
+ const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/y;
9
+ const IDENTIFIER_FULL = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
+ const ITEM_KINDS = { fn: "fn", struct: "struct", enum: "enum", trait: "trait" };
11
+ const ITEM_MODIFIERS = new Set(["async", "const", "unsafe", "extern"]);
12
+ const BLOCK_ITEM_KEYWORDS = new Set([
13
+ "fn",
14
+ "struct",
15
+ "enum",
16
+ "trait",
17
+ "impl",
18
+ "mod",
19
+ "const",
20
+ "static",
21
+ "type",
22
+ "union",
23
+ "macro_rules"
24
+ ]);
25
+ const TS_SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
26
+ const TS_ITEM_KINDS = ["function", "class", "interface", "type", "enum", "variable"];
27
+ const TS_EXPORT_DECLARATION = /\bexport\s+(?:default\s+)?(?:declare\s+)?(?:(?:async\s+)?function\s+(?<function>[A-Za-z_$][A-Za-z0-9_$]*)|class\s+(?<class>[A-Za-z_$][A-Za-z0-9_$]*)|interface\s+(?<interface>[A-Za-z_$][A-Za-z0-9_$]*)|type\s+(?<type>[A-Za-z_$][A-Za-z0-9_$]*)|enum\s+(?<enum>[A-Za-z_$][A-Za-z0-9_$]*)|(?:const|let|var)\s+(?<variable>[A-Za-z_$][A-Za-z0-9_$]*))/g;
28
+ const TS_REEXPORT = /\bexport\s+(?:type\s+)?(?<form>\*|\{[^}]*\})\s+from\s+(?<quote>["'`])(?<specifier>[^"'`]+)\k<quote>/g;
29
+ const TS_ROUTE_CALL = /\.\s*(?<method>get|post|put|patch|delete|options|head)\s*(?:<(?:(?!>\s*\()[\s\S])*>\s*)?\(\s*(?<quote>["'`])(?<path>[^"'`]+)\k<quote>/gi;
30
+ const TS_ROUTE_OBJECT = /\.route\s*\(\s*\{(?<body>.*?)\}\s*\)/gis;
31
+ const TS_ROUTE_METHOD = /\bmethod\s*:\s*(?<quote>["'`])(?<method>[A-Za-z]+)\k<quote>/i;
32
+ const TS_ROUTE_URL = /\b(?:url|path)\s*:\s*(?<quote>["'`])(?<path>[^"'`]+)\k<quote>/i;
33
+ const USAGE = "usage: generate-public-surface [-h] [--check] [--print] [--output OUTPUT]";
34
+
35
+ class ToolExit extends Error {}
36
+
37
+ function fail(message) {
38
+ throw new ToolExit(message);
39
+ }
40
+
41
+ function isObject(value) {
42
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+
45
+ function get(container, key, fallback) {
46
+ return isObject(container) && key in container ? container[key] : fallback;
47
+ }
48
+
49
+ function isFile(target) {
50
+ try {
51
+ return fs.statSync(target).isFile();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function readText(target) {
58
+ return fs.readFileSync(target, "utf8").replace(/\r\n?/g, "\n");
59
+ }
60
+
61
+ function resolvePath(target) {
62
+ const absolute = path.resolve(target);
63
+ try {
64
+ return fs.realpathSync(absolute);
65
+ } catch {
66
+ const parent = path.dirname(absolute);
67
+ return parent === absolute ? absolute : path.join(resolvePath(parent), path.basename(absolute));
68
+ }
69
+ }
70
+
71
+ function isInside(relative) {
72
+ return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
73
+ }
74
+
75
+ function relativePath(target, root) {
76
+ let relative = path.relative(path.resolve(root), path.resolve(target));
77
+ if (!isInside(relative)) {
78
+ relative = path.relative(resolvePath(root), resolvePath(target));
79
+ if (!isInside(relative)) {
80
+ fail("'" + target + "' is not in the subpath of '" + root + "'");
81
+ }
82
+ }
83
+ return relative === "" ? "." : relative.split(path.sep).join("/");
84
+ }
85
+
86
+ function pathSuffix(target) {
87
+ const name = path.basename(target);
88
+ const index = name.lastIndexOf(".");
89
+ return index > 0 && index < name.length - 1 ? name.slice(index) : "";
90
+ }
91
+
92
+ function withSuffix(target, suffix) {
93
+ const current = pathSuffix(target);
94
+ return (current ? target.slice(0, -current.length) : target) + suffix;
95
+ }
96
+
97
+ function isSpace(character) {
98
+ return /\s/.test(character);
99
+ }
100
+
101
+ function isAlpha(character) {
102
+ return /\p{L}/u.test(character);
103
+ }
104
+
105
+ function collapseWhitespace(text) {
106
+ return text.split(/\s+/).filter(Boolean).join(" ");
107
+ }
108
+
109
+ function uniqueItems(items) {
110
+ const seen = new Set();
111
+ return items.filter((item) => {
112
+ const key = JSON.stringify([item.path, item.kind, item.source.path, item.source.line]);
113
+ if (seen.has(key)) {
114
+ return false;
115
+ }
116
+ seen.add(key);
117
+ return true;
118
+ });
119
+ }
120
+
121
+ function loadModuleIndex(root) {
122
+ const target = path.join(root, ".ai/generated/module-index.json");
123
+ if (!isFile(target)) {
124
+ fail("Missing .ai/generated/module-index.json; run .ai/tools/generate-module-index first.");
125
+ }
126
+ return JSON.parse(readText(target));
127
+ }
128
+
129
+ function loadOptionalJson(target) {
130
+ if (!isFile(target)) {
131
+ return {};
132
+ }
133
+ return JSON.parse(readText(target));
134
+ }
135
+
136
+ function lineNumberFromOffset(source, offset) {
137
+ let count = 1;
138
+ for (let index = source.indexOf("\n"); index >= 0 && index < offset; index = source.indexOf("\n", index + 1)) {
139
+ count += 1;
140
+ }
141
+ return count;
142
+ }
143
+
144
+ function countNewlines(text) {
145
+ return text.split("\n").length - 1;
146
+ }
147
+
148
+ function rawStringEnd(source, index) {
149
+ for (const prefix of ["br", "rb", "r"]) {
150
+ if (!source.startsWith(prefix, index)) {
151
+ continue;
152
+ }
153
+ let cursor = index + prefix.length;
154
+ let hashes = 0;
155
+ while (cursor < source.length && source[cursor] === "#") {
156
+ hashes += 1;
157
+ cursor += 1;
158
+ }
159
+ if (cursor >= source.length || source[cursor] !== "\"") {
160
+ continue;
161
+ }
162
+ const delimiter = "\"" + "#".repeat(hashes);
163
+ const end = source.indexOf(delimiter, cursor + 1);
164
+ return end === -1 ? source.length : end + delimiter.length;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ function quotedLiteralEnd(source, index) {
170
+ const quote = source[index];
171
+ let escaped = false;
172
+ for (let cursor = index + 1; cursor < source.length; cursor += 1) {
173
+ const character = source[cursor];
174
+ if (escaped) {
175
+ escaped = false;
176
+ } else if (character === "\\") {
177
+ escaped = true;
178
+ } else if (character === quote) {
179
+ return cursor + 1;
180
+ }
181
+ }
182
+ return source.length;
183
+ }
184
+
185
+ function blockCommentEnd(source, index) {
186
+ let cursor = index + 2;
187
+ let depth = 1;
188
+ while (cursor < source.length && depth) {
189
+ if (source.startsWith("/*", cursor)) {
190
+ depth += 1;
191
+ cursor += 2;
192
+ } else if (source.startsWith("*/", cursor)) {
193
+ depth -= 1;
194
+ cursor += 2;
195
+ } else {
196
+ cursor += 1;
197
+ }
198
+ }
199
+ return cursor;
200
+ }
201
+
202
+ function isByteStringStart(source, cursor) {
203
+ return source[cursor] === "\"" || (source[cursor] === "b" && cursor + 1 < source.length && source[cursor + 1] === "\"");
204
+ }
205
+
206
+ function isCharLiteralStart(source, cursor) {
207
+ return source[cursor] === "'" && cursor + 1 < source.length && !isAlpha(source[cursor + 1]);
208
+ }
209
+
210
+ function scanTokens(source) {
211
+ const tokens = [];
212
+ let cursor = 0;
213
+ let line = 1;
214
+
215
+ while (cursor < source.length) {
216
+ const character = source[cursor];
217
+
218
+ if (isSpace(character)) {
219
+ if (character === "\n") {
220
+ line += 1;
221
+ }
222
+ cursor += 1;
223
+ continue;
224
+ }
225
+
226
+ if (source.startsWith("//", cursor)) {
227
+ const end = source.indexOf("\n", cursor + 2);
228
+ cursor = end === -1 ? source.length : end;
229
+ continue;
230
+ }
231
+
232
+ if (source.startsWith("/*", cursor)) {
233
+ const end = blockCommentEnd(source, cursor);
234
+ line += countNewlines(source.slice(cursor, end));
235
+ cursor = end;
236
+ continue;
237
+ }
238
+
239
+ const rawEnd = rawStringEnd(source, cursor);
240
+ if (rawEnd !== null) {
241
+ line += countNewlines(source.slice(cursor, rawEnd));
242
+ cursor = rawEnd;
243
+ continue;
244
+ }
245
+
246
+ if (isByteStringStart(source, cursor)) {
247
+ const end = quotedLiteralEnd(source, character === "b" ? cursor + 1 : cursor);
248
+ line += countNewlines(source.slice(cursor, end));
249
+ cursor = end;
250
+ continue;
251
+ }
252
+
253
+ if (isCharLiteralStart(source, cursor)) {
254
+ const end = quotedLiteralEnd(source, cursor);
255
+ line += countNewlines(source.slice(cursor, end));
256
+ cursor = end;
257
+ continue;
258
+ }
259
+
260
+ IDENTIFIER.lastIndex = cursor;
261
+ const match = IDENTIFIER.exec(source);
262
+ if (match) {
263
+ tokens.push({ value: match[0], start: cursor, end: IDENTIFIER.lastIndex, line });
264
+ cursor = IDENTIFIER.lastIndex;
265
+ continue;
266
+ }
267
+
268
+ tokens.push({ value: character, start: cursor, end: cursor + 1, line });
269
+ cursor += 1;
270
+ }
271
+
272
+ return tokens;
273
+ }
274
+
275
+ function removeCommentsPreserveLiterals(text) {
276
+ const output = [];
277
+ let cursor = 0;
278
+ while (cursor < text.length) {
279
+ if (text.startsWith("//", cursor)) {
280
+ const end = text.indexOf("\n", cursor + 2);
281
+ if (end === -1) {
282
+ break;
283
+ }
284
+ output.push("\n");
285
+ cursor = end + 1;
286
+ continue;
287
+ }
288
+
289
+ if (text.startsWith("/*", cursor)) {
290
+ const end = blockCommentEnd(text, cursor);
291
+ output.push("\n".repeat(countNewlines(text.slice(cursor, end))));
292
+ cursor = end;
293
+ continue;
294
+ }
295
+
296
+ const rawEnd = rawStringEnd(text, cursor);
297
+ if (rawEnd !== null) {
298
+ output.push(text.slice(cursor, rawEnd));
299
+ cursor = rawEnd;
300
+ continue;
301
+ }
302
+
303
+ const character = text[cursor];
304
+ if (isByteStringStart(text, cursor)) {
305
+ const end = quotedLiteralEnd(text, character === "b" ? cursor + 1 : cursor);
306
+ output.push(text.slice(cursor, end));
307
+ cursor = end;
308
+ continue;
309
+ }
310
+
311
+ if (isCharLiteralStart(text, cursor)) {
312
+ const end = quotedLiteralEnd(text, cursor);
313
+ output.push(text.slice(cursor, end));
314
+ cursor = end;
315
+ continue;
316
+ }
317
+
318
+ output.push(character);
319
+ cursor += 1;
320
+ }
321
+
322
+ return output.join("");
323
+ }
324
+
325
+ function normalizeSignature(signature) {
326
+ return collapseWhitespace(removeCommentsPreserveLiterals(signature));
327
+ }
328
+
329
+ function trackDepth(depth, character) {
330
+ if (character === "<") {
331
+ depth.angle += 1;
332
+ } else if (character === ">" && depth.angle) {
333
+ depth.angle -= 1;
334
+ } else if (character === "(") {
335
+ depth.paren += 1;
336
+ } else if (character === ")" && depth.paren) {
337
+ depth.paren -= 1;
338
+ } else if (character === "[") {
339
+ depth.bracket += 1;
340
+ } else if (character === "]" && depth.bracket) {
341
+ depth.bracket -= 1;
342
+ }
343
+ return depth.angle === 0 && depth.paren === 0 && depth.bracket === 0;
344
+ }
345
+
346
+ function signatureFromSource(source, start) {
347
+ let cursor = start;
348
+ const depth = { angle: 0, paren: 0, bracket: 0 };
349
+
350
+ while (cursor < source.length) {
351
+ if (source.startsWith("//", cursor)) {
352
+ const end = source.indexOf("\n", cursor + 2);
353
+ cursor = end === -1 ? source.length : end;
354
+ continue;
355
+ }
356
+
357
+ if (source.startsWith("/*", cursor)) {
358
+ cursor = blockCommentEnd(source, cursor);
359
+ continue;
360
+ }
361
+
362
+ const rawEnd = rawStringEnd(source, cursor);
363
+ if (rawEnd !== null) {
364
+ cursor = rawEnd;
365
+ continue;
366
+ }
367
+
368
+ const character = source[cursor];
369
+ if (isByteStringStart(source, cursor)) {
370
+ cursor = quotedLiteralEnd(source, character === "b" ? cursor + 1 : cursor);
371
+ continue;
372
+ }
373
+
374
+ if (isCharLiteralStart(source, cursor)) {
375
+ cursor = quotedLiteralEnd(source, cursor);
376
+ continue;
377
+ }
378
+
379
+ if (trackDepth(depth, character) && (character === "{" || character === ";")) {
380
+ return normalizeSignature(source.slice(start, cursor));
381
+ }
382
+
383
+ cursor += 1;
384
+ }
385
+
386
+ return normalizeSignature(source.slice(start));
387
+ }
388
+
389
+ function childModuleCandidates(currentFile, moduleName) {
390
+ const base = ["lib.rs", "main.rs", "mod.rs"].includes(path.basename(currentFile))
391
+ ? path.dirname(currentFile)
392
+ : withSuffix(currentFile, "");
393
+ return [path.join(base, moduleName + ".rs"), path.join(base, moduleName, "mod.rs")];
394
+ }
395
+
396
+ function isBarePub(tokens, index) {
397
+ return tokens[index].value === "pub" && !(index + 1 < tokens.length && tokens[index + 1].value === "(");
398
+ }
399
+
400
+ function publicDeclAt(tokens, index) {
401
+ if (!isBarePub(tokens, index)) {
402
+ return null;
403
+ }
404
+
405
+ let cursor = index + 1;
406
+ while (cursor < tokens.length && ITEM_MODIFIERS.has(tokens[cursor].value)) {
407
+ cursor += 1;
408
+ }
409
+
410
+ if (cursor >= tokens.length) {
411
+ return null;
412
+ }
413
+
414
+ const kind = tokens[cursor].value;
415
+ if (kind === "mod") {
416
+ if (cursor + 1 < tokens.length && IDENTIFIER_FULL.test(tokens[cursor + 1].value)) {
417
+ return { kind: "mod", name: tokens[cursor + 1].value, pubIndex: index, keywordIndex: cursor, nameIndex: cursor + 1 };
418
+ }
419
+ return null;
420
+ }
421
+
422
+ if (Object.hasOwn(ITEM_KINDS, kind) && cursor + 1 < tokens.length) {
423
+ const name = tokens[cursor + 1].value;
424
+ if (IDENTIFIER_FULL.test(name)) {
425
+ return { kind: ITEM_KINDS[kind], name, pubIndex: index, keywordIndex: cursor, nameIndex: cursor + 1 };
426
+ }
427
+ }
428
+
429
+ return null;
430
+ }
431
+
432
+ function skipBraceBlock(tokens, index) {
433
+ let depth = 0;
434
+ let cursor = index;
435
+ while (cursor < tokens.length) {
436
+ if (tokens[cursor].value === "{") {
437
+ depth += 1;
438
+ } else if (tokens[cursor].value === "}") {
439
+ depth -= 1;
440
+ if (depth === 0) {
441
+ return cursor + 1;
442
+ }
443
+ }
444
+ cursor += 1;
445
+ }
446
+ return cursor;
447
+ }
448
+
449
+ function skipItem(tokens, index) {
450
+ for (let cursor = index; cursor < tokens.length; cursor += 1) {
451
+ if (tokens[cursor].value === ";") {
452
+ return cursor + 1;
453
+ }
454
+ if (tokens[cursor].value === "{") {
455
+ return skipBraceBlock(tokens, cursor);
456
+ }
457
+ }
458
+ return tokens.length;
459
+ }
460
+
461
+ function fileModuleParts(sourceFile, module) {
462
+ const modulePath = get(module, "path", ".");
463
+ const prefix = modulePath === "." ? "src/" : modulePath + "/src/";
464
+ if (sourceFile.endsWith("/src/lib.rs") || sourceFile.endsWith("/src/main.rs")) {
465
+ return [];
466
+ }
467
+ let relative = sourceFile.startsWith(prefix) ? sourceFile.slice(prefix.length) : sourceFile;
468
+ relative = relative.endsWith(".rs") ? relative.slice(0, -3) : relative;
469
+ return relative.split("/").filter((part) => part && part !== "mod");
470
+ }
471
+
472
+ function joinPath(parts) {
473
+ return parts.join("::");
474
+ }
475
+
476
+ function publicPath(parts, itemName) {
477
+ return joinPath([...parts, itemName]);
478
+ }
479
+
480
+ function isPathSeparator(tokens, index) {
481
+ return index + 1 < tokens.length && tokens[index].value === ":" && tokens[index + 1].value === ":";
482
+ }
483
+
484
+ function publicFileModuleNames(source) {
485
+ const tokens = scanTokens(source);
486
+ const names = [];
487
+ tokens.forEach((token, index) => {
488
+ if (token.value !== "pub") {
489
+ return;
490
+ }
491
+ const decl = publicDeclAt(tokens, index);
492
+ if (decl === null || decl.kind !== "mod") {
493
+ return;
494
+ }
495
+ if (decl.nameIndex + 1 < tokens.length && tokens[decl.nameIndex + 1].value === ";") {
496
+ names.push(decl.name);
497
+ }
498
+ });
499
+ return names;
500
+ }
501
+
502
+ function parsePubUseExports(source) {
503
+ const tokens = scanTokens(source);
504
+ const exports = [];
505
+
506
+ function parseGroup(index, prefix) {
507
+ let cursor = index + 1;
508
+ while (cursor < tokens.length) {
509
+ if (tokens[cursor].value === "}") {
510
+ return cursor + 1;
511
+ }
512
+ if (tokens[cursor].value === ",") {
513
+ cursor += 1;
514
+ continue;
515
+ }
516
+ cursor = parseTree(cursor, prefix);
517
+ if (cursor < tokens.length && tokens[cursor].value === ",") {
518
+ cursor += 1;
519
+ }
520
+ }
521
+ return cursor;
522
+ }
523
+
524
+ function parseTree(index, prefix) {
525
+ let cursor = index;
526
+ const parts = [...prefix];
527
+
528
+ while (cursor < tokens.length) {
529
+ const token = tokens[cursor];
530
+
531
+ if (token.value === "{") {
532
+ return parseGroup(cursor, parts);
533
+ }
534
+
535
+ if (token.value === "*") {
536
+ exports.push({ path: [...parts], alias: null, wildcard: true });
537
+ return cursor + 1;
538
+ }
539
+
540
+ if (!IDENTIFIER_FULL.test(token.value)) {
541
+ return cursor + 1;
542
+ }
543
+
544
+ const name = token.value;
545
+ if (name === "self") {
546
+ cursor += 1;
547
+ if (isPathSeparator(tokens, cursor)) {
548
+ cursor += 2;
549
+ continue;
550
+ }
551
+ return cursor;
552
+ }
553
+
554
+ parts.push(name);
555
+ cursor += 1;
556
+
557
+ if (isPathSeparator(tokens, cursor)) {
558
+ cursor += 2;
559
+ continue;
560
+ }
561
+
562
+ let alias = null;
563
+ if (cursor + 1 < tokens.length && tokens[cursor].value === "as") {
564
+ if (IDENTIFIER_FULL.test(tokens[cursor + 1].value)) {
565
+ alias = tokens[cursor + 1].value;
566
+ cursor += 2;
567
+ }
568
+ }
569
+
570
+ exports.push({ path: [...parts], alias, wildcard: false });
571
+ return cursor;
572
+ }
573
+
574
+ return cursor;
575
+ }
576
+
577
+ let index = 0;
578
+ while (index < tokens.length) {
579
+ if (
580
+ tokens[index].value === "pub"
581
+ && isBarePub(tokens, index)
582
+ && index + 1 < tokens.length
583
+ && tokens[index + 1].value === "use"
584
+ ) {
585
+ let cursor = parseTree(index + 2, []);
586
+ while (cursor < tokens.length && tokens[cursor].value !== ";") {
587
+ cursor += 1;
588
+ }
589
+ index = cursor + 1;
590
+ continue;
591
+ }
592
+ index += 1;
593
+ }
594
+
595
+ return exports;
596
+ }
597
+
598
+ function definesPath(parts, definitions) {
599
+ const joined = joinPath(parts);
600
+ return definitions.has(joined) || [...definitions.keys()].some((key) => key.startsWith(joined + "::"));
601
+ }
602
+
603
+ function resolveUsePath(parts, currentParts, definitions) {
604
+ if (parts.length === 0) {
605
+ return null;
606
+ }
607
+
608
+ if (parts[0] === "crate") {
609
+ return parts.slice(1);
610
+ }
611
+
612
+ if (parts[0] === "self") {
613
+ return [...currentParts, ...parts.slice(1)];
614
+ }
615
+
616
+ if (parts[0] === "super") {
617
+ let cursor = 0;
618
+ const base = [...currentParts];
619
+ while (cursor < parts.length && parts[cursor] === "super") {
620
+ base.pop();
621
+ cursor += 1;
622
+ }
623
+ return [...base, ...parts.slice(cursor)];
624
+ }
625
+
626
+ if (definesPath(parts, definitions)) {
627
+ return parts;
628
+ }
629
+
630
+ const relative = [...currentParts, ...parts];
631
+ if (definesPath(relative, definitions)) {
632
+ return relative;
633
+ }
634
+
635
+ return null;
636
+ }
637
+
638
+ function reexportedItemsFromSource(source, sourceFile, module, definitions) {
639
+ const items = [];
640
+ const currentParts = fileModuleParts(sourceFile, module);
641
+
642
+ for (const exported of parsePubUseExports(source)) {
643
+ const targetParts = resolveUsePath(exported.path, currentParts, definitions);
644
+ if (targetParts === null) {
645
+ continue;
646
+ }
647
+
648
+ const targetPath = joinPath(targetParts);
649
+
650
+ if (exported.wildcard) {
651
+ const prefix = targetPath + "::";
652
+ for (const [definitionPath, definition] of definitions) {
653
+ if (!definitionPath.startsWith(prefix) || definitionPath.slice(prefix.length).includes("::")) {
654
+ continue;
655
+ }
656
+ items.push({ ...definition, path: publicPath(currentParts, definition.name) });
657
+ }
658
+ continue;
659
+ }
660
+
661
+ const definition = definitions.get(targetPath);
662
+ if (definition === undefined) {
663
+ continue;
664
+ }
665
+ const name = exported.alias || definition.name;
666
+ items.push({ ...definition, path: publicPath(currentParts, name), name });
667
+ }
668
+
669
+ return items;
670
+ }
671
+
672
+ function moduleSourceFiles(module) {
673
+ return get(get(module, "files", {}), "source", []);
674
+ }
675
+
676
+ function moduleRoot(root, module) {
677
+ const modulePath = get(module, "path", ".");
678
+ return modulePath === "." ? root : path.join(root, modulePath);
679
+ }
680
+
681
+ function indexedSourceMap(root, module) {
682
+ return new Map(moduleSourceFiles(module).map((sourceFile) => [resolvePath(path.join(root, sourceFile)), sourceFile]));
683
+ }
684
+
685
+ function reachablePublicSourceFiles(root, module) {
686
+ const indexedSources = indexedSourceMap(root, module);
687
+ const crateRoots = [path.join(moduleRoot(root, module), "src/lib.rs"), path.join(moduleRoot(root, module), "src/main.rs")];
688
+ const reachable = [];
689
+ const visited = new Set();
690
+
691
+ function visit(target) {
692
+ const resolved = resolvePath(target);
693
+ if (visited.has(resolved) || !indexedSources.has(resolved)) {
694
+ return;
695
+ }
696
+ visited.add(resolved);
697
+ reachable.push(indexedSources.get(resolved));
698
+
699
+ for (const moduleName of publicFileModuleNames(readText(target))) {
700
+ for (const candidate of childModuleCandidates(target, moduleName)) {
701
+ if (indexedSources.has(resolvePath(candidate))) {
702
+ visit(candidate);
703
+ break;
704
+ }
705
+ }
706
+ }
707
+ }
708
+
709
+ for (const crateRoot of crateRoots) {
710
+ if (indexedSources.has(resolvePath(crateRoot))) {
711
+ visit(crateRoot);
712
+ break;
713
+ }
714
+ }
715
+
716
+ return reachable;
717
+ }
718
+
719
+ function extractItemsFromSource(source, sourceFile, module) {
720
+ const tokens = scanTokens(source);
721
+ const items = [];
722
+
723
+ function parseScope(startIndex, pathParts, stopAtBrace) {
724
+ let cursor = startIndex;
725
+ while (cursor < tokens.length) {
726
+ const token = tokens[cursor];
727
+
728
+ if (stopAtBrace && token.value === "}") {
729
+ return cursor + 1;
730
+ }
731
+
732
+ if (token.value === "pub") {
733
+ const decl = publicDeclAt(tokens, cursor);
734
+ if (decl !== null) {
735
+ const afterName = decl.nameIndex + 1;
736
+ if (decl.kind === "mod") {
737
+ if (afterName < tokens.length && tokens[afterName].value === "{") {
738
+ cursor = parseScope(afterName + 1, [...pathParts, decl.name], true);
739
+ continue;
961
740
  }
962
- )
741
+ cursor = skipItem(tokens, cursor);
742
+ continue;
743
+ }
744
+
745
+ items.push({
746
+ path: publicPath(pathParts, decl.name),
747
+ kind: decl.kind,
748
+ name: decl.name,
749
+ source: { path: sourceFile, line: tokens[decl.pubIndex].line },
750
+ signature: signatureFromSource(source, tokens[decl.pubIndex].start)
751
+ });
752
+ cursor = skipItem(tokens, cursor);
753
+ continue;
754
+ }
755
+ }
756
+
757
+ if (BLOCK_ITEM_KEYWORDS.has(token.value)) {
758
+ cursor = skipItem(tokens, cursor);
759
+ continue;
760
+ }
761
+
762
+ if (token.value === "{") {
763
+ cursor = skipBraceBlock(tokens, cursor);
764
+ continue;
765
+ }
963
766
 
964
- return items
767
+ cursor += 1;
768
+ }
769
+
770
+ return cursor;
771
+ }
965
772
 
773
+ parseScope(0, fileModuleParts(sourceFile, module), false);
774
+ return items;
775
+ }
776
+
777
+ function readModuleSource(root, sourceFile) {
778
+ const target = path.join(root, sourceFile);
779
+ if (!isFile(target)) {
780
+ fail("Missing source file from module index: " + sourceFile);
781
+ }
782
+ return readText(target);
783
+ }
966
784
 
967
- def extract_typescript_public_items(root: Path, module: dict) -> list[dict]:
968
- indexed_sources = {
969
- (root / source_file).resolve(): source_file
970
- for source_file in module.get("files", {}).get("source", [])
785
+ function extractPublicItems(root, module) {
786
+ const definitions = new Map();
787
+ for (const sourceFile of moduleSourceFiles(module)) {
788
+ for (const item of extractItemsFromSource(readModuleSource(root, sourceFile), sourceFile, module)) {
789
+ if (!definitions.has(item.path)) {
790
+ definitions.set(item.path, item);
791
+ }
971
792
  }
972
- direct_definitions: dict[str, dict[str, dict]] = {}
793
+ }
973
794
 
974
- for source_file in module.get("files", {}).get("source", []):
975
- path = root / source_file
976
- if not path.is_file():
977
- raise SystemExit(f"Missing source file from module index: {source_file}")
978
- direct_definitions[source_file] = {
979
- item["name"]: item
980
- for item in extract_ts_exported_declarations(path.read_text(), source_file)
795
+ const items = [];
796
+ for (const sourceFile of reachablePublicSourceFiles(root, module)) {
797
+ const source = readModuleSource(root, sourceFile);
798
+ items.push(...extractItemsFromSource(source, sourceFile, module));
799
+ items.push(...reexportedItemsFromSource(source, sourceFile, module, definitions));
800
+ }
801
+
802
+ return uniqueItems(items);
803
+ }
804
+
805
+ function removeTsCommentsPreserveLiterals(source) {
806
+ const output = [];
807
+ let cursor = 0;
808
+ let quote = null;
809
+ let escaped = false;
810
+
811
+ while (cursor < source.length) {
812
+ const character = source[cursor];
813
+
814
+ if (quote !== null) {
815
+ output.push(character);
816
+ if (escaped) {
817
+ escaped = false;
818
+ } else if (character === "\\") {
819
+ escaped = true;
820
+ } else if (character === quote) {
821
+ quote = null;
822
+ }
823
+ cursor += 1;
824
+ continue;
825
+ }
826
+
827
+ if (source.startsWith("//", cursor)) {
828
+ const end = source.indexOf("\n", cursor + 2);
829
+ if (end === -1) {
830
+ output.push(" ".repeat(source.length - cursor));
831
+ break;
832
+ }
833
+ output.push(" ".repeat(end - cursor));
834
+ output.push("\n");
835
+ cursor = end + 1;
836
+ continue;
837
+ }
838
+
839
+ if (source.startsWith("/*", cursor)) {
840
+ const found = source.indexOf("*/", cursor + 2);
841
+ const end = found === -1 ? source.length : found + 2;
842
+ output.push(source.slice(cursor, end).replace(/[^\n]/g, " "));
843
+ cursor = end;
844
+ continue;
845
+ }
846
+
847
+ if (character === "'" || character === "\"" || character === "`") {
848
+ quote = character;
849
+ escaped = false;
850
+ output.push(character);
851
+ cursor += 1;
852
+ continue;
853
+ }
854
+
855
+ output.push(character);
856
+ cursor += 1;
857
+ }
858
+
859
+ return output.join("");
860
+ }
861
+
862
+ function tsSignatureFromSource(source, start) {
863
+ let cursor = start;
864
+ const depth = { angle: 0, paren: 0, bracket: 0 };
865
+ let backtickSource = null;
866
+
867
+ while (cursor < source.length) {
868
+ const character = source[cursor];
869
+ if (character === "'" || character === "\"") {
870
+ cursor = quotedLiteralEnd(source, cursor);
871
+ continue;
872
+ }
873
+ if (character === "`") {
874
+ backtickSource = backtickSource || source.replace(/`/g, "\"");
875
+ cursor = quotedLiteralEnd(backtickSource, cursor);
876
+ continue;
877
+ }
878
+
879
+ if (trackDepth(depth, character) && (character === "{" || character === ";")) {
880
+ return collapseWhitespace(source.slice(start, cursor));
881
+ }
882
+ cursor += 1;
883
+ }
884
+
885
+ return collapseWhitespace(source.slice(start));
886
+ }
887
+
888
+ function tsItemKind(match) {
889
+ for (const kind of TS_ITEM_KINDS) {
890
+ const name = match.groups[kind];
891
+ if (name) {
892
+ return [kind === "variable" ? "const" : kind, name];
893
+ }
894
+ }
895
+ return null;
896
+ }
897
+
898
+ function extractTsExportedDeclarations(source, sourceFile) {
899
+ const sanitized = removeTsCommentsPreserveLiterals(source);
900
+ const items = [];
901
+
902
+ for (const match of sanitized.matchAll(TS_EXPORT_DECLARATION)) {
903
+ const kindAndName = tsItemKind(match);
904
+ if (kindAndName === null) {
905
+ continue;
906
+ }
907
+ const [kind, name] = kindAndName;
908
+ items.push({
909
+ path: name,
910
+ kind,
911
+ name,
912
+ source: { path: sourceFile, line: lineNumberFromOffset(sanitized, match.index) },
913
+ signature: tsSignatureFromSource(source, match.index)
914
+ });
915
+ }
916
+
917
+ return items;
918
+ }
919
+
920
+ function exportedNamesFromGroup(group) {
921
+ const names = [];
922
+ const body = group.trim().slice(1, -1);
923
+ for (const rawPart of body.split(",")) {
924
+ let part = rawPart.trim();
925
+ if (!part) {
926
+ continue;
927
+ }
928
+ if (part.startsWith("type ")) {
929
+ part = part.slice("type ".length).trim();
930
+ }
931
+ const separator = /\s+as\s+/.exec(part);
932
+ const pieces = separator
933
+ ? [part.slice(0, separator.index).trim(), part.slice(separator.index + separator[0].length).trim()]
934
+ : [part.trim()];
935
+ const sourceName = pieces[0];
936
+ const publicName = pieces.length === 2 ? pieces[1] : sourceName;
937
+ if (sourceName && sourceName !== "default") {
938
+ names.push([sourceName, publicName]);
939
+ }
940
+ }
941
+ return names;
942
+ }
943
+
944
+ function resolveTsSpecifier(currentFile, specifier, indexedSources) {
945
+ if (!specifier.startsWith(".")) {
946
+ return null;
947
+ }
948
+
949
+ const base = resolvePath(path.join(path.dirname(currentFile), specifier));
950
+ const candidates = [];
951
+
952
+ if (pathSuffix(base)) {
953
+ candidates.push(base);
954
+ for (const extension of TS_SOURCE_EXTENSIONS) {
955
+ candidates.push(withSuffix(base, extension));
956
+ }
957
+ } else {
958
+ for (const extension of TS_SOURCE_EXTENSIONS) {
959
+ candidates.push(withSuffix(base, extension));
960
+ }
961
+ for (const extension of TS_SOURCE_EXTENSIONS) {
962
+ candidates.push(path.join(base, "index" + extension));
963
+ }
964
+ }
965
+
966
+ for (const candidate of candidates) {
967
+ const sourceFile = indexedSources.get(resolvePath(candidate));
968
+ if (sourceFile !== undefined) {
969
+ return sourceFile;
970
+ }
971
+ }
972
+
973
+ return null;
974
+ }
975
+
976
+ function packageExportTargets(value) {
977
+ if (typeof value === "string") {
978
+ return [value];
979
+ }
980
+ if (Array.isArray(value)) {
981
+ return value.flatMap(packageExportTargets);
982
+ }
983
+ if (isObject(value)) {
984
+ return Object.entries(value)
985
+ .filter(([key]) => ["types", "import", "require", "default", "module"].includes(key) || key.startsWith("."))
986
+ .flatMap(([, item]) => packageExportTargets(item));
987
+ }
988
+ return [];
989
+ }
990
+
991
+ function tsEntrypointFiles(root, module) {
992
+ const indexedSources = new Set(moduleSourceFiles(module));
993
+ const modulePath = get(module, "path", ".");
994
+ const base = moduleRoot(root, module);
995
+ const manifest = loadOptionalJson(path.join(root, get(module, "manifest", "")));
996
+ const targets = ["exports", "main", "module", "types"].flatMap((key) => packageExportTargets(get(manifest, key, null)));
997
+
998
+ const candidates = [];
999
+ for (const target of targets) {
1000
+ const targetPath = resolvePath(path.resolve(base, target));
1001
+ for (const candidate of [targetPath, ...TS_SOURCE_EXTENSIONS.map((extension) => withSuffix(targetPath, extension))]) {
1002
+ const relative = relativePath(candidate, root);
1003
+ if (indexedSources.has(relative)) {
1004
+ candidates.push(relative);
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ if (candidates.length === 0) {
1010
+ for (const name of ["index", "main", "app"]) {
1011
+ for (const extension of TS_SOURCE_EXTENSIONS) {
1012
+ const relative = relativePath(path.join(base, "src", name + extension), root);
1013
+ if (indexedSources.has(relative)) {
1014
+ candidates.push(relative);
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ if (candidates.length === 0 && modulePath.startsWith("apps/")) {
1021
+ candidates.push(...moduleSourceFiles(module));
1022
+ }
1023
+
1024
+ return [...new Set(candidates)].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
1025
+ }
1026
+
1027
+ function collectTsPublicItems(root, sourceFile, module, directDefinitions, indexedSources, visited) {
1028
+ if (visited.has(sourceFile)) {
1029
+ return [];
1030
+ }
1031
+ visited.add(sourceFile);
1032
+
1033
+ const target = path.join(root, sourceFile);
1034
+ const sanitized = removeTsCommentsPreserveLiterals(readText(target));
1035
+ const items = [...(directDefinitions.get(sourceFile) || new Map()).values()];
1036
+
1037
+ for (const match of sanitized.matchAll(TS_REEXPORT)) {
1038
+ const targetFile = resolveTsSpecifier(target, match.groups.specifier, indexedSources);
1039
+ if (targetFile === null) {
1040
+ continue;
1041
+ }
1042
+
1043
+ const form = match.groups.form;
1044
+ if (form === "*") {
1045
+ items.push(...collectTsPublicItems(root, targetFile, module, directDefinitions, indexedSources, visited));
1046
+ continue;
1047
+ }
1048
+
1049
+ const targetDefinitions = directDefinitions.get(targetFile) || new Map();
1050
+ for (const [sourceName, publicName] of exportedNamesFromGroup(form)) {
1051
+ const definition = targetDefinitions.get(sourceName);
1052
+ if (definition === undefined) {
1053
+ items.push({
1054
+ path: publicName,
1055
+ kind: "re-export",
1056
+ name: publicName,
1057
+ source: { path: sourceFile, line: lineNumberFromOffset(sanitized, match.index) }
1058
+ });
1059
+ continue;
1060
+ }
1061
+ items.push({ ...definition, path: publicName, name: publicName });
1062
+ }
1063
+ }
1064
+
1065
+ return items;
1066
+ }
1067
+
1068
+ function routeItem(name, sourceFile, sanitized, offset) {
1069
+ return {
1070
+ path: name,
1071
+ kind: "route",
1072
+ name,
1073
+ source: { path: sourceFile, line: lineNumberFromOffset(sanitized, offset) }
1074
+ };
1075
+ }
1076
+
1077
+ function extractTsRoutes(source, sourceFile) {
1078
+ const sanitized = removeTsCommentsPreserveLiterals(source);
1079
+ const items = [];
1080
+
1081
+ for (const match of sanitized.matchAll(TS_ROUTE_CALL)) {
1082
+ items.push(routeItem(match.groups.method.toUpperCase() + " " + match.groups.path, sourceFile, sanitized, match.index));
1083
+ }
1084
+
1085
+ for (const match of sanitized.matchAll(TS_ROUTE_OBJECT)) {
1086
+ const body = match.groups.body;
1087
+ const methodMatch = TS_ROUTE_METHOD.exec(body);
1088
+ const pathMatch = TS_ROUTE_URL.exec(body);
1089
+ if (methodMatch === null || pathMatch === null) {
1090
+ continue;
1091
+ }
1092
+ items.push(routeItem(methodMatch.groups.method.toUpperCase() + " " + pathMatch.groups.path, sourceFile, sanitized, match.index));
1093
+ }
1094
+
1095
+ return items;
1096
+ }
1097
+
1098
+ function extractTypescriptPublicItems(root, module) {
1099
+ const indexedSources = indexedSourceMap(root, module);
1100
+ const directDefinitions = new Map();
1101
+
1102
+ for (const sourceFile of moduleSourceFiles(module)) {
1103
+ const definitions = new Map();
1104
+ for (const item of extractTsExportedDeclarations(readModuleSource(root, sourceFile), sourceFile)) {
1105
+ definitions.set(item.name, item);
1106
+ }
1107
+ directDefinitions.set(sourceFile, definitions);
1108
+ }
1109
+
1110
+ let entrypoints = tsEntrypointFiles(root, module);
1111
+ if (entrypoints.length === 0) {
1112
+ entrypoints = [...moduleSourceFiles(module)];
1113
+ }
1114
+
1115
+ const items = [];
1116
+ for (const entrypoint of entrypoints) {
1117
+ items.push(...collectTsPublicItems(root, entrypoint, module, directDefinitions, indexedSources, new Set()));
1118
+ }
1119
+
1120
+ if (get(module, "path", "").startsWith("apps/")) {
1121
+ for (const sourceFile of moduleSourceFiles(module)) {
1122
+ items.push(...extractTsRoutes(readText(path.join(root, sourceFile)), sourceFile));
1123
+ }
1124
+ }
1125
+
1126
+ return uniqueItems(items);
1127
+ }
1128
+
1129
+ function moduleIndexIsTypescript(moduleIndex) {
1130
+ const workspace = get(moduleIndex, "workspace", {});
1131
+ const workspaceType = get(workspace, "type", null);
1132
+ if (workspaceType === "npm-workspaces" || workspaceType === "npm-package") {
1133
+ return true;
1134
+ }
1135
+ const manifest = get(workspace, "manifest", null);
1136
+ return typeof manifest === "string" && manifest.endsWith("package.json");
1137
+ }
1138
+
1139
+ function buildSurface(root) {
1140
+ const moduleIndex = loadModuleIndex(root);
1141
+ const isTypescript = moduleIndexIsTypescript(moduleIndex);
1142
+ const modules = [];
1143
+
1144
+ for (const layer of get(moduleIndex, "layers", [])) {
1145
+ for (const module of get(layer, "modules", [])) {
1146
+ const publicItems = isTypescript || get(module, "language", null) === "typescript"
1147
+ ? extractTypescriptPublicItems(root, module)
1148
+ : extractPublicItems(root, module);
1149
+ modules.push({ name: module.name, items: publicItems });
1150
+ }
1151
+ }
1152
+
1153
+ return {
1154
+ schemaVersion: 1,
1155
+ kind: "public-surface",
1156
+ generatedBy: ".ai/tools/generate-public-surface",
1157
+ visibility: isTypescript ? "project-public" : "crate-external",
1158
+ modules
1159
+ };
1160
+ }
1161
+
1162
+ // Matches Python json.dumps(indent=2) with ensure_ascii.
1163
+ function toJson(data) {
1164
+ return JSON.stringify(data, null, 2)
1165
+ .replace(/[\u007f-￿]/g, (character) => "\\u" + character.charCodeAt(0).toString(16).padStart(4, "0")) + "\n";
1166
+ }
1167
+
1168
+ function parseArgs(argv) {
1169
+ const args = { check: false, print: false, output: ".ai/generated/public-surface.json" };
1170
+ const usageError = (message) => {
1171
+ process.stderr.write(USAGE + "\ngenerate-public-surface: error: " + message + "\n");
1172
+ process.exit(2);
1173
+ };
1174
+ for (let index = 0; index < argv.length; index += 1) {
1175
+ const argument = argv[index];
1176
+ if (argument === "-h" || argument === "--help") {
1177
+ process.stdout.write(USAGE + "\n\nGenerate .ai/generated/public-surface.json from Rust or TypeScript source files.\n");
1178
+ process.exit(0);
1179
+ } else if (argument === "--check") {
1180
+ args.check = true;
1181
+ } else if (argument === "--print") {
1182
+ args.print = true;
1183
+ } else if (argument === "--output" || argument.startsWith("--output=")) {
1184
+ if (argument === "--output") {
1185
+ if (index + 1 >= argv.length) {
1186
+ usageError("argument --output: expected one argument");
981
1187
  }
1188
+ index += 1;
1189
+ args.output = argv[index];
1190
+ } else {
1191
+ args.output = argument.slice("--output=".length);
1192
+ }
1193
+ } else {
1194
+ usageError("unrecognized arguments: " + argv.slice(index).join(" "));
1195
+ }
1196
+ }
1197
+ return args;
1198
+ }
1199
+
1200
+ function main(argv) {
1201
+ const args = parseArgs(argv);
1202
+ const root = path.resolve(path.dirname(fs.realpathSync(__filename)), "..", "..");
1203
+ const generated = toJson(buildSurface(root));
1204
+ const outputPath = path.join(root, args.output);
1205
+ const displayPath = path.relative(root, outputPath).split(path.sep).join("/");
1206
+
1207
+ if (args.check) {
1208
+ if (!isFile(outputPath)) {
1209
+ process.stderr.write("Missing generated public surface: " + displayPath + "\n");
1210
+ return 1;
1211
+ }
1212
+ if (readText(outputPath) !== generated) {
1213
+ process.stderr.write("Stale generated public surface: " + displayPath + "\n");
1214
+ return 1;
1215
+ }
1216
+ process.stdout.write("generate-public-surface check passed\n");
1217
+ return 0;
1218
+ }
1219
+
1220
+ if (args.print) {
1221
+ process.stdout.write(generated);
1222
+ return 0;
1223
+ }
1224
+
1225
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1226
+ fs.writeFileSync(outputPath, generated);
1227
+ process.stdout.write("wrote " + displayPath + "\n");
1228
+ return 0;
1229
+ }
982
1230
 
983
- entrypoints = ts_entrypoint_files(root, module)
984
- if not entrypoints:
985
- entrypoints = list(module.get("files", {}).get("source", []))
986
-
987
- items: list[dict] = []
988
- for entrypoint in entrypoints:
989
- items.extend(collect_ts_public_items(root, entrypoint, module, direct_definitions, indexed_sources, set()))
990
-
991
- if module.get("path", "").startswith("apps/"):
992
- for source_file in module.get("files", {}).get("source", []):
993
- items.extend(extract_ts_routes((root / source_file).read_text(), source_file))
994
-
995
- seen: set[tuple[str, str, str, int]] = set()
996
- unique_items: list[dict] = []
997
- for item in items:
998
- key = (item["path"], item["kind"], item["source"]["path"], item["source"]["line"])
999
- if key in seen:
1000
- continue
1001
- seen.add(key)
1002
- unique_items.append(item)
1003
-
1004
- return unique_items
1005
-
1006
-
1007
- def module_index_is_typescript(module_index: dict) -> bool:
1008
- workspace = module_index.get("workspace", {})
1009
- workspace_type = workspace.get("type")
1010
- if workspace_type in {"npm-workspaces", "npm-package"}:
1011
- return True
1012
- manifest = workspace.get("manifest")
1013
- return isinstance(manifest, str) and manifest.endswith("package.json")
1014
-
1015
-
1016
- def build_surface(root: Path) -> dict:
1017
- module_index = load_module_index(root)
1018
- is_typescript = module_index_is_typescript(module_index)
1019
- modules = []
1020
-
1021
- for layer in module_index.get("layers", []):
1022
- for module in layer.get("modules", []):
1023
- public_items = (
1024
- extract_typescript_public_items(root, module)
1025
- if is_typescript or module.get("language") == "typescript"
1026
- else extract_public_items(root, module)
1027
- )
1028
- modules.append(
1029
- {
1030
- "name": module["name"],
1031
- "items": public_items,
1032
- }
1033
- )
1034
-
1035
- return {
1036
- "schemaVersion": 1,
1037
- "kind": "public-surface",
1038
- "generatedBy": ".ai/tools/generate-public-surface",
1039
- "visibility": "project-public" if is_typescript else "crate-external",
1040
- "modules": modules,
1041
- }
1042
-
1043
-
1044
- def main() -> int:
1045
- parser = argparse.ArgumentParser(description="Generate .ai/generated/public-surface.json from Rust or TypeScript source files.")
1046
- parser.add_argument("--check", action="store_true", help="Fail if the generated public surface differs from the current file.")
1047
- parser.add_argument("--print", action="store_true", help="Print generated JSON instead of writing it.")
1048
- parser.add_argument("--output", default=".ai/generated/public-surface.json", help="Output path relative to the project root.")
1049
- args = parser.parse_args()
1050
-
1051
- root = Path(__file__).resolve().parents[2]
1052
- generated = json.dumps(build_surface(root), indent=2, sort_keys=False) + "\n"
1053
- output_path = root / args.output
1054
-
1055
- if args.check:
1056
- if not output_path.is_file():
1057
- sys.stderr.write(f"Missing generated public surface: {output_path.relative_to(root)}\n")
1058
- return 1
1059
- current = output_path.read_text()
1060
- if current != generated:
1061
- sys.stderr.write(f"Stale generated public surface: {output_path.relative_to(root)}\n")
1062
- return 1
1063
- print("generate-public-surface check passed")
1064
- return 0
1065
-
1066
- if args.print:
1067
- sys.stdout.write(generated)
1068
- return 0
1069
-
1070
- output_path.parent.mkdir(parents=True, exist_ok=True)
1071
- output_path.write_text(generated)
1072
- print(f"wrote {output_path.relative_to(root)}")
1073
- return 0
1074
-
1075
-
1076
- if __name__ == "__main__":
1077
- raise SystemExit(main())
1231
+ try {
1232
+ process.exitCode = main(process.argv.slice(2));
1233
+ } catch (error) {
1234
+ if (!(error instanceof ToolExit)) {
1235
+ throw error;
1236
+ }
1237
+ process.stderr.write(error.message + "\n");
1238
+ process.exitCode = 1;
1239
+ }