synorch 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/application/doctor-service.d.ts +24 -0
- package/dist/application/doctor-service.d.ts.map +1 -0
- package/dist/application/doctor-service.js +508 -0
- package/dist/application/doctor-service.js.map +1 -0
- package/dist/application/project-discovery.d.ts +23 -0
- package/dist/application/project-discovery.d.ts.map +1 -0
- package/dist/application/project-discovery.js +741 -0
- package/dist/application/project-discovery.js.map +1 -0
- package/dist/application/skill-resolver.d.ts +25 -0
- package/dist/application/skill-resolver.d.ts.map +1 -0
- package/dist/application/skill-resolver.js +90 -0
- package/dist/application/skill-resolver.js.map +1 -0
- package/dist/application/structure-service.d.ts +11 -0
- package/dist/application/structure-service.d.ts.map +1 -0
- package/dist/application/structure-service.js +116 -0
- package/dist/application/structure-service.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +170 -0
- package/dist/cli.js.map +1 -0
- package/dist/domain/config.d.ts +210 -0
- package/dist/domain/config.d.ts.map +1 -0
- package/dist/domain/config.js +108 -0
- package/dist/domain/config.js.map +1 -0
- package/dist/domain/errors.d.ts +5 -0
- package/dist/domain/errors.d.ts.map +1 -0
- package/dist/domain/errors.js +9 -0
- package/dist/domain/errors.js.map +1 -0
- package/dist/domain/generation.d.ts +21 -0
- package/dist/domain/generation.d.ts.map +1 -0
- package/dist/domain/generation.js +2 -0
- package/dist/domain/generation.js.map +1 -0
- package/dist/domain/product.d.ts +3 -0
- package/dist/domain/product.d.ts.map +1 -0
- package/dist/domain/product.js +3 -0
- package/dist/domain/product.js.map +1 -0
- package/dist/domain/skill-packs.d.ts +39 -0
- package/dist/domain/skill-packs.d.ts.map +1 -0
- package/dist/domain/skill-packs.js +98 -0
- package/dist/domain/skill-packs.js.map +1 -0
- package/dist/domain/skill-sources.d.ts +20 -0
- package/dist/domain/skill-sources.d.ts.map +1 -0
- package/dist/domain/skill-sources.js +95 -0
- package/dist/domain/skill-sources.js.map +1 -0
- package/dist/infrastructure/bundled-skill-library.d.ts +10 -0
- package/dist/infrastructure/bundled-skill-library.d.ts.map +1 -0
- package/dist/infrastructure/bundled-skill-library.js +108 -0
- package/dist/infrastructure/bundled-skill-library.js.map +1 -0
- package/dist/infrastructure/file-system.d.ts +21 -0
- package/dist/infrastructure/file-system.d.ts.map +1 -0
- package/dist/infrastructure/file-system.js +72 -0
- package/dist/infrastructure/file-system.js.map +1 -0
- package/dist/infrastructure/serialization.d.ts +3 -0
- package/dist/infrastructure/serialization.d.ts.map +1 -0
- package/dist/infrastructure/serialization.js +11 -0
- package/dist/infrastructure/serialization.js.map +1 -0
- package/dist/templates/structure-templates.d.ts +4 -0
- package/dist/templates/structure-templates.d.ts.map +1 -0
- package/dist/templates/structure-templates.js +471 -0
- package/dist/templates/structure-templates.js.map +1 -0
- package/dist/templates/technology-skill-templates.d.ts +3 -0
- package/dist/templates/technology-skill-templates.d.ts.map +1 -0
- package/dist/templates/technology-skill-templates.js +73 -0
- package/dist/templates/technology-skill-templates.js.map +1 -0
- package/package.json +59 -0
- package/skill-sources/ingenium/NOTICE.md +11 -0
- package/skill-sources/ingenium/skills/db-schema-craft/SKILL.md +127 -0
- package/skill-sources/ingenium/skills/debug-detective/SKILL.md +67 -0
- package/skill-sources/ingenium/skills/design-system/SKILL.md +57 -0
- package/skill-sources/ingenium/skills/docs-sync/SKILL.md +68 -0
- package/skill-sources/ingenium/skills/dotnet-backend/SKILL.md +110 -0
- package/skill-sources/ingenium/skills/frontend-craft/SKILL.md +69 -0
- package/skill-sources/ingenium/skills/game-audio/SKILL.md +73 -0
- package/skill-sources/ingenium/skills/game-design/SKILL.md +92 -0
- package/skill-sources/ingenium/skills/godot-dev/SKILL.md +78 -0
- package/skill-sources/ingenium/skills/human-made-design/SKILL.md +73 -0
- package/skill-sources/ingenium/skills/java-backend/SKILL.md +98 -0
- package/skill-sources/ingenium/skills/jev/SKILL.md +150 -0
- package/skill-sources/ingenium/skills/motion-craft/SKILL.md +66 -0
- package/skill-sources/ingenium/skills/multiplayer-netcode/SKILL.md +63 -0
- package/skill-sources/ingenium/skills/node-backend/SKILL.md +113 -0
- package/skill-sources/ingenium/skills/node-backend/reference.md +144 -0
- package/skill-sources/ingenium/skills/perf-audit/SKILL.md +70 -0
- package/skill-sources/ingenium/skills/pixel-art-assets/SKILL.md +91 -0
- package/skill-sources/ingenium/skills/pixel-art-assets/scripts/px.py +169 -0
- package/skill-sources/ingenium/skills/pixel-game-dev/SKILL.md +79 -0
- package/skill-sources/ingenium/skills/project-onboard/SKILL.md +78 -0
- package/skill-sources/ingenium/skills/pwa-offline/SKILL.md +70 -0
- package/skill-sources/ingenium/skills/query-tuning/SKILL.md +137 -0
- package/skill-sources/ingenium/skills/react-modern/SKILL.md +92 -0
- package/skill-sources/ingenium/skills/refactor-safe/SKILL.md +63 -0
- package/skill-sources/ingenium/skills/release-prep/SKILL.md +57 -0
- package/skill-sources/ingenium/skills/safe-merge/SKILL.md +91 -0
- package/skill-sources/ingenium/skills/session-recap/SKILL.md +95 -0
- package/skill-sources/ingenium/skills/session-recap/scripts/extract_session.py +409 -0
- package/skill-sources/ingenium/skills/shader-vfx/SKILL.md +70 -0
- package/skill-sources/ingenium/skills/tailwind-v4-tokens/SKILL.md +165 -0
- package/skill-sources/ingenium/skills/task-conductor/SKILL.md +158 -0
- package/skill-sources/ingenium/skills/tauri-game-dev/SKILL.md +81 -0
- package/skill-sources/ingenium/skills/ui-ux-design/SKILL.md +96 -0
- package/skill-sources/ingenium/skills/vue-modern/SKILL.md +88 -0
- package/skill-sources/ingenium/skills/web-kickoff/SKILL.md +63 -0
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { workspaceSchema } from "../domain/config.js";
|
|
3
|
+
import { CliError } from "../domain/errors.js";
|
|
4
|
+
import { loadBundledSkillPool } from "../infrastructure/bundled-skill-library.js";
|
|
5
|
+
import { parseYaml, stringifyYaml } from "../infrastructure/serialization.js";
|
|
6
|
+
import { resolveSkillPacks } from "./skill-resolver.js";
|
|
7
|
+
const DISCOVERY_FILES = [
|
|
8
|
+
"Cargo.toml",
|
|
9
|
+
"build.gradle",
|
|
10
|
+
"build.gradle.kts",
|
|
11
|
+
"bun.lock",
|
|
12
|
+
"bun.lockb",
|
|
13
|
+
"go.mod",
|
|
14
|
+
"package.json",
|
|
15
|
+
"package-lock.json",
|
|
16
|
+
"pnpm-lock.yaml",
|
|
17
|
+
"pom.xml",
|
|
18
|
+
"pyproject.toml",
|
|
19
|
+
"tsconfig.json",
|
|
20
|
+
"yarn.lock",
|
|
21
|
+
];
|
|
22
|
+
const DISCOVERY_FILE_NAMES = new Set(DISCOVERY_FILES);
|
|
23
|
+
const LOCKFILE_PACKAGE_MANAGERS = {
|
|
24
|
+
"bun.lock": "bun",
|
|
25
|
+
"bun.lockb": "bun",
|
|
26
|
+
"package-lock.json": "npm",
|
|
27
|
+
"pnpm-lock.yaml": "pnpm",
|
|
28
|
+
"yarn.lock": "yarn",
|
|
29
|
+
};
|
|
30
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
31
|
+
".ai",
|
|
32
|
+
".git",
|
|
33
|
+
"build",
|
|
34
|
+
"coverage",
|
|
35
|
+
"dist",
|
|
36
|
+
"node_modules",
|
|
37
|
+
"out",
|
|
38
|
+
"target",
|
|
39
|
+
]);
|
|
40
|
+
const SOURCE_TREE_DIRECTORIES = new Set([
|
|
41
|
+
"generated",
|
|
42
|
+
"generated-sources",
|
|
43
|
+
"generated-test-sources",
|
|
44
|
+
"src",
|
|
45
|
+
"test",
|
|
46
|
+
"tests",
|
|
47
|
+
]);
|
|
48
|
+
const MAX_DISCOVERY_DEPTH = 6;
|
|
49
|
+
const MAX_DISCOVERED_DIRECTORIES = 2_000;
|
|
50
|
+
const MAX_SOURCE_TREE_DIRECTORIES = 2_000;
|
|
51
|
+
export class ProjectDiscoveryService {
|
|
52
|
+
fileSystem;
|
|
53
|
+
constructor(fileSystem) {
|
|
54
|
+
this.fileSystem = fileSystem;
|
|
55
|
+
}
|
|
56
|
+
async sync(targetDirectory, options = {}) {
|
|
57
|
+
const root = path.resolve(targetDirectory);
|
|
58
|
+
const workspacePath = path.join(root, ".ai", "workspace.yaml");
|
|
59
|
+
await assertSafeGeneratedPath(this.fileSystem, root, workspacePath, ".ai/workspace.yaml");
|
|
60
|
+
if (!(await this.fileSystem.exists(workspacePath))) {
|
|
61
|
+
throw new CliError("Structure is not initialized. Run `syn init` first.", 2);
|
|
62
|
+
}
|
|
63
|
+
const currentWorkspace = workspaceSchema.parse(parseYaml(await this.fileSystem.readText(workspacePath)));
|
|
64
|
+
const candidates = await this.findCandidates(root, currentWorkspace.scope);
|
|
65
|
+
const inspectedProjects = [];
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const identity = projectIdentity(root, candidate);
|
|
68
|
+
const detectedAt = await this.readPreviousDetectedAt(root, currentWorkspace, identity);
|
|
69
|
+
inspectedProjects.push(await this.inspectProject(root, candidate, detectedAt));
|
|
70
|
+
}
|
|
71
|
+
assertUniqueProjectIdentifiers(inspectedProjects);
|
|
72
|
+
const preparedProjects = [];
|
|
73
|
+
const desiredSkills = new Map();
|
|
74
|
+
const bundledPool = await loadBundledSkillPool();
|
|
75
|
+
desiredSkills.set(".ai/skills/catalog.yaml", bundledPool.catalogContent);
|
|
76
|
+
for (const file of bundledPool.files) {
|
|
77
|
+
desiredSkills.set(file.relativePath, file.content);
|
|
78
|
+
}
|
|
79
|
+
for (const inspected of inspectedProjects) {
|
|
80
|
+
const skillRegistryPath = `.ai/projects/${inspected.id}.skills.yaml`;
|
|
81
|
+
const record = { ...inspected, skill_registry: skillRegistryPath };
|
|
82
|
+
const skillResolution = resolveSkillPacks(record.modules);
|
|
83
|
+
for (const skill of skillResolution.technologySkills) {
|
|
84
|
+
if (skill.content === null) {
|
|
85
|
+
if (skill.sourceId !== null && desiredSkills.has(skill.relativePath))
|
|
86
|
+
continue;
|
|
87
|
+
throw new Error(`Technology skill ${skill.id} has no materializable content.`);
|
|
88
|
+
}
|
|
89
|
+
const existing = desiredSkills.get(skill.relativePath);
|
|
90
|
+
if (existing !== undefined &&
|
|
91
|
+
normalizeLineEndings(existing) !== normalizeLineEndings(skill.content)) {
|
|
92
|
+
throw new CliError(`Technology skill output collision at ${skill.relativePath}.`, 2);
|
|
93
|
+
}
|
|
94
|
+
desiredSkills.set(skill.relativePath, skill.content);
|
|
95
|
+
}
|
|
96
|
+
preparedProjects.push({
|
|
97
|
+
record,
|
|
98
|
+
registry: {
|
|
99
|
+
schema_version: 1,
|
|
100
|
+
project_id: record.id,
|
|
101
|
+
base_skills: skillResolution.baseSkills.map((skill) => ({
|
|
102
|
+
id: skill.id,
|
|
103
|
+
relative_path: skill.relativePath,
|
|
104
|
+
})),
|
|
105
|
+
technology_skills: skillResolution.technologySkills.map((skill) => ({
|
|
106
|
+
id: skill.id,
|
|
107
|
+
pack_id: requiredPackId(skill.id, skill.packId),
|
|
108
|
+
source_id: skill.sourceId,
|
|
109
|
+
relative_path: skill.relativePath,
|
|
110
|
+
reasons: [...skill.reasons],
|
|
111
|
+
})),
|
|
112
|
+
selected_packs: skillResolution.selectedPacks.map((pack) => ({
|
|
113
|
+
id: pack.id,
|
|
114
|
+
skill_ids: [...pack.skillIds],
|
|
115
|
+
matched_evidence: pack.matchedEvidence.map((evidence) => ({
|
|
116
|
+
module_id: evidence.moduleId,
|
|
117
|
+
module_path: evidence.modulePath,
|
|
118
|
+
fact: evidence.fact,
|
|
119
|
+
})),
|
|
120
|
+
})),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const skillWrites = [];
|
|
125
|
+
for (const [relativePath, content] of [...desiredSkills.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
126
|
+
const absolutePath = await resolveSafeGeneratedPath(this.fileSystem, root, relativePath);
|
|
127
|
+
if (await this.fileSystem.exists(absolutePath)) {
|
|
128
|
+
const current = await this.fileSystem.readText(absolutePath);
|
|
129
|
+
if (normalizeLineEndings(current) === normalizeLineEndings(content))
|
|
130
|
+
continue;
|
|
131
|
+
if (options.force !== true) {
|
|
132
|
+
throw new CliError(`Sync stopped because generated skill file differs: ${relativePath}\nRe-run with --force only after reviewing this file.`, 2);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
skillWrites.push({ absolutePath, content, relativePath });
|
|
136
|
+
}
|
|
137
|
+
for (const prepared of preparedProjects) {
|
|
138
|
+
await resolveSafeGeneratedPath(this.fileSystem, root, `.ai/projects/${prepared.record.id}.yaml`);
|
|
139
|
+
await resolveSafeGeneratedPath(this.fileSystem, root, prepared.record.skill_registry);
|
|
140
|
+
}
|
|
141
|
+
await resolveSafeGeneratedPath(this.fileSystem, root, ".ai/workspace.yaml");
|
|
142
|
+
const writtenFiles = [];
|
|
143
|
+
const writtenFileSet = new Set();
|
|
144
|
+
for (const skill of skillWrites) {
|
|
145
|
+
await assertSafeGeneratedPath(this.fileSystem, root, skill.absolutePath, skill.relativePath);
|
|
146
|
+
await this.fileSystem.writeText(skill.absolutePath, skill.content);
|
|
147
|
+
addWrittenFile(writtenFiles, writtenFileSet, skill.relativePath);
|
|
148
|
+
}
|
|
149
|
+
for (const prepared of preparedProjects) {
|
|
150
|
+
const recordPath = `.ai/projects/${prepared.record.id}.yaml`;
|
|
151
|
+
await writeIfChanged(this.fileSystem, root, resolveWithinRoot(root, recordPath), stringifyYaml(prepared.record), recordPath, writtenFiles, writtenFileSet);
|
|
152
|
+
}
|
|
153
|
+
const projects = preparedProjects.map((item) => item.record);
|
|
154
|
+
const workspace = {
|
|
155
|
+
schema_version: 1,
|
|
156
|
+
scope: currentWorkspace.scope,
|
|
157
|
+
projects: projects.map((project) => ({
|
|
158
|
+
id: project.id,
|
|
159
|
+
path: project.path,
|
|
160
|
+
record: `.ai/projects/${project.id}.yaml`,
|
|
161
|
+
})),
|
|
162
|
+
};
|
|
163
|
+
await writeIfChanged(this.fileSystem, root, workspacePath, stringifyYaml(workspace), ".ai/workspace.yaml", writtenFiles, writtenFileSet);
|
|
164
|
+
for (const prepared of preparedProjects) {
|
|
165
|
+
await writeIfChanged(this.fileSystem, root, resolveWithinRoot(root, prepared.record.skill_registry), stringifyYaml(prepared.registry), prepared.record.skill_registry, writtenFiles, writtenFileSet);
|
|
166
|
+
}
|
|
167
|
+
return { projects, writtenFiles };
|
|
168
|
+
}
|
|
169
|
+
async readPreviousDetectedAt(root, workspace, identity) {
|
|
170
|
+
const entry = workspace.projects.find((project) => project.id === identity.id && project.path === identity.path);
|
|
171
|
+
if (entry === undefined)
|
|
172
|
+
return undefined;
|
|
173
|
+
const recordPath = await resolveSafeGeneratedPath(this.fileSystem, root, entry.record);
|
|
174
|
+
if (!(await this.fileSystem.exists(recordPath)))
|
|
175
|
+
return undefined;
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = parseYaml(await this.fileSystem.readText(recordPath));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
182
|
+
throw new CliError(`Invalid existing project record at ${entry.record}: ${detail}`, 2);
|
|
183
|
+
}
|
|
184
|
+
const record = asRecord(parsed);
|
|
185
|
+
if (record["id"] !== identity.id || record["path"] !== identity.path) {
|
|
186
|
+
throw new CliError(`Existing project record identity mismatch at ${entry.record}.`, 2);
|
|
187
|
+
}
|
|
188
|
+
const detectedAt = record["detected_at"];
|
|
189
|
+
return typeof detectedAt === "string" && !Number.isNaN(Date.parse(detectedAt))
|
|
190
|
+
? detectedAt
|
|
191
|
+
: undefined;
|
|
192
|
+
}
|
|
193
|
+
async findCandidates(root, scope) {
|
|
194
|
+
if (scope === "repository") {
|
|
195
|
+
return [root];
|
|
196
|
+
}
|
|
197
|
+
const candidates = [];
|
|
198
|
+
const entries = [...(await this.fileSystem.list(root))].sort((left, right) => left.name.localeCompare(right.name));
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
if (!entry.isDirectory || IGNORED_DIRECTORIES.has(entry.name)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const candidate = path.join(root, entry.name);
|
|
204
|
+
if (await this.isProject(candidate)) {
|
|
205
|
+
candidates.push(candidate);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return candidates;
|
|
209
|
+
}
|
|
210
|
+
async isProject(directory) {
|
|
211
|
+
if (await this.fileSystem.exists(path.join(directory, ".git"))) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
return (await this.findDiscoveryFileNames(directory)).length > 0;
|
|
215
|
+
}
|
|
216
|
+
async inspectProject(root, directory, detectedAt) {
|
|
217
|
+
const { id, path: relativePath } = projectIdentity(root, directory);
|
|
218
|
+
const modules = await this.discoverModules(root, directory);
|
|
219
|
+
assertUniqueModuleIdentifiers(id, modules);
|
|
220
|
+
return {
|
|
221
|
+
id,
|
|
222
|
+
path: relativePath,
|
|
223
|
+
detected_at: detectedAt ?? new Date().toISOString(),
|
|
224
|
+
repository: { git: await this.fileSystem.exists(path.join(directory, ".git")) },
|
|
225
|
+
stack: aggregateStack(modules),
|
|
226
|
+
commands: aggregateCommands(modules),
|
|
227
|
+
manifests: sortedUnique(modules.flatMap((module) => module.manifests)),
|
|
228
|
+
evidence: sortEvidence(modules.flatMap((module) => module.evidence)),
|
|
229
|
+
modules,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async discoverModules(root, projectDirectory) {
|
|
233
|
+
const directories = [
|
|
234
|
+
{ directory: projectDirectory, depth: 0 },
|
|
235
|
+
];
|
|
236
|
+
const modules = [];
|
|
237
|
+
let enqueuedDirectories = 1;
|
|
238
|
+
while (directories.length > 0) {
|
|
239
|
+
const current = directories.shift();
|
|
240
|
+
if (current === undefined)
|
|
241
|
+
break;
|
|
242
|
+
const discoveryFiles = await this.findDiscoveryFileNames(current.directory);
|
|
243
|
+
if (discoveryFiles.length > 0) {
|
|
244
|
+
modules.push(await this.inspectModule(root, current.directory, discoveryFiles));
|
|
245
|
+
}
|
|
246
|
+
const entries = [...(await this.fileSystem.list(current.directory))].sort((left, right) => left.name.localeCompare(right.name));
|
|
247
|
+
const childDirectories = [];
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
if (!entry.isDirectory || IGNORED_DIRECTORIES.has(entry.name))
|
|
250
|
+
continue;
|
|
251
|
+
if (SOURCE_TREE_DIRECTORIES.has(entry.name)) {
|
|
252
|
+
const sourceTreePath = path.join(current.directory, entry.name);
|
|
253
|
+
if ((await this.findDiscoveryFileNames(sourceTreePath)).length === 0) {
|
|
254
|
+
modules.push(...(await this.discoverModulesInSourceTree(root, sourceTreePath)));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
childDirectories.push(entry);
|
|
259
|
+
}
|
|
260
|
+
if (current.depth >= MAX_DISCOVERY_DEPTH) {
|
|
261
|
+
if (childDirectories.length > 0) {
|
|
262
|
+
const relative = normalizeRelativePath(path.relative(root, current.directory) || ".");
|
|
263
|
+
throw new CliError(`Project discovery exceeded maximum depth ${MAX_DISCOVERY_DEPTH} at ${relative}.`, 2);
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
for (const entry of childDirectories) {
|
|
268
|
+
if (enqueuedDirectories >= MAX_DISCOVERED_DIRECTORIES) {
|
|
269
|
+
throw new CliError(`Project discovery exceeded the ${MAX_DISCOVERED_DIRECTORIES}-directory safety limit.`, 2);
|
|
270
|
+
}
|
|
271
|
+
directories.push({
|
|
272
|
+
directory: path.join(current.directory, entry.name),
|
|
273
|
+
depth: current.depth + 1,
|
|
274
|
+
});
|
|
275
|
+
enqueuedDirectories += 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return modules.sort((left, right) => left.path.localeCompare(right.path));
|
|
279
|
+
}
|
|
280
|
+
async discoverModulesInSourceTree(root, sourceTree) {
|
|
281
|
+
const directories = [sourceTree];
|
|
282
|
+
const modules = [];
|
|
283
|
+
let inspectedDirectories = 0;
|
|
284
|
+
while (directories.length > 0) {
|
|
285
|
+
if (inspectedDirectories >= MAX_SOURCE_TREE_DIRECTORIES) {
|
|
286
|
+
const relative = normalizeRelativePath(path.relative(root, sourceTree));
|
|
287
|
+
throw new CliError(`Source-tree module scan exceeded ${MAX_SOURCE_TREE_DIRECTORIES} directories at ${relative}.`, 2);
|
|
288
|
+
}
|
|
289
|
+
const directory = directories.shift();
|
|
290
|
+
if (directory === undefined)
|
|
291
|
+
break;
|
|
292
|
+
inspectedDirectories += 1;
|
|
293
|
+
const entries = [...(await this.fileSystem.list(directory))].sort((left, right) => left.name.localeCompare(right.name));
|
|
294
|
+
const discoveryFiles = entries
|
|
295
|
+
.filter((entry) => !entry.isDirectory && DISCOVERY_FILE_NAMES.has(entry.name))
|
|
296
|
+
.map((entry) => entry.name);
|
|
297
|
+
if (discoveryFiles.length > 0) {
|
|
298
|
+
modules.push(await this.inspectModule(root, directory, discoveryFiles));
|
|
299
|
+
}
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
if (entry.isDirectory && !IGNORED_DIRECTORIES.has(entry.name)) {
|
|
302
|
+
directories.push(path.join(directory, entry.name));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return modules;
|
|
307
|
+
}
|
|
308
|
+
async findDiscoveryFileNames(directory) {
|
|
309
|
+
const files = [];
|
|
310
|
+
for (const fileName of DISCOVERY_FILES) {
|
|
311
|
+
if (await this.fileSystem.exists(path.join(directory, fileName)))
|
|
312
|
+
files.push(fileName);
|
|
313
|
+
}
|
|
314
|
+
return files;
|
|
315
|
+
}
|
|
316
|
+
async inspectModule(root, directory, discoveryFiles) {
|
|
317
|
+
const relativePath = normalizeRelativePath(path.relative(root, directory) || ".");
|
|
318
|
+
const languages = new Set();
|
|
319
|
+
const frameworks = new Set();
|
|
320
|
+
const buildTools = new Set();
|
|
321
|
+
const commands = {};
|
|
322
|
+
const evidence = [];
|
|
323
|
+
let packageJson;
|
|
324
|
+
let packageSource;
|
|
325
|
+
for (const fileName of discoveryFiles) {
|
|
326
|
+
const filePath = path.join(directory, fileName);
|
|
327
|
+
const source = normalizeRelativePath(path.relative(root, filePath));
|
|
328
|
+
addEvidence(evidence, "manifest", fileName, source);
|
|
329
|
+
if (fileName === "package.json") {
|
|
330
|
+
languages.add("javascript");
|
|
331
|
+
addEvidence(evidence, "language", "javascript", source);
|
|
332
|
+
packageJson = parsePackageJson(await this.fileSystem.readText(filePath), filePath);
|
|
333
|
+
packageSource = source;
|
|
334
|
+
for (const dependency of Object.keys(allDependencies(packageJson)).sort((left, right) => left.localeCompare(right))) {
|
|
335
|
+
addEvidence(evidence, "dependency", dependency, source);
|
|
336
|
+
}
|
|
337
|
+
if (packageJsonHasTypeScript(packageJson)) {
|
|
338
|
+
languages.add("typescript");
|
|
339
|
+
addEvidence(evidence, "language", "typescript", source);
|
|
340
|
+
}
|
|
341
|
+
for (const framework of detectJavaScriptFrameworks(packageJson)) {
|
|
342
|
+
frameworks.add(framework);
|
|
343
|
+
addEvidence(evidence, "framework", framework, source);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (fileName === "tsconfig.json") {
|
|
347
|
+
languages.add("typescript");
|
|
348
|
+
addEvidence(evidence, "language", "typescript", source);
|
|
349
|
+
}
|
|
350
|
+
if (fileName === "pom.xml") {
|
|
351
|
+
languages.add("java");
|
|
352
|
+
buildTools.add("maven");
|
|
353
|
+
addEvidence(evidence, "language", "java", source);
|
|
354
|
+
addEvidence(evidence, "build_tool", "maven", source);
|
|
355
|
+
detectMavenFacts(await this.fileSystem.readText(filePath), frameworks, evidence, source);
|
|
356
|
+
const executable = (await this.fileSystem.exists(path.join(directory, "mvnw")))
|
|
357
|
+
? "./mvnw"
|
|
358
|
+
: "mvn";
|
|
359
|
+
commands["test"] = verifiedCommand(`${executable} test`, source, relativePath);
|
|
360
|
+
commands["build"] = verifiedCommand(`${executable} verify`, source, relativePath);
|
|
361
|
+
}
|
|
362
|
+
if (fileName === "build.gradle" || fileName === "build.gradle.kts") {
|
|
363
|
+
buildTools.add("gradle");
|
|
364
|
+
addEvidence(evidence, "build_tool", "gradle", source);
|
|
365
|
+
const content = await this.fileSystem.readText(filePath);
|
|
366
|
+
if (detectGradleFacts(content, frameworks, evidence, source)) {
|
|
367
|
+
languages.add("java");
|
|
368
|
+
addEvidence(evidence, "language", "java", source);
|
|
369
|
+
}
|
|
370
|
+
const executable = (await this.fileSystem.exists(path.join(directory, "gradlew")))
|
|
371
|
+
? "./gradlew"
|
|
372
|
+
: "gradle";
|
|
373
|
+
commands["test"] = verifiedCommand(`${executable} test`, source, relativePath);
|
|
374
|
+
commands["build"] = verifiedCommand(`${executable} build`, source, relativePath);
|
|
375
|
+
}
|
|
376
|
+
if (fileName === "pyproject.toml") {
|
|
377
|
+
languages.add("python");
|
|
378
|
+
addEvidence(evidence, "language", "python", source);
|
|
379
|
+
}
|
|
380
|
+
if (fileName === "Cargo.toml") {
|
|
381
|
+
languages.add("rust");
|
|
382
|
+
buildTools.add("cargo");
|
|
383
|
+
addEvidence(evidence, "language", "rust", source);
|
|
384
|
+
addEvidence(evidence, "build_tool", "cargo", source);
|
|
385
|
+
commands["test"] = verifiedCommand("cargo test", source, relativePath);
|
|
386
|
+
commands["build"] = verifiedCommand("cargo build", source, relativePath);
|
|
387
|
+
}
|
|
388
|
+
if (fileName === "go.mod") {
|
|
389
|
+
languages.add("go");
|
|
390
|
+
addEvidence(evidence, "language", "go", source);
|
|
391
|
+
commands["test"] = verifiedCommand("go test ./...", source, relativePath);
|
|
392
|
+
commands["build"] = verifiedCommand("go build ./...", source, relativePath);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const packageManager = detectPackageManager(packageJson, discoveryFiles);
|
|
396
|
+
if (packageManager !== null) {
|
|
397
|
+
const managerSource = normalizeRelativePath(path.relative(root, path.join(directory, packageManager.sourceFile)));
|
|
398
|
+
addEvidence(evidence, "package_manager", packageManager.value, managerSource);
|
|
399
|
+
if (packageJson !== undefined && packageSource !== undefined) {
|
|
400
|
+
for (const [name] of Object.entries(readScripts(packageJson)).sort(([left], [right]) => left.localeCompare(right))) {
|
|
401
|
+
commands[name] = verifiedCommand(packageScriptCommand(packageManager.value, name), packageSource, relativePath);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
id: createProjectId(relativePath === "." ? path.basename(root) : relativePath),
|
|
407
|
+
path: relativePath,
|
|
408
|
+
manifests: discoveryFiles
|
|
409
|
+
.map((fileName) => normalizeRelativePath(path.relative(root, path.join(directory, fileName))))
|
|
410
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
411
|
+
stack: {
|
|
412
|
+
languages: [...languages].sort((left, right) => left.localeCompare(right)),
|
|
413
|
+
frameworks: [...frameworks].sort((left, right) => left.localeCompare(right)),
|
|
414
|
+
package_manager: packageManager?.value ?? null,
|
|
415
|
+
build_tool: buildTools.size === 1 ? [...buildTools][0] ?? null : null,
|
|
416
|
+
},
|
|
417
|
+
commands: sortCommands(commands),
|
|
418
|
+
evidence: sortEvidence(evidence),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function addWrittenFile(paths, seen, relativePath) {
|
|
423
|
+
if (seen.has(relativePath))
|
|
424
|
+
return;
|
|
425
|
+
seen.add(relativePath);
|
|
426
|
+
paths.push(relativePath);
|
|
427
|
+
}
|
|
428
|
+
function requiredPackId(skillId, packId) {
|
|
429
|
+
if (packId === null) {
|
|
430
|
+
throw new Error("Technology skill " + skillId + " is missing its pack id.");
|
|
431
|
+
}
|
|
432
|
+
return packId;
|
|
433
|
+
}
|
|
434
|
+
function parsePackageJson(content, packagePath) {
|
|
435
|
+
try {
|
|
436
|
+
const parsed = JSON.parse(content);
|
|
437
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
438
|
+
throw new Error("expected a JSON object");
|
|
439
|
+
}
|
|
440
|
+
return parsed;
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
444
|
+
throw new CliError(`Invalid package.json at ${packagePath}: ${detail}`, 2);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function packageJsonHasTypeScript(packageJson) {
|
|
448
|
+
const dependencies = allDependencies(packageJson);
|
|
449
|
+
return "typescript" in dependencies || "tsx" in dependencies || "ts-node" in dependencies;
|
|
450
|
+
}
|
|
451
|
+
function detectPackageManager(packageJson, discoveryFiles) {
|
|
452
|
+
const declared = packageJson?.["packageManager"];
|
|
453
|
+
if (typeof declared === "string") {
|
|
454
|
+
const value = declared.split("@")[0]?.trim();
|
|
455
|
+
if (value !== undefined && value.length > 0)
|
|
456
|
+
return { value, sourceFile: "package.json" };
|
|
457
|
+
}
|
|
458
|
+
const lockfiles = discoveryFiles
|
|
459
|
+
.map((sourceFile) => ({ sourceFile, value: LOCKFILE_PACKAGE_MANAGERS[sourceFile] }))
|
|
460
|
+
.filter((item) => item.value !== undefined);
|
|
461
|
+
const managers = sortedUnique(lockfiles.map((item) => item.value));
|
|
462
|
+
if (managers.length !== 1)
|
|
463
|
+
return null;
|
|
464
|
+
const value = managers[0];
|
|
465
|
+
const match = lockfiles.find((item) => item.value === value);
|
|
466
|
+
return value === undefined || match === undefined ? null : { value, sourceFile: match.sourceFile };
|
|
467
|
+
}
|
|
468
|
+
function packageScriptCommand(packageManager, scriptName) {
|
|
469
|
+
return packageManager === "npm"
|
|
470
|
+
? `npm run ${scriptName}`
|
|
471
|
+
: `${packageManager} ${scriptName}`;
|
|
472
|
+
}
|
|
473
|
+
function detectJavaScriptFrameworks(packageJson) {
|
|
474
|
+
const dependencies = allDependencies(packageJson);
|
|
475
|
+
const knownFrameworks = {
|
|
476
|
+
next: "nextjs",
|
|
477
|
+
react: "react",
|
|
478
|
+
vue: "vue",
|
|
479
|
+
nuxt: "nuxt",
|
|
480
|
+
"@angular/core": "angular",
|
|
481
|
+
express: "express",
|
|
482
|
+
fastify: "fastify",
|
|
483
|
+
"@nestjs/core": "nestjs",
|
|
484
|
+
};
|
|
485
|
+
return Object.entries(knownFrameworks)
|
|
486
|
+
.filter(([dependency]) => dependency in dependencies)
|
|
487
|
+
.map(([, framework]) => framework)
|
|
488
|
+
.sort((left, right) => left.localeCompare(right));
|
|
489
|
+
}
|
|
490
|
+
function detectMavenFacts(content, frameworks, evidence, source) {
|
|
491
|
+
const xml = content.replace(/<!--[\s\S]*?-->/g, "");
|
|
492
|
+
const activeXml = removeXmlElementBlocks(removeXmlElementBlocks(xml, "dependencyManagement"), "pluginManagement");
|
|
493
|
+
const dependencies = extractXmlCoordinates(activeXml, "dependency");
|
|
494
|
+
const plugins = extractXmlCoordinates(activeXml, "plugin");
|
|
495
|
+
const parents = extractXmlCoordinates(xml, "parent");
|
|
496
|
+
for (const coordinate of dependencies) {
|
|
497
|
+
addEvidence(evidence, "dependency", coordinate, source);
|
|
498
|
+
}
|
|
499
|
+
const normalizedDependencies = new Set(dependencies.map((value) => value.toLowerCase()));
|
|
500
|
+
const springBoot = [...normalizedDependencies].some((coordinate) => {
|
|
501
|
+
const [group, artifact] = coordinate.split(":");
|
|
502
|
+
return group === "org.springframework.boot" && artifact?.startsWith("spring-boot") === true;
|
|
503
|
+
}) ||
|
|
504
|
+
plugins.some((coordinate) => coordinate.toLowerCase() === "org.springframework.boot:spring-boot-maven-plugin") ||
|
|
505
|
+
parents.some((coordinate) => coordinate.toLowerCase() === "org.springframework.boot:spring-boot-starter-parent");
|
|
506
|
+
if (springBoot) {
|
|
507
|
+
frameworks.add("spring-boot");
|
|
508
|
+
addEvidence(evidence, "framework", "spring-boot", source);
|
|
509
|
+
}
|
|
510
|
+
if ([...normalizedDependencies].some(isJpaCoordinate)) {
|
|
511
|
+
frameworks.add("jpa");
|
|
512
|
+
addEvidence(evidence, "framework", "jpa", source);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function removeXmlElementBlocks(content, element) {
|
|
516
|
+
return content.replace(new RegExp(`<${element}\\b[^>]*>[\\s\\S]*?<\\/${element}>`, "gi"), "");
|
|
517
|
+
}
|
|
518
|
+
function extractXmlCoordinates(content, element) {
|
|
519
|
+
const coordinates = [];
|
|
520
|
+
const blocks = new RegExp(`<${element}\\b[^>]*>([\\s\\S]*?)<\\/${element}>`, "gi");
|
|
521
|
+
for (const match of content.matchAll(blocks)) {
|
|
522
|
+
const block = match[1];
|
|
523
|
+
if (block === undefined)
|
|
524
|
+
continue;
|
|
525
|
+
const group = readXmlElement(block, "groupId");
|
|
526
|
+
const artifact = readXmlElement(block, "artifactId");
|
|
527
|
+
if (group !== null && artifact !== null)
|
|
528
|
+
coordinates.push(`${group}:${artifact}`);
|
|
529
|
+
}
|
|
530
|
+
return sortedUnique(coordinates);
|
|
531
|
+
}
|
|
532
|
+
function readXmlElement(content, element) {
|
|
533
|
+
const match = new RegExp(`<${element}\\b[^>]*>\\s*([^<]+?)\\s*<\\/${element}>`, "i").exec(content);
|
|
534
|
+
const value = match?.[1]?.trim();
|
|
535
|
+
return value === undefined || value.length === 0 ? null : value;
|
|
536
|
+
}
|
|
537
|
+
function detectGradleFacts(content, frameworks, evidence, source) {
|
|
538
|
+
const gradle = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\r\n]*/g, "");
|
|
539
|
+
const pluginIds = new Set();
|
|
540
|
+
for (const match of gradle.matchAll(/\bid\s*(?:\(\s*)?["']([^"']+)["']\s*\)?/g)) {
|
|
541
|
+
if (match[1] !== undefined)
|
|
542
|
+
pluginIds.add(match[1].trim().toLowerCase());
|
|
543
|
+
}
|
|
544
|
+
for (const match of gradle.matchAll(/\bapply\s+plugin\s*:\s*["']([^"']+)["']/g)) {
|
|
545
|
+
if (match[1] !== undefined)
|
|
546
|
+
pluginIds.add(match[1].trim().toLowerCase());
|
|
547
|
+
}
|
|
548
|
+
if (/(?:^|[\r\n])\s*java\s*(?:[\r\n}]|$)/m.test(gradle))
|
|
549
|
+
pluginIds.add("java");
|
|
550
|
+
const dependencies = [];
|
|
551
|
+
const dependencyPattern = /\b(?:api|implementation|compileOnly|runtimeOnly|testImplementation|annotationProcessor)\s*(?:\(\s*)?["']([^"'\r\n]+)["']/g;
|
|
552
|
+
for (const match of gradle.matchAll(dependencyPattern)) {
|
|
553
|
+
const raw = match[1];
|
|
554
|
+
if (raw === undefined || raw.includes("$"))
|
|
555
|
+
continue;
|
|
556
|
+
const [group, artifact] = raw.split(":");
|
|
557
|
+
if (group !== undefined &&
|
|
558
|
+
artifact !== undefined &&
|
|
559
|
+
group.length > 0 &&
|
|
560
|
+
artifact.length > 0) {
|
|
561
|
+
dependencies.push(`${group}:${artifact}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const coordinate of sortedUnique(dependencies)) {
|
|
565
|
+
addEvidence(evidence, "dependency", coordinate, source);
|
|
566
|
+
}
|
|
567
|
+
const normalizedDependencies = new Set(dependencies.map((value) => value.toLowerCase()));
|
|
568
|
+
const springBoot = pluginIds.has("org.springframework.boot") ||
|
|
569
|
+
[...normalizedDependencies].some((coordinate) => {
|
|
570
|
+
const [group, artifact] = coordinate.split(":");
|
|
571
|
+
return group === "org.springframework.boot" && artifact?.startsWith("spring-boot") === true;
|
|
572
|
+
});
|
|
573
|
+
if (springBoot) {
|
|
574
|
+
frameworks.add("spring-boot");
|
|
575
|
+
addEvidence(evidence, "framework", "spring-boot", source);
|
|
576
|
+
}
|
|
577
|
+
if ([...normalizedDependencies].some(isJpaCoordinate)) {
|
|
578
|
+
frameworks.add("jpa");
|
|
579
|
+
addEvidence(evidence, "framework", "jpa", source);
|
|
580
|
+
}
|
|
581
|
+
return pluginIds.has("java") || pluginIds.has("java-library") || springBoot;
|
|
582
|
+
}
|
|
583
|
+
function isJpaCoordinate(coordinate) {
|
|
584
|
+
return new Set([
|
|
585
|
+
"jakarta.persistence:jakarta.persistence-api",
|
|
586
|
+
"javax.persistence:javax.persistence-api",
|
|
587
|
+
"org.hibernate:hibernate-core",
|
|
588
|
+
"org.hibernate.orm:hibernate-core",
|
|
589
|
+
"org.springframework.boot:spring-boot-starter-data-jpa",
|
|
590
|
+
]).has(coordinate);
|
|
591
|
+
}
|
|
592
|
+
function allDependencies(packageJson) {
|
|
593
|
+
return {
|
|
594
|
+
...asRecord(packageJson["dependencies"]),
|
|
595
|
+
...asRecord(packageJson["devDependencies"]),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function readScripts(packageJson) {
|
|
599
|
+
const scripts = asRecord(packageJson["scripts"]);
|
|
600
|
+
return Object.fromEntries(Object.entries(scripts).filter((entry) => typeof entry[1] === "string"));
|
|
601
|
+
}
|
|
602
|
+
function asRecord(value) {
|
|
603
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
604
|
+
? value
|
|
605
|
+
: {};
|
|
606
|
+
}
|
|
607
|
+
function aggregateStack(modules) {
|
|
608
|
+
const languages = sortedUnique(modules.flatMap((module) => module.stack.languages));
|
|
609
|
+
const frameworks = sortedUnique(modules.flatMap((module) => module.stack.frameworks));
|
|
610
|
+
const packageManagers = sortedUnique(modules.flatMap((module) => module.stack.package_manager === null ? [] : [module.stack.package_manager]));
|
|
611
|
+
const buildTools = sortedUnique(modules.flatMap((module) => (module.stack.build_tool === null ? [] : [module.stack.build_tool])));
|
|
612
|
+
return {
|
|
613
|
+
languages,
|
|
614
|
+
frameworks,
|
|
615
|
+
package_manager: packageManagers.length === 1 ? packageManagers[0] ?? null : null,
|
|
616
|
+
build_tool: buildTools.length === 1 ? buildTools[0] ?? null : null,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function aggregateCommands(modules) {
|
|
620
|
+
const occurrences = new Map();
|
|
621
|
+
for (const module of modules) {
|
|
622
|
+
for (const [name, value] of Object.entries(module.commands)) {
|
|
623
|
+
const current = occurrences.get(name) ?? [];
|
|
624
|
+
current.push({ module, value });
|
|
625
|
+
occurrences.set(name, current);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const commands = {};
|
|
629
|
+
for (const [name, values] of [...occurrences.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
630
|
+
if (values.length === 1) {
|
|
631
|
+
const only = values[0];
|
|
632
|
+
if (only !== undefined)
|
|
633
|
+
commands[name] = only.value;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
for (const { module, value } of values.sort((left, right) => left.module.path.localeCompare(right.module.path))) {
|
|
637
|
+
commands[`${module.path}:${name}`] = value;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return sortCommands(commands);
|
|
641
|
+
}
|
|
642
|
+
function verifiedCommand(value, source, cwd) {
|
|
643
|
+
return { value, cwd, source, confidence: "verified" };
|
|
644
|
+
}
|
|
645
|
+
function sortCommands(commands) {
|
|
646
|
+
return Object.fromEntries(Object.entries(commands).sort(([left], [right]) => left.localeCompare(right)));
|
|
647
|
+
}
|
|
648
|
+
function addEvidence(evidence, kind, value, source) {
|
|
649
|
+
evidence.push({ kind, value, source, confidence: "verified" });
|
|
650
|
+
}
|
|
651
|
+
function sortEvidence(evidence) {
|
|
652
|
+
const unique = new Map();
|
|
653
|
+
for (const item of evidence) {
|
|
654
|
+
unique.set(`${item.source}\u0000${item.kind}\u0000${item.value}`, item);
|
|
655
|
+
}
|
|
656
|
+
return [...unique.values()].sort((left, right) => left.source.localeCompare(right.source) ||
|
|
657
|
+
left.kind.localeCompare(right.kind) ||
|
|
658
|
+
left.value.localeCompare(right.value));
|
|
659
|
+
}
|
|
660
|
+
function sortedUnique(values) {
|
|
661
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
662
|
+
}
|
|
663
|
+
function projectIdentity(root, directory) {
|
|
664
|
+
const relativePath = normalizeRelativePath(path.relative(root, directory) || ".");
|
|
665
|
+
return {
|
|
666
|
+
id: createProjectId(relativePath === "." ? path.basename(root) : relativePath),
|
|
667
|
+
path: relativePath,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function assertUniqueProjectIdentifiers(projects) {
|
|
671
|
+
const pathsById = new Map();
|
|
672
|
+
for (const project of projects) {
|
|
673
|
+
const previousPath = pathsById.get(project.id);
|
|
674
|
+
if (previousPath !== undefined && previousPath !== project.path) {
|
|
675
|
+
throw new CliError(`Project id collision: '${project.id}' maps to both '${previousPath}' and '${project.path}'.`, 2);
|
|
676
|
+
}
|
|
677
|
+
pathsById.set(project.id, project.path);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function assertUniqueModuleIdentifiers(projectId, modules) {
|
|
681
|
+
const pathsById = new Map();
|
|
682
|
+
for (const module of modules) {
|
|
683
|
+
const previousPath = pathsById.get(module.id);
|
|
684
|
+
if (previousPath !== undefined && previousPath !== module.path) {
|
|
685
|
+
throw new CliError(`Module id collision in project '${projectId}': '${module.id}' maps to both '${previousPath}' and '${module.path}'.`, 2);
|
|
686
|
+
}
|
|
687
|
+
pathsById.set(module.id, module.path);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function resolveWithinRoot(root, relativePath) {
|
|
691
|
+
if (path.isAbsolute(relativePath)) {
|
|
692
|
+
throw new CliError(`Generated path must be relative: ${relativePath}`, 2);
|
|
693
|
+
}
|
|
694
|
+
const resolved = path.resolve(root, relativePath);
|
|
695
|
+
const boundary = path.relative(root, resolved);
|
|
696
|
+
if (boundary === ".." || boundary.startsWith(`..${path.sep}`) || path.isAbsolute(boundary)) {
|
|
697
|
+
throw new CliError(`Generated path escapes the target directory: ${relativePath}`, 2);
|
|
698
|
+
}
|
|
699
|
+
return resolved;
|
|
700
|
+
}
|
|
701
|
+
async function resolveSafeGeneratedPath(fileSystem, root, relativePath) {
|
|
702
|
+
const absolutePath = resolveWithinRoot(root, relativePath);
|
|
703
|
+
await assertSafeGeneratedPath(fileSystem, root, absolutePath, relativePath);
|
|
704
|
+
return absolutePath;
|
|
705
|
+
}
|
|
706
|
+
async function assertSafeGeneratedPath(fileSystem, root, absolutePath, displayPath) {
|
|
707
|
+
try {
|
|
708
|
+
await fileSystem.assertPathWithinRoot(root, absolutePath);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
712
|
+
throw new CliError(`Unsafe generated path '${displayPath}': ${detail}`, 2);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async function writeIfChanged(fileSystem, root, absolutePath, content, relativePath, writtenFiles, writtenFileSet) {
|
|
716
|
+
await assertSafeGeneratedPath(fileSystem, root, absolutePath, relativePath);
|
|
717
|
+
if (await fileSystem.exists(absolutePath)) {
|
|
718
|
+
const current = await fileSystem.readText(absolutePath);
|
|
719
|
+
if (normalizeLineEndings(current) === normalizeLineEndings(content))
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
await assertSafeGeneratedPath(fileSystem, root, absolutePath, relativePath);
|
|
723
|
+
await fileSystem.writeText(absolutePath, content);
|
|
724
|
+
addWrittenFile(writtenFiles, writtenFileSet, relativePath);
|
|
725
|
+
}
|
|
726
|
+
function normalizeLineEndings(content) {
|
|
727
|
+
return content.replaceAll("\r\n", "\n");
|
|
728
|
+
}
|
|
729
|
+
function createProjectId(value) {
|
|
730
|
+
const normalized = value
|
|
731
|
+
.toLowerCase()
|
|
732
|
+
.replaceAll("\\", "-")
|
|
733
|
+
.replaceAll("/", "-")
|
|
734
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
735
|
+
.replace(/^-+|-+$/g, "");
|
|
736
|
+
return normalized || "project";
|
|
737
|
+
}
|
|
738
|
+
function normalizeRelativePath(value) {
|
|
739
|
+
return value.replaceAll("\\", "/");
|
|
740
|
+
}
|
|
741
|
+
//# sourceMappingURL=project-discovery.js.map
|