vibe-coding-master 0.4.1 → 0.4.2
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 +2 -2
- package/dist/backend/cli/install-vcm-harness.js +1 -1
- package/dist/backend/services/message-service.js +0 -32
- package/dist/backend/templates/harness/architect-agent.js +1 -1
- package/dist/backend/templates/harness/claude-root.js +1 -1
- package/dist/backend/templates/harness/coder-agent.js +1 -1
- package/dist/backend/templates/harness/vcm-harness-bootstrap-skill.js +1 -1
- package/docs/full-harness-baseline.md +4 -4
- package/docs/vcm-cc-best-practices.md +5 -6
- package/package.json +1 -1
- package/scripts/harness-tools/__pycache__/generate-module-indexcpython-314.pyc +0 -0
- package/scripts/harness-tools/__pycache__/generate-public-surfacecpython-314.pyc +0 -0
- package/scripts/harness-tools/generate-module-index +225 -5
- package/scripts/harness-tools/generate-public-surface +388 -3
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ When Gate Review Gates are enabled for a task, or when a Gate Reviewer session a
|
|
|
30
30
|
- Manual and automatic orchestration modes.
|
|
31
31
|
- Two-stage VCM harness setup: deterministic fixed install plus AI-assisted bootstrap.
|
|
32
32
|
- VCM-managed root rules, role agents, repo-local VCM skills, Claude Code hooks, generated-context tools, and PR template.
|
|
33
|
-
-
|
|
33
|
+
- Generated context for Rust and npm workspace module indexing plus public surface indexing.
|
|
34
34
|
- Translation panel powered by the long-lived Translator session.
|
|
35
35
|
- Mobile Gateway through Tencent iLink Bot API / Weixin DM, for talking to PM and managing tasks from Weixin.
|
|
36
36
|
- Durable task state, role session state, handoff artifacts, and message history.
|
|
@@ -497,7 +497,7 @@ docs/TESTING.md
|
|
|
497
497
|
.ai/generated/public-surface.json
|
|
498
498
|
```
|
|
499
499
|
|
|
500
|
-
The generated-context tools
|
|
500
|
+
The generated-context tools support Rust/Cargo projects and npm workspace TypeScript/JavaScript projects. Other repository shapes can still install the fixed harness, but generated context should be treated as unsupported until project-specific generators exist.
|
|
501
501
|
|
|
502
502
|
After applying harness changes or completing bootstrap, VCM reports the exact files changed or checks completed and reminds the user to review and commit them before starting long-running work.
|
|
503
503
|
|
|
@@ -314,7 +314,7 @@ Installs only fixed VCM harness content.
|
|
|
314
314
|
This deterministic installer handles VCM-owned managed blocks, VCM-owned whole
|
|
315
315
|
files, VCM Claude settings hooks, generic long-running helper tools, and the
|
|
316
316
|
harness manifest. It also creates blank durable project doc templates when
|
|
317
|
-
missing and installs
|
|
317
|
+
missing and installs generated-context tools for Rust and npm workspace projects. It does not copy
|
|
318
318
|
example project docs, generated context artifacts, module-level architecture
|
|
319
319
|
docs, or task runtime handoff artifacts.`);
|
|
320
320
|
}
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { CORE_VCM_ROLE_NAMES } from "../../shared/constants.js";
|
|
4
|
-
import { VcmError } from "../errors.js";
|
|
5
4
|
import { resolveRepoPath } from "../adapters/filesystem.js";
|
|
6
5
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
7
6
|
import { renderMessageEnvelope } from "../templates/message-envelope.js";
|
|
8
7
|
const PM_ROLE = "project-manager";
|
|
9
|
-
const PM_TO_ROLE_TYPES = new Set(["task", "question", "review-request", "revise", "cancel"]);
|
|
10
|
-
const ROLE_TO_PM_TYPES = new Set(["result", "question", "blocked", "finding"]);
|
|
11
8
|
const DEFAULT_PRE_DISPATCH_SWITCH_DELAY_MS = 500;
|
|
12
9
|
const DEFAULT_AUTO_DISPATCH_ENTER_DELAY_MS = 500;
|
|
13
10
|
const DEFAULT_DISPATCH_CONFIRMATION_RETRY_DELAYS_MS = [1500, 3000];
|
|
@@ -64,7 +61,6 @@ export function createMessageService(deps) {
|
|
|
64
61
|
return all.filter((routeFile) => routeFile.pending);
|
|
65
62
|
}
|
|
66
63
|
async function dispatchRouteFile(input, routeFile, state, timestamp) {
|
|
67
|
-
validateMessagePolicy(routeFile.fromRole, routeFile.toRole, routeFile.type);
|
|
68
64
|
const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, routeFile.toRole);
|
|
69
65
|
if (!session || session.status !== "running") {
|
|
70
66
|
return {
|
|
@@ -370,34 +366,6 @@ function selectDispatchCandidates(routeFiles, stoppedRole) {
|
|
|
370
366
|
return updated !== 0 ? updated : left.path.localeCompare(right.path);
|
|
371
367
|
});
|
|
372
368
|
}
|
|
373
|
-
function validateMessagePolicy(fromRole, toRole, type) {
|
|
374
|
-
if (!CORE_VCM_ROLE_NAMES.some((role) => role === toRole)) {
|
|
375
|
-
throw new VcmError({
|
|
376
|
-
code: "MESSAGE_TARGET_UNKNOWN",
|
|
377
|
-
message: `Unknown target role: ${toRole}`,
|
|
378
|
-
statusCode: 400
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
if (!CORE_VCM_ROLE_NAMES.some((role) => role === fromRole)) {
|
|
382
|
-
throw new VcmError({
|
|
383
|
-
code: "MESSAGE_SENDER_UNKNOWN",
|
|
384
|
-
message: `Unknown sender role: ${fromRole}`,
|
|
385
|
-
statusCode: 400
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
if (fromRole === PM_ROLE && toRole !== PM_ROLE && PM_TO_ROLE_TYPES.has(type)) {
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
if (fromRole !== PM_ROLE && toRole === PM_ROLE && ROLE_TO_PM_TYPES.has(type)) {
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
throw new VcmError({
|
|
395
|
-
code: "MESSAGE_POLICY_DENIED",
|
|
396
|
-
message: `${fromRole} cannot send ${type} messages to ${toRole}.`,
|
|
397
|
-
statusCode: 403,
|
|
398
|
-
hint: "Use project-manager as the orchestration hub unless this task explicitly allows a peer route."
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
369
|
async function withTaskLock(locks, key, run) {
|
|
402
370
|
const previous = locks.get(key) ?? Promise.resolve();
|
|
403
371
|
const next = previous.catch(() => undefined).then(run);
|
|
@@ -80,7 +80,7 @@ export function renderArchitectHarnessRules() {
|
|
|
80
80
|
- Update affected \`<module>/ARCHITECTURE.md\` when module-level detailed design changes: boundaries, behavior, important public surface explanations, internal risks, or module-specific architecture notes.
|
|
81
81
|
- Treat \`.ai/generated/public-surface.json\` as the full machine index for public surface. Verify or report its freshness when public APIs changed; do not replace it with prose in architecture docs.
|
|
82
82
|
- When module structure changes, require \`.ai/tools/generate-module-index --check\` or regeneration.
|
|
83
|
-
- When
|
|
83
|
+
- When public APIs, routes, or externally consumed surfaces change, require \`.ai/tools/generate-public-surface --check\` or regeneration.
|
|
84
84
|
- Read \`.ai/vcm/handoffs/known-issues.md\` and promote confirmed unresolved issues to \`docs/known-issues.md\`.
|
|
85
85
|
- Write \`.ai/vcm/handoffs/docs-sync-report.md\` with decision, evidence reviewed, architecture drift check, docs updated, docs left unchanged, remaining documentation risks, and handoff notes.
|
|
86
86
|
|
|
@@ -22,7 +22,7 @@ export function renderRootClaudeHarnessRules() {
|
|
|
22
22
|
- \`docs/TESTING.md\`: validation strategy, commands, validation levels, integration/E2E case definitions, final-validation cleanup, and known testing gaps; reviewer-owned.
|
|
23
23
|
- \`docs/known-issues.md\`: durable known issues and accepted limitations; architect-owned.
|
|
24
24
|
- \`.ai/generated/module-index.json\`: generated module index; use it to find layers, modules, manifests, module docs, source files, test files, and workspace dependencies.
|
|
25
|
-
- \`.ai/generated/public-surface.json\`: generated
|
|
25
|
+
- \`.ai/generated/public-surface.json\`: generated public surface index; use it to inspect module-to-module public APIs, routes, and source evidence.
|
|
26
26
|
|
|
27
27
|
## VCM Task Flow
|
|
28
28
|
|
|
@@ -48,7 +48,7 @@ export function renderCoderHarnessRules() {
|
|
|
48
48
|
### Generated Context
|
|
49
49
|
|
|
50
50
|
- Regenerate \`.ai/generated/module-index.json\` with \`.ai/tools/generate-module-index\` after module, manifest, source-file, or test-file changes.
|
|
51
|
-
- Regenerate \`.ai/generated/public-surface.json\` with \`.ai/tools/generate-public-surface\` after
|
|
51
|
+
- Regenerate \`.ai/generated/public-surface.json\` with \`.ai/tools/generate-public-surface\` after public API, route, externally consumed surface, or public visibility changes.
|
|
52
52
|
- Do not hand-edit generated context files.
|
|
53
53
|
|
|
54
54
|
### Baseline Tests
|
|
@@ -49,7 +49,7 @@ This skill is an operating procedure. It does not replace the deterministic VCM
|
|
|
49
49
|
|
|
50
50
|
- Document the project-level module overview, module responsibilities, module relationships, dependency direction, and project-wide constraints.
|
|
51
51
|
- Link to module-level \`ARCHITECTURE.md\` files when present.
|
|
52
|
-
- Explain generated-context ownership, especially that \`.ai/generated/public-surface.json\` is the machine index for
|
|
52
|
+
- Explain generated-context ownership, especially that \`.ai/generated/public-surface.json\` is the machine index for public APIs, routes, and externally consumed surfaces.
|
|
53
53
|
|
|
54
54
|
### Module-Level \`ARCHITECTURE.md\`
|
|
55
55
|
|
|
@@ -101,10 +101,10 @@ Directory roots created by the installer:
|
|
|
101
101
|
.ai/tools/vcm-bash-guard
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
`generate-module-index` and `generate-public-surface`
|
|
105
|
-
projects
|
|
106
|
-
|
|
107
|
-
generators exist.
|
|
104
|
+
`generate-module-index` and `generate-public-surface` support Rust/Cargo
|
|
105
|
+
projects and npm workspace TypeScript/JavaScript projects. Other repository
|
|
106
|
+
shapes can still install the fixed harness, but generated context should be
|
|
107
|
+
treated as unsupported until project-specific generators exist.
|
|
108
108
|
|
|
109
109
|
## Runtime State
|
|
110
110
|
|
|
@@ -363,13 +363,12 @@ The current example has two generated artifacts:
|
|
|
363
363
|
`module-index.json` helps agents find layers, modules, manifests, module docs,
|
|
364
364
|
source files, test files, and workspace dependencies.
|
|
365
365
|
|
|
366
|
-
`public-surface.json` indexes
|
|
367
|
-
index, not an architecture document.
|
|
366
|
+
`public-surface.json` indexes project public APIs, routes, and externally
|
|
367
|
+
consumed surfaces. It is a machine index, not an architecture document.
|
|
368
368
|
|
|
369
|
-
Current generated-context support
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
`.ai/generated/*` is considered reliable.
|
|
369
|
+
Current generated-context support covers Rust/Cargo projects and npm workspace
|
|
370
|
+
TypeScript/JavaScript projects. Other repository shapes must use
|
|
371
|
+
project-specific generators before `.ai/generated/*` is considered reliable.
|
|
373
372
|
|
|
374
373
|
There is no `test-map.json`. Rust unit tests live with source where appropriate;
|
|
375
374
|
integration tests use Cargo's normal test layout. Test files are discoverable
|
package/package.json
CHANGED
|
@@ -12,6 +12,12 @@ except ModuleNotFoundError:
|
|
|
12
12
|
tomllib = None
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
NODE_SOURCE_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
|
16
|
+
NODE_EXCLUDED_DIRS = {"node_modules", "dist", "build", "coverage", ".next", ".turbo", ".vite"}
|
|
17
|
+
NODE_DEPENDENCY_TABLES = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies")
|
|
18
|
+
NODE_TEST_MARKERS = (".test.", ".spec.")
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
def relative_path(path: Path, root: Path) -> str:
|
|
16
22
|
try:
|
|
17
23
|
rel = path.absolute().relative_to(root.absolute())
|
|
@@ -20,7 +26,7 @@ def relative_path(path: Path, root: Path) -> str:
|
|
|
20
26
|
return "." if str(rel) == "." else rel.as_posix()
|
|
21
27
|
|
|
22
28
|
|
|
23
|
-
def
|
|
29
|
+
def maybe_find_cargo_root(project_root: Path) -> Path | None:
|
|
24
30
|
if (project_root / "Cargo.toml").is_file():
|
|
25
31
|
return project_root
|
|
26
32
|
|
|
@@ -28,7 +34,18 @@ def find_cargo_root(project_root: Path) -> Path:
|
|
|
28
34
|
if child.is_dir() and (child / "Cargo.toml").is_file():
|
|
29
35
|
return child
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_cargo_root(project_root: Path) -> Path:
|
|
41
|
+
cargo_root = maybe_find_cargo_root(project_root)
|
|
42
|
+
if cargo_root is None:
|
|
43
|
+
raise SystemExit(f"Could not find Cargo.toml in {project_root} or its direct child directories.")
|
|
44
|
+
return cargo_root
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_json(path: Path) -> dict:
|
|
48
|
+
return json.loads(path.read_text())
|
|
32
49
|
|
|
33
50
|
|
|
34
51
|
def load_toml(path: Path) -> dict:
|
|
@@ -149,6 +166,196 @@ def rust_files_under(module_dir: Path, project_root: Path, child: str) -> list[s
|
|
|
149
166
|
)
|
|
150
167
|
|
|
151
168
|
|
|
169
|
+
def maybe_find_node_root(project_root: Path) -> Path | None:
|
|
170
|
+
if (project_root / "package.json").is_file():
|
|
171
|
+
return project_root
|
|
172
|
+
|
|
173
|
+
for child in sorted(project_root.iterdir(), key=lambda path: path.name):
|
|
174
|
+
if child.is_dir() and (child / "package.json").is_file():
|
|
175
|
+
return child
|
|
176
|
+
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def workspace_patterns(package_data: dict) -> list[str]:
|
|
181
|
+
workspaces = package_data.get("workspaces")
|
|
182
|
+
if isinstance(workspaces, list):
|
|
183
|
+
return [pattern for pattern in workspaces if isinstance(pattern, str)]
|
|
184
|
+
if isinstance(workspaces, dict):
|
|
185
|
+
packages = workspaces.get("packages", [])
|
|
186
|
+
if isinstance(packages, list):
|
|
187
|
+
return [pattern for pattern in packages if isinstance(pattern, str)]
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def node_workspace_manifests(node_root: Path) -> list[Path]:
|
|
192
|
+
root_manifest = node_root / "package.json"
|
|
193
|
+
root_data = load_json(root_manifest)
|
|
194
|
+
patterns = workspace_patterns(root_data)
|
|
195
|
+
|
|
196
|
+
if not patterns:
|
|
197
|
+
return [root_manifest]
|
|
198
|
+
|
|
199
|
+
manifests: list[Path] = []
|
|
200
|
+
seen: set[Path] = set()
|
|
201
|
+
|
|
202
|
+
for pattern in patterns:
|
|
203
|
+
if pattern.startswith("!") or pattern.startswith("/") or ".." in Path(pattern).parts:
|
|
204
|
+
continue
|
|
205
|
+
for candidate in sorted(node_root.glob(pattern), key=lambda path: path.as_posix()):
|
|
206
|
+
manifest = candidate / "package.json"
|
|
207
|
+
if not candidate.is_dir() or not manifest.is_file() or manifest in seen:
|
|
208
|
+
continue
|
|
209
|
+
if any(part in NODE_EXCLUDED_DIRS for part in relative_path(candidate, node_root).split("/")):
|
|
210
|
+
continue
|
|
211
|
+
manifests.append(manifest)
|
|
212
|
+
seen.add(manifest)
|
|
213
|
+
|
|
214
|
+
if root_data.get("name") and (root_data.get("main") or root_data.get("exports") or (node_root / "src").is_dir()):
|
|
215
|
+
manifests.insert(0, root_manifest)
|
|
216
|
+
|
|
217
|
+
return manifests
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def node_dependency_names(manifest: Path) -> set[str]:
|
|
221
|
+
data = load_json(manifest)
|
|
222
|
+
names: set[str] = set()
|
|
223
|
+
for table_name in NODE_DEPENDENCY_TABLES:
|
|
224
|
+
table = data.get(table_name, {})
|
|
225
|
+
if isinstance(table, dict):
|
|
226
|
+
names.update(name for name in table.keys() if isinstance(name, str))
|
|
227
|
+
return names
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def node_source_files_under(module_dir: Path, project_root: Path) -> tuple[list[str], list[str]]:
|
|
231
|
+
source_root = module_dir / "src"
|
|
232
|
+
if not source_root.is_dir():
|
|
233
|
+
return ([], [])
|
|
234
|
+
|
|
235
|
+
source_files: list[str] = []
|
|
236
|
+
test_files: list[str] = []
|
|
237
|
+
|
|
238
|
+
for path in sorted(source_root.rglob("*"), key=lambda item: item.as_posix()):
|
|
239
|
+
if not path.is_file():
|
|
240
|
+
continue
|
|
241
|
+
if path.suffix not in NODE_SOURCE_EXTENSIONS or path.name.endswith(".d.ts"):
|
|
242
|
+
continue
|
|
243
|
+
if any(part in NODE_EXCLUDED_DIRS for part in path.relative_to(module_dir).parts):
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
rel = relative_path(path, project_root)
|
|
247
|
+
if any(marker in path.name for marker in NODE_TEST_MARKERS):
|
|
248
|
+
test_files.append(rel)
|
|
249
|
+
else:
|
|
250
|
+
source_files.append(rel)
|
|
251
|
+
|
|
252
|
+
tests_root = module_dir / "tests"
|
|
253
|
+
if tests_root.is_dir():
|
|
254
|
+
for path in sorted(tests_root.rglob("*"), key=lambda item: item.as_posix()):
|
|
255
|
+
if (
|
|
256
|
+
path.is_file()
|
|
257
|
+
and path.suffix in NODE_SOURCE_EXTENSIONS
|
|
258
|
+
and not path.name.endswith(".d.ts")
|
|
259
|
+
and not any(part in NODE_EXCLUDED_DIRS for part in path.relative_to(module_dir).parts)
|
|
260
|
+
):
|
|
261
|
+
test_files.append(relative_path(path, project_root))
|
|
262
|
+
|
|
263
|
+
return (source_files, sorted(set(test_files)))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def infer_node_layer(module_path: str, node_root: Path, project_root: Path) -> tuple[str, str]:
|
|
267
|
+
node_root_rel = relative_path(node_root, project_root)
|
|
268
|
+
if module_path == node_root_rel:
|
|
269
|
+
return ("root", node_root_rel)
|
|
270
|
+
|
|
271
|
+
relative_to_node_root = relative_path(project_root / module_path, node_root)
|
|
272
|
+
first = relative_to_node_root.split("/", 1)[0]
|
|
273
|
+
return (first, relative_path(node_root / first, project_root))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def node_package_records(node_root: Path) -> list[dict]:
|
|
277
|
+
manifests = node_workspace_manifests(node_root)
|
|
278
|
+
records: list[dict] = []
|
|
279
|
+
names_by_manifest: dict[Path, str] = {}
|
|
280
|
+
|
|
281
|
+
for manifest in manifests:
|
|
282
|
+
data = load_json(manifest)
|
|
283
|
+
name = data.get("name")
|
|
284
|
+
if isinstance(name, str) and name:
|
|
285
|
+
names_by_manifest[manifest] = name
|
|
286
|
+
|
|
287
|
+
workspace_names = set(names_by_manifest.values())
|
|
288
|
+
|
|
289
|
+
for manifest, name in names_by_manifest.items():
|
|
290
|
+
dependency_names = node_dependency_names(manifest) & workspace_names
|
|
291
|
+
records.append(
|
|
292
|
+
{
|
|
293
|
+
"name": name,
|
|
294
|
+
"manifest": manifest,
|
|
295
|
+
"workspaceDependencies": sorted(dependency_names),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return records
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def build_node_index(project_root: Path, node_root: Path) -> dict:
|
|
303
|
+
records = node_package_records(node_root)
|
|
304
|
+
has_workspaces = bool(workspace_patterns(load_json(node_root / "package.json")))
|
|
305
|
+
layers: list[dict] = []
|
|
306
|
+
layer_by_name: dict[str, dict] = {}
|
|
307
|
+
|
|
308
|
+
for record in records:
|
|
309
|
+
manifest_path = record["manifest"]
|
|
310
|
+
module_dir = manifest_path.parent
|
|
311
|
+
module_path = relative_path(module_dir, project_root)
|
|
312
|
+
manifest_rel = relative_path(manifest_path, project_root)
|
|
313
|
+
layer_name, layer_path = infer_node_layer(module_path, node_root, project_root)
|
|
314
|
+
source_files, test_files = node_source_files_under(module_dir, project_root)
|
|
315
|
+
|
|
316
|
+
if layer_name not in layer_by_name:
|
|
317
|
+
layer = {
|
|
318
|
+
"name": layer_name,
|
|
319
|
+
"path": layer_path,
|
|
320
|
+
"modules": [],
|
|
321
|
+
}
|
|
322
|
+
layer_by_name[layer_name] = layer
|
|
323
|
+
layers.append(layer)
|
|
324
|
+
|
|
325
|
+
layer_by_name[layer_name]["modules"].append(
|
|
326
|
+
{
|
|
327
|
+
"name": record["name"],
|
|
328
|
+
"path": module_path,
|
|
329
|
+
"manifest": manifest_rel,
|
|
330
|
+
"architectureDoc": f"{module_path}/ARCHITECTURE.md"
|
|
331
|
+
if module_path != "."
|
|
332
|
+
else "ARCHITECTURE.md",
|
|
333
|
+
"workspaceDependencies": record["workspaceDependencies"],
|
|
334
|
+
"language": "typescript",
|
|
335
|
+
"files": {
|
|
336
|
+
"source": source_files,
|
|
337
|
+
"tests": test_files,
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
"schemaVersion": 1,
|
|
344
|
+
"kind": "module-index",
|
|
345
|
+
"generatedBy": ".ai/tools/generate-module-index",
|
|
346
|
+
"workspace": {
|
|
347
|
+
"type": "npm-workspaces" if has_workspaces else "npm-package",
|
|
348
|
+
"root": relative_path(node_root, project_root),
|
|
349
|
+
"manifest": relative_path(node_root / "package.json", project_root),
|
|
350
|
+
},
|
|
351
|
+
"layerInference": {
|
|
352
|
+
"method": "path-first-segment-from-workspace-root",
|
|
353
|
+
"rootPackageLayer": "root",
|
|
354
|
+
},
|
|
355
|
+
"layers": layers,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
152
359
|
def package_records_from_metadata(project_root: Path, cargo_root: Path, metadata: dict) -> list[dict]:
|
|
153
360
|
workspace_root = Path(metadata["workspace_root"])
|
|
154
361
|
workspace_member_ids = set(metadata["workspace_members"])
|
|
@@ -201,8 +408,7 @@ def package_records_from_manifests(cargo_root: Path) -> list[dict]:
|
|
|
201
408
|
return records
|
|
202
409
|
|
|
203
410
|
|
|
204
|
-
def
|
|
205
|
-
cargo_root = find_cargo_root(project_root)
|
|
411
|
+
def build_cargo_index(project_root: Path, cargo_root: Path) -> dict:
|
|
206
412
|
metadata = load_cargo_metadata(cargo_root)
|
|
207
413
|
records = (
|
|
208
414
|
package_records_from_metadata(project_root, cargo_root, metadata)
|
|
@@ -262,8 +468,22 @@ def build_index(project_root: Path) -> dict:
|
|
|
262
468
|
}
|
|
263
469
|
|
|
264
470
|
|
|
471
|
+
def build_index(project_root: Path) -> dict:
|
|
472
|
+
cargo_root = maybe_find_cargo_root(project_root)
|
|
473
|
+
if cargo_root is not None:
|
|
474
|
+
return build_cargo_index(project_root, cargo_root)
|
|
475
|
+
|
|
476
|
+
node_root = maybe_find_node_root(project_root)
|
|
477
|
+
if node_root is not None:
|
|
478
|
+
return build_node_index(project_root, node_root)
|
|
479
|
+
|
|
480
|
+
raise SystemExit(
|
|
481
|
+
f"Could not find Cargo.toml or package.json in {project_root} or its direct child directories."
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
265
485
|
def main() -> int:
|
|
266
|
-
parser = argparse.ArgumentParser(description="Generate .ai/generated/module-index.json from
|
|
486
|
+
parser = argparse.ArgumentParser(description="Generate .ai/generated/module-index.json from Cargo or npm workspace metadata.")
|
|
267
487
|
parser.add_argument("--check", action="store_true", help="Fail if the generated module index differs from the current file.")
|
|
268
488
|
parser.add_argument("--print", action="store_true", help="Print generated JSON instead of writing it.")
|
|
269
489
|
parser.add_argument("--output", default=".ai/generated/module-index.json", help="Output path relative to the project root.")
|
|
@@ -23,6 +23,20 @@ BLOCK_ITEM_KEYWORDS = {
|
|
|
23
23
|
"union",
|
|
24
24
|
"macro_rules",
|
|
25
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*(?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)
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
@dataclass(frozen=True)
|
|
@@ -56,6 +70,24 @@ def load_module_index(root: Path) -> dict:
|
|
|
56
70
|
return json.loads(path.read_text())
|
|
57
71
|
|
|
58
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
|
+
|
|
59
91
|
def raw_string_end(source: str, index: int) -> int | None:
|
|
60
92
|
for prefix in ("br", "rb", "r"):
|
|
61
93
|
if not source.startswith(prefix, index):
|
|
@@ -633,13 +665,366 @@ def extract_public_items(root: Path, module: dict) -> list[dict]:
|
|
|
633
665
|
return unique_items
|
|
634
666
|
|
|
635
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
|
+
},
|
|
961
|
+
}
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
return items
|
|
965
|
+
|
|
966
|
+
|
|
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", [])
|
|
971
|
+
}
|
|
972
|
+
direct_definitions: dict[str, dict[str, dict]] = {}
|
|
973
|
+
|
|
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)
|
|
981
|
+
}
|
|
982
|
+
|
|
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
|
+
|
|
636
1016
|
def build_surface(root: Path) -> dict:
|
|
637
1017
|
module_index = load_module_index(root)
|
|
1018
|
+
is_typescript = module_index_is_typescript(module_index)
|
|
638
1019
|
modules = []
|
|
639
1020
|
|
|
640
1021
|
for layer in module_index.get("layers", []):
|
|
641
1022
|
for module in layer.get("modules", []):
|
|
642
|
-
public_items =
|
|
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
|
+
)
|
|
643
1028
|
modules.append(
|
|
644
1029
|
{
|
|
645
1030
|
"name": module["name"],
|
|
@@ -651,13 +1036,13 @@ def build_surface(root: Path) -> dict:
|
|
|
651
1036
|
"schemaVersion": 1,
|
|
652
1037
|
"kind": "public-surface",
|
|
653
1038
|
"generatedBy": ".ai/tools/generate-public-surface",
|
|
654
|
-
"visibility": "crate-external",
|
|
1039
|
+
"visibility": "project-public" if is_typescript else "crate-external",
|
|
655
1040
|
"modules": modules,
|
|
656
1041
|
}
|
|
657
1042
|
|
|
658
1043
|
|
|
659
1044
|
def main() -> int:
|
|
660
|
-
parser = argparse.ArgumentParser(description="Generate .ai/generated/public-surface.json from Rust source files.")
|
|
1045
|
+
parser = argparse.ArgumentParser(description="Generate .ai/generated/public-surface.json from Rust or TypeScript source files.")
|
|
661
1046
|
parser.add_argument("--check", action="store_true", help="Fail if the generated public surface differs from the current file.")
|
|
662
1047
|
parser.add_argument("--print", action="store_true", help="Print generated JSON instead of writing it.")
|
|
663
1048
|
parser.add_argument("--output", default=".ai/generated/public-surface.json", help="Output path relative to the project root.")
|