vibe-coding-master 0.0.17 → 0.2.1

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 (42) hide show
  1. package/README.md +60 -28
  2. package/dist/backend/api/artifact-routes.js +5 -5
  3. package/dist/backend/api/harness-routes.js +8 -0
  4. package/dist/backend/server.js +8 -2
  5. package/dist/backend/services/artifact-service.js +12 -12
  6. package/dist/backend/services/harness-service.js +586 -5
  7. package/dist/backend/services/project-service.js +4 -1
  8. package/dist/backend/services/session-service.js +1 -3
  9. package/dist/backend/services/task-service.js +16 -17
  10. package/dist/backend/templates/handoff.js +64 -26
  11. package/dist/backend/templates/harness/architect-agent.js +42 -12
  12. package/dist/backend/templates/harness/claude-root.js +42 -18
  13. package/dist/backend/templates/harness/coder-agent.js +15 -11
  14. package/dist/backend/templates/harness/known-issues-doc.js +22 -0
  15. package/dist/backend/templates/harness/project-manager-agent.js +66 -16
  16. package/dist/backend/templates/harness/pull-request-template.js +29 -0
  17. package/dist/backend/templates/harness/reviewer-agent.js +40 -12
  18. package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +105 -0
  19. package/dist/backend/templates/harness/vcm-harness-bootstrap-skill.js +78 -0
  20. package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +50 -0
  21. package/dist/backend/templates/harness/vcm-route-message-skill.js +86 -0
  22. package/dist/backend/templates/message-envelope.js +1 -0
  23. package/dist/backend/templates/role-command.js +7 -1
  24. package/dist/shared/validation/artifact-check.js +14 -9
  25. package/dist-frontend/assets/index-BmpJxnNx.css +32 -0
  26. package/dist-frontend/assets/index-CGUkhVAP.js +90 -0
  27. package/dist-frontend/index.html +2 -2
  28. package/docs/cc-best-practices.md +433 -192
  29. package/docs/full-harness-baseline.md +258 -0
  30. package/docs/product-design.md +9 -9
  31. package/docs/v0.2-implementation-plan.md +379 -0
  32. package/docs/vcm-cc-best-practices.md +449 -0
  33. package/package.json +3 -1
  34. package/scripts/harness-tools/generate-module-index +298 -0
  35. package/scripts/harness-tools/generate-public-surface +692 -0
  36. package/scripts/install-vcm-harness.mjs +1690 -0
  37. package/scripts/uninstall-vcm-harness.mjs +490 -0
  38. package/scripts/verify-package.mjs +4 -0
  39. package/dist-frontend/assets/index-D40qaonx.css +0 -32
  40. package/dist-frontend/assets/index-DK2F4LFT.js +0 -90
  41. package/docs/v1-architecture-design.md +0 -1014
  42. package/docs/v1-implementation-plan.md +0 -1379
