vibe-coding-master 0.0.17 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -28
- package/dist/backend/api/artifact-routes.js +5 -5
- package/dist/backend/api/harness-routes.js +8 -0
- package/dist/backend/server.js +8 -2
- package/dist/backend/services/artifact-service.js +12 -12
- package/dist/backend/services/harness-service.js +579 -5
- package/dist/backend/services/project-service.js +4 -1
- package/dist/backend/services/session-service.js +1 -3
- package/dist/backend/services/task-service.js +16 -17
- package/dist/backend/templates/handoff.js +64 -26
- package/dist/backend/templates/harness/architect-agent.js +42 -12
- package/dist/backend/templates/harness/claude-root.js +42 -18
- package/dist/backend/templates/harness/coder-agent.js +15 -11
- package/dist/backend/templates/harness/known-issues-doc.js +22 -0
- package/dist/backend/templates/harness/project-manager-agent.js +66 -16
- package/dist/backend/templates/harness/pull-request-template.js +29 -0
- package/dist/backend/templates/harness/reviewer-agent.js +40 -12
- package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +105 -0
- package/dist/backend/templates/harness/vcm-harness-bootstrap-skill.js +78 -0
- package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +50 -0
- package/dist/backend/templates/harness/vcm-route-message-skill.js +86 -0
- package/dist/backend/templates/message-envelope.js +1 -0
- package/dist/backend/templates/role-command.js +7 -1
- package/dist/shared/validation/artifact-check.js +14 -9
- package/dist-frontend/assets/index-CrY5Ryps.js +90 -0
- package/dist-frontend/assets/index-CvvtrrCN.css +32 -0
- package/dist-frontend/index.html +2 -2
- package/docs/cc-best-practices.md +433 -192
- package/docs/full-harness-baseline.md +254 -0
- package/docs/product-design.md +9 -9
- package/docs/v0.2-implementation-plan.md +379 -0
- package/docs/vcm-cc-best-practices.md +449 -0
- package/package.json +3 -1
- package/scripts/harness-tools/generate-module-index +298 -0
- package/scripts/harness-tools/generate-public-surface +692 -0
- package/scripts/install-vcm-harness.mjs +1607 -0
- package/scripts/uninstall-vcm-harness.mjs +490 -0
- package/scripts/verify-package.mjs +4 -0
- package/dist-frontend/assets/index-D40qaonx.css +0 -32
- package/dist-frontend/assets/index-DK2F4LFT.js +0 -90
- package/docs/v1-architecture-design.md +0 -1014
- 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())
|