@@ -0,0 +1,692 @@
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
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Token:
30
+ value: str
31
+ start: int
32
+ end: int
33
+ line: int
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class PublicDecl:
38
+ kind: str
39
+ name: str
40
+ pub_index: int
41
+ keyword_index: int
42
+ name_index: int
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class UseExport:
47
+ path: tuple[str, ...]
48
+ alias: str | None = None
49
+ wildcard: bool = False
50
+
51
+
52
+ def load_module_index(root: Path) -> dict:
53
+ path = root / ".ai/generated/module-index.json"
54
+ if not path.is_file():
55
+ raise SystemExit("Missing .ai/generated/module-index.json; run .ai/tools/generate-module-index first.")
56
+ return json.loads(path.read_text())
57
+
58
+
59
+ def raw_string_end(source: str, index: int) -> int | None:
60
+ for prefix in ("br", "rb", "r"):
61
+ if not source.startswith(prefix, index):
62
+ continue
63
+ cursor = index + len(prefix)
64
+ hashes = 0
65
+ while cursor < len(source) and source[cursor] == "#":
66
+ hashes += 1
67
+ cursor += 1
68
+ if cursor >= len(source) or source[cursor] != '"':
69
+ continue
70
+ delimiter = '"' + ("#" * hashes)
71
+ end = source.find(delimiter, cursor + 1)
72
+ return len(source) if end == -1 else end + len(delimiter)
73
+ return None
74
+
75
+
76
+ def quoted_literal_end(source: str, index: int) -> int:
77
+ quote = source[index]
78
+ cursor = index + 1
79
+ escaped = False
80
+ while cursor < len(source):
81
+ char = source[cursor]
82
+ if escaped:
83
+ escaped = False
84
+ elif char == "\\":
85
+ escaped = True
86
+ elif char == quote:
87
+ return cursor + 1
88
+ cursor += 1
89
+ return len(source)
90
+
91
+
92
+ def block_comment_end(source: str, index: int) -> int:
93
+ cursor = index + 2
94
+ depth = 1
95
+ while cursor < len(source) and depth:
96
+ if source.startswith("/*", cursor):
97
+ depth += 1
98
+ cursor += 2
99
+ elif source.startswith("*/", cursor):
100
+ depth -= 1
101
+ cursor += 2
102
+ else:
103
+ cursor += 1
104
+ return cursor
105
+
106
+
107
+ def scan_tokens(source: str) -> list[Token]:
108
+ tokens: list[Token] = []
109
+ cursor = 0
110
+ line = 1
111
+
112
+ while cursor < len(source):
113
+ char = source[cursor]
114
+
115
+ if char.isspace():
116
+ if char == "\n":
117
+ line += 1
118
+ cursor += 1
119
+ continue
120
+
121
+ if source.startswith("//", cursor):
122
+ end = source.find("\n", cursor + 2)
123
+ cursor = len(source) if end == -1 else end
124
+ continue
125
+
126
+ if source.startswith("/*", cursor):
127
+ end = block_comment_end(source, cursor)
128
+ line += source[cursor:end].count("\n")
129
+ cursor = end
130
+ continue
131
+
132
+ raw_end = raw_string_end(source, cursor)
133
+ if raw_end is not None:
134
+ line += source[cursor:raw_end].count("\n")
135
+ cursor = raw_end
136
+ continue
137
+
138
+ if char == '"' or (char == "b" and cursor + 1 < len(source) and source[cursor + 1] == '"'):
139
+ start = cursor + 1 if char == "b" else cursor
140
+ end = quoted_literal_end(source, start)
141
+ line += source[cursor:end].count("\n")
142
+ cursor = end
143
+ continue
144
+
145
+ if char == "'" and cursor + 1 < len(source) and not source[cursor + 1].isalpha():
146
+ end = quoted_literal_end(source, cursor)
147
+ line += source[cursor:end].count("\n")
148
+ cursor = end
149
+ continue
150
+
151
+ match = IDENTIFIER.match(source, cursor)
152
+ if match:
153
+ tokens.append(Token(match.group(0), cursor, match.end(), line))
154
+ cursor = match.end()
155
+ continue
156
+
157
+ tokens.append(Token(char, cursor, cursor + 1, line))
158
+ cursor += 1
159
+
160
+ return tokens
161
+
162
+
163
+ def remove_comments_preserve_literals(text: str) -> str:
164
+ output: list[str] = []
165
+ cursor = 0
166
+ while cursor < len(text):
167
+ if text.startswith("//", cursor):
168
+ end = text.find("\n", cursor + 2)
169
+ if end == -1:
170
+ break
171
+ output.append("\n")
172
+ cursor = end + 1
173
+ continue
174
+
175
+ if text.startswith("/*", cursor):
176
+ end = block_comment_end(text, cursor)
177
+ output.append("\n" * text[cursor:end].count("\n"))
178
+ cursor = end
179
+ continue
180
+
181
+ raw_end = raw_string_end(text, cursor)
182
+ if raw_end is not None:
183
+ output.append(text[cursor:raw_end])
184
+ cursor = raw_end
185
+ continue
186
+
187
+ char = text[cursor]
188
+ if char == '"' or (char == "b" and cursor + 1 < len(text) and text[cursor + 1] == '"'):
189
+ start = cursor + 1 if char == "b" else cursor
190
+ end = quoted_literal_end(text, start)
191
+ output.append(text[cursor:end])
192
+ cursor = end
193
+ continue
194
+
195
+ if char == "'" and cursor + 1 < len(text) and not text[cursor + 1].isalpha():
196
+ end = quoted_literal_end(text, cursor)
197
+ output.append(text[cursor:end])
198
+ cursor = end
199
+ continue
200
+
201
+ output.append(char)
202
+ cursor += 1
203
+
204
+ return "".join(output)
205
+
206
+
207
+ def normalize_signature(signature: str) -> str:
208
+ return " ".join(remove_comments_preserve_literals(signature).split())
209
+
210
+
211
+ def signature_from_source(source: str, start: int) -> str:
212
+ cursor = start
213
+ depth_angle = 0
214
+ depth_paren = 0
215
+ depth_bracket = 0
216
+
217
+ while cursor < len(source):
218
+ if source.startswith("//", cursor):
219
+ end = source.find("\n", cursor + 2)
220
+ cursor = len(source) if end == -1 else end
221
+ continue
222
+
223
+ if source.startswith("/*", cursor):
224
+ cursor = block_comment_end(source, cursor)
225
+ continue
226
+
227
+ raw_end = raw_string_end(source, cursor)
228
+ if raw_end is not None:
229
+ cursor = raw_end
230
+ continue
231
+
232
+ char = source[cursor]
233
+ if char == '"' or (char == "b" and cursor + 1 < len(source) and source[cursor + 1] == '"'):
234
+ start_quote = cursor + 1 if char == "b" else cursor
235
+ cursor = quoted_literal_end(source, start_quote)
236
+ continue
237
+
238
+ if char == "'" and cursor + 1 < len(source) and not source[cursor + 1].isalpha():
239
+ cursor = quoted_literal_end(source, cursor)
240
+ continue
241
+
242
+ if char == "<":
243
+ depth_angle += 1
244
+ elif char == ">" and depth_angle:
245
+ depth_angle -= 1
246
+ elif char == "(":
247
+ depth_paren += 1
248
+ elif char == ")" and depth_paren:
249
+ depth_paren -= 1
250
+ elif char == "[":
251
+ depth_bracket += 1
252
+ elif char == "]" and depth_bracket:
253
+ depth_bracket -= 1
254
+
255
+ at_top_level = depth_angle == 0 and depth_paren == 0 and depth_bracket == 0
256
+ if at_top_level and char in "{;":
257
+ return normalize_signature(source[start:cursor])
258
+
259
+ cursor += 1
260
+
261
+ return normalize_signature(source[start:])
262
+
263
+
264
+ def child_module_candidates(current_file: Path, module_name: str) -> list[Path]:
265
+ if current_file.name in {"lib.rs", "main.rs", "mod.rs"}:
266
+ base = current_file.parent
267
+ else:
268
+ base = current_file.with_suffix("")
269
+ return [
270
+ base / f"{module_name}.rs",
271
+ base / module_name / "mod.rs",
272
+ ]
273
+
274
+
275
+ def is_bare_pub(tokens: list[Token], index: int) -> bool:
276
+ return tokens[index].value == "pub" and not (
277
+ index + 1 < len(tokens) and tokens[index + 1].value == "("
278
+ )
279
+
280
+
281
+ def public_decl_at(tokens: list[Token], index: int) -> PublicDecl | None:
282
+ if not is_bare_pub(tokens, index):
283
+ return None
284
+
285
+ cursor = index + 1
286
+ while cursor < len(tokens) and tokens[cursor].value in ITEM_MODIFIERS:
287
+ cursor += 1
288
+
289
+ if cursor >= len(tokens):
290
+ return None
291
+
292
+ kind = tokens[cursor].value
293
+ if kind == "mod":
294
+ if cursor + 1 < len(tokens) and IDENTIFIER.fullmatch(tokens[cursor + 1].value):
295
+ return PublicDecl("mod", tokens[cursor + 1].value, index, cursor, cursor + 1)
296
+ return None
297
+
298
+ if kind in ITEM_KINDS and cursor + 1 < len(tokens):
299
+ name = tokens[cursor + 1].value
300
+ if IDENTIFIER.fullmatch(name):
301
+ return PublicDecl(ITEM_KINDS[kind], name, index, cursor, cursor + 1)
302
+
303
+ return None
304
+
305
+
306
+ def skip_brace_block(tokens: list[Token], index: int) -> int:
307
+ depth = 0
308
+ cursor = index
309
+ while cursor < len(tokens):
310
+ if tokens[cursor].value == "{":
311
+ depth += 1
312
+ elif tokens[cursor].value == "}":
313
+ depth -= 1
314
+ if depth == 0:
315
+ return cursor + 1
316
+ cursor += 1
317
+ return cursor
318
+
319
+
320
+ def skip_item(tokens: list[Token], index: int) -> int:
321
+ cursor = index
322
+ while cursor < len(tokens):
323
+ if tokens[cursor].value == ";":
324
+ return cursor + 1
325
+ if tokens[cursor].value == "{":
326
+ return skip_brace_block(tokens, cursor)
327
+ cursor += 1
328
+ return cursor
329
+
330
+
331
+ def file_module_parts(source_file: str, module: dict) -> list[str]:
332
+ module_path = module.get("path", ".")
333
+ prefix = "src/" if module_path == "." else f"{module_path}/src/"
334
+ if source_file.endswith("/src/lib.rs") or source_file.endswith("/src/main.rs"):
335
+ return []
336
+ relative = source_file.removeprefix(prefix).removesuffix(".rs")
337
+ return [part for part in relative.split("/") if part and part != "mod"]
338
+
339
+
340
+ def public_path(parts: list[str], item_name: str) -> str:
341
+ return "::".join([*parts, item_name])
342
+
343
+
344
+ def join_path(parts: list[str] | tuple[str, ...]) -> str:
345
+ return "::".join(parts)
346
+
347
+
348
+ def is_path_separator(tokens: list[Token], index: int) -> bool:
349
+ return (
350
+ index + 1 < len(tokens)
351
+ and tokens[index].value == ":"
352
+ and tokens[index + 1].value == ":"
353
+ )
354
+
355
+
356
+ def public_file_module_names(source: str) -> list[str]:
357
+ tokens = scan_tokens(source)
358
+ names: list[str] = []
359
+
360
+ for index, token in enumerate(tokens):
361
+ if token.value != "pub":
362
+ continue
363
+ decl = public_decl_at(tokens, index)
364
+ if decl is None or decl.kind != "mod":
365
+ continue
366
+ if decl.name_index + 1 < len(tokens) and tokens[decl.name_index + 1].value == ";":
367
+ names.append(decl.name)
368
+
369
+ return names
370
+
371
+
372
+ def parse_pub_use_exports(source: str) -> list[UseExport]:
373
+ tokens = scan_tokens(source)
374
+ exports: list[UseExport] = []
375
+
376
+ def parse_group(index: int, prefix: list[str]) -> int:
377
+ cursor = index + 1
378
+ while cursor < len(tokens):
379
+ if tokens[cursor].value == "}":
380
+ return cursor + 1
381
+ if tokens[cursor].value == ",":
382
+ cursor += 1
383
+ continue
384
+ cursor = parse_tree(cursor, prefix)
385
+ if cursor < len(tokens) and tokens[cursor].value == ",":
386
+ cursor += 1
387
+ return cursor
388
+
389
+ def parse_tree(index: int, prefix: list[str]) -> int:
390
+ cursor = index
391
+ parts = list(prefix)
392
+
393
+ while cursor < len(tokens):
394
+ token = tokens[cursor]
395
+
396
+ if token.value == "{":
397
+ return parse_group(cursor, parts)
398
+
399
+ if token.value == "*":
400
+ exports.append(UseExport(tuple(parts), wildcard=True))
401
+ return cursor + 1
402
+
403
+ if not IDENTIFIER.fullmatch(token.value):
404
+ return cursor + 1
405
+
406
+ name = token.value
407
+ if name == "self":
408
+ cursor += 1
409
+ if is_path_separator(tokens, cursor):
410
+ cursor += 2
411
+ continue
412
+ return cursor
413
+
414
+ parts.append(name)
415
+ cursor += 1
416
+
417
+ if is_path_separator(tokens, cursor):
418
+ cursor += 2
419
+ if cursor < len(tokens) and tokens[cursor].value in {"{", "*"}:
420
+ continue
421
+ continue
422
+
423
+ alias = None
424
+ if cursor + 1 < len(tokens) and tokens[cursor].value == "as":
425
+ if IDENTIFIER.fullmatch(tokens[cursor + 1].value):
426
+ alias = tokens[cursor + 1].value
427
+ cursor += 2
428
+
429
+ exports.append(UseExport(tuple(parts), alias=alias))
430
+ return cursor
431
+
432
+ return cursor
433
+
434
+ index = 0
435
+ while index < len(tokens):
436
+ if (
437
+ tokens[index].value == "pub"
438
+ and is_bare_pub(tokens, index)
439
+ and index + 1 < len(tokens)
440
+ and tokens[index + 1].value == "use"
441
+ ):
442
+ cursor = parse_tree(index + 2, [])
443
+ while cursor < len(tokens) and tokens[cursor].value != ";":
444
+ cursor += 1
445
+ index = cursor + 1
446
+ continue
447
+ index += 1
448
+
449
+ return exports
450
+
451
+
452
+ def resolve_use_path(parts: tuple[str, ...], current_parts: list[str], definitions: dict[str, dict]) -> tuple[str, ...] | None:
453
+ if not parts:
454
+ return None
455
+
456
+ if parts[0] == "crate":
457
+ return parts[1:]
458
+
459
+ if parts[0] == "self":
460
+ return tuple([*current_parts, *parts[1:]])
461
+
462
+ if parts[0] == "super":
463
+ cursor = 0
464
+ base = list(current_parts)
465
+ while cursor < len(parts) and parts[cursor] == "super":
466
+ if base:
467
+ base.pop()
468
+ cursor += 1
469
+ return tuple([*base, *parts[cursor:]])
470
+
471
+ candidate = parts
472
+ if join_path(candidate) in definitions or any(path.startswith(f"{join_path(candidate)}::") for path in definitions):
473
+ return candidate
474
+
475
+ relative = tuple([*current_parts, *parts])
476
+ if join_path(relative) in definitions or any(path.startswith(f"{join_path(relative)}::") for path in definitions):
477
+ return relative
478
+
479
+ return None
480
+
481
+
482
+ def reexported_items_from_source(source: str, source_file: str, module: dict, definitions: dict[str, dict]) -> list[dict]:
483
+ items: list[dict] = []
484
+ current_parts = file_module_parts(source_file, module)
485
+
486
+ for export in parse_pub_use_exports(source):
487
+ target_parts = resolve_use_path(export.path, current_parts, definitions)
488
+ if target_parts is None:
489
+ continue
490
+
491
+ target_path = join_path(target_parts)
492
+ public_base = current_parts
493
+
494
+ if export.wildcard:
495
+ prefix = f"{target_path}::"
496
+ for definition_path, definition in definitions.items():
497
+ if not definition_path.startswith(prefix):
498
+ continue
499
+ alias_path = definition_path.removeprefix(prefix)
500
+ if "::" in alias_path:
501
+ continue
502
+ items.append({**definition, "path": public_path(public_base, definition["name"])})
503
+ continue
504
+
505
+ definition = definitions.get(target_path)
506
+ if definition is None:
507
+ continue
508
+ name = export.alias or definition["name"]
509
+ items.append({**definition, "path": public_path(public_base, name), "name": name})
510
+
511
+ return items
512
+
513
+
514
+ def reachable_public_source_files(root: Path, module: dict) -> list[str]:
515
+ indexed_sources = {
516
+ (root / source_file).resolve(): source_file
517
+ for source_file in module.get("files", {}).get("source", [])
518
+ }
519
+ module_path = module.get("path", ".")
520
+ module_root = root if module_path == "." else root / module_path
521
+ crate_roots = [
522
+ module_root / "src/lib.rs",
523
+ module_root / "src/main.rs",
524
+ ]
525
+
526
+ reachable: list[str] = []
527
+ visited: set[Path] = set()
528
+
529
+ def visit(path: Path) -> None:
530
+ resolved = path.resolve()
531
+ if resolved in visited or resolved not in indexed_sources:
532
+ return
533
+ visited.add(resolved)
534
+ reachable.append(indexed_sources[resolved])
535
+
536
+ for module_name in public_file_module_names(path.read_text()):
537
+ for candidate in child_module_candidates(path, module_name):
538
+ if candidate.resolve() in indexed_sources:
539
+ visit(candidate)
540
+ break
541
+
542
+ for crate_root in crate_roots:
543
+ if crate_root.resolve() in indexed_sources:
544
+ visit(crate_root)
545
+ break
546
+
547
+ return reachable
548
+
549
+
550
+ def extract_items_from_source(source: str, source_file: str, module: dict) -> list[dict]:
551
+ tokens = scan_tokens(source)
552
+ items: list[dict] = []
553
+ base_parts = file_module_parts(source_file, module)
554
+
555
+ def parse_scope(start_index: int, path_parts: list[str], stop_at_brace: bool) -> int:
556
+ cursor = start_index
557
+ while cursor < len(tokens):
558
+ token = tokens[cursor]
559
+
560
+ if stop_at_brace and token.value == "}":
561
+ return cursor + 1
562
+
563
+ if token.value == "pub":
564
+ decl = public_decl_at(tokens, cursor)
565
+ if decl is not None:
566
+ after_name = decl.name_index + 1
567
+ if decl.kind == "mod":
568
+ if after_name < len(tokens) and tokens[after_name].value == "{":
569
+ cursor = parse_scope(after_name + 1, [*path_parts, decl.name], True)
570
+ continue
571
+ cursor = skip_item(tokens, cursor)
572
+ continue
573
+
574
+ items.append(
575
+ {
576
+ "path": public_path(path_parts, decl.name),
577
+ "kind": decl.kind,
578
+ "name": decl.name,
579
+ "source": {
580
+ "path": source_file,
581
+ "line": tokens[decl.pub_index].line,
582
+ },
583
+ "signature": signature_from_source(source, tokens[decl.pub_index].start),
584
+ }
585
+ )
586
+ cursor = skip_item(tokens, cursor)
587
+ continue
588
+
589
+ if token.value in BLOCK_ITEM_KEYWORDS:
590
+ cursor = skip_item(tokens, cursor)
591
+ continue
592
+
593
+ if token.value == "{":
594
+ cursor = skip_brace_block(tokens, cursor)
595
+ continue
596
+
597
+ cursor += 1
598
+
599
+ return cursor
600
+
601
+ parse_scope(0, base_parts, False)
602
+ return items
603
+
604
+
605
+ def extract_public_items(root: Path, module: dict) -> list[dict]:
606
+ definitions: dict[str, dict] = {}
607
+ for source_file in module.get("files", {}).get("source", []):
608
+ path = root / source_file
609
+ if not path.is_file():
610
+ raise SystemExit(f"Missing source file from module index: {source_file}")
611
+ for item in extract_items_from_source(path.read_text(), source_file, module):
612
+ definitions.setdefault(item["path"], item)
613
+
614
+ items: list[dict] = []
615
+
616
+ for source_file in reachable_public_source_files(root, module):
617
+ path = root / source_file
618
+ if not path.is_file():
619
+ raise SystemExit(f"Missing source file from module index: {source_file}")
620
+ source = path.read_text()
621
+ items.extend(extract_items_from_source(source, source_file, module))
622
+ items.extend(reexported_items_from_source(source, source_file, module, definitions))
623
+
624
+ seen: set[tuple[str, str, str, int]] = set()
625
+ unique_items: list[dict] = []
626
+ for item in items:
627
+ key = (item["path"], item["kind"], item["source"]["path"], item["source"]["line"])
628
+ if key in seen:
629
+ continue
630
+ seen.add(key)
631
+ unique_items.append(item)
632
+
633
+ return unique_items
634
+
635
+
636
+ def build_surface(root: Path) -> dict:
637
+ module_index = load_module_index(root)
638
+ modules = []
639
+
640
+ for layer in module_index.get("layers", []):
641
+ for module in layer.get("modules", []):
642
+ public_items = extract_public_items(root, module)
643
+ modules.append(
644
+ {
645
+ "name": module["name"],
646
+ "items": public_items,
647
+ }
648
+ )
649
+
650
+ return {
651
+ "schemaVersion": 1,
652
+ "kind": "public-surface",
653
+ "generatedBy": ".ai/tools/generate-public-surface",
654
+ "visibility": "crate-external",
655
+ "modules": modules,
656
+ }
657
+
658
+
659
+ def main() -> int:
660
+ parser = argparse.ArgumentParser(description="Generate .ai/generated/public-surface.json from Rust source files.")
661
+ parser.add_argument("--check", action="store_true", help="Fail if the generated public surface differs from the current file.")
662
+ parser.add_argument("--print", action="store_true", help="Print generated JSON instead of writing it.")
663
+ parser.add_argument("--output", default=".ai/generated/public-surface.json", help="Output path relative to the project root.")
664
+ args = parser.parse_args()
665
+
666
+ root = Path(__file__).resolve().parents[2]
667
+ generated = json.dumps(build_surface(root), indent=2, sort_keys=False) + "\n"
668
+ output_path = root / args.output
669
+
670
+ if args.check:
671
+ if not output_path.is_file():
672
+ sys.stderr.write(f"Missing generated public surface: {output_path.relative_to(root)}\n")
673
+ return 1
674
+ current = output_path.read_text()
675
+ if current != generated:
676
+ sys.stderr.write(f"Stale generated public surface: {output_path.relative_to(root)}\n")
677
+ return 1
678
+ print("generate-public-surface check passed")
679
+ return 0
680
+
681
+ if args.print:
682
+ sys.stdout.write(generated)
683
+ return 0
684
+
685
+ output_path.parent.mkdir(parents=True, exist_ok=True)
686
+ output_path.write_text(generated)
687
+ print(f"wrote {output_path.relative_to(root)}")
688
+ return 0
689
+
690
+
691
+ if __name__ == "__main__":
692
+ raise SystemExit(main())