ai-spec-dev 0.1.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/README.md +1215 -146
  3. package/RELEASE_LOG.md +1489 -0
  4. package/cli/index.ts +1981 -0
  5. package/cli/welcome.ts +151 -0
  6. package/core/code-generator.ts +757 -0
  7. package/core/combined-generator.ts +63 -0
  8. package/core/constitution-consolidator.ts +141 -0
  9. package/core/constitution-generator.ts +89 -0
  10. package/core/context-loader.ts +453 -0
  11. package/core/contract-bridge.ts +217 -0
  12. package/core/dsl-extractor.ts +337 -0
  13. package/core/dsl-types.ts +166 -0
  14. package/core/dsl-validator.ts +450 -0
  15. package/core/error-feedback.ts +354 -0
  16. package/core/frontend-context-loader.ts +602 -0
  17. package/core/global-constitution.ts +88 -0
  18. package/core/key-store.ts +49 -0
  19. package/core/knowledge-memory.ts +171 -0
  20. package/core/mock-server-generator.ts +571 -0
  21. package/core/openapi-exporter.ts +361 -0
  22. package/core/requirement-decomposer.ts +198 -0
  23. package/core/reviewer.ts +259 -0
  24. package/core/spec-assessor.ts +99 -0
  25. package/core/spec-generator.ts +428 -0
  26. package/core/spec-refiner.ts +89 -0
  27. package/core/spec-updater.ts +227 -0
  28. package/core/spec-versioning.ts +213 -0
  29. package/core/task-generator.ts +174 -0
  30. package/core/test-generator.ts +273 -0
  31. package/core/workspace-loader.ts +256 -0
  32. package/dist/cli/index.js +6717 -672
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/index.mjs +6717 -670
  35. package/dist/cli/index.mjs.map +1 -1
  36. package/dist/index.d.mts +147 -27
  37. package/dist/index.d.ts +147 -27
  38. package/dist/index.js +2337 -286
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2329 -285
  41. package/dist/index.mjs.map +1 -1
  42. package/git/worktree.ts +109 -0
  43. package/index.ts +9 -0
  44. package/package.json +4 -28
  45. package/prompts/codegen.prompt.ts +259 -0
  46. package/prompts/consolidate.prompt.ts +73 -0
  47. package/prompts/constitution.prompt.ts +63 -0
  48. package/prompts/decompose.prompt.ts +168 -0
  49. package/prompts/dsl.prompt.ts +203 -0
  50. package/prompts/frontend-spec.prompt.ts +191 -0
  51. package/prompts/global-constitution.prompt.ts +61 -0
  52. package/prompts/spec-assess.prompt.ts +53 -0
  53. package/prompts/spec.prompt.ts +102 -0
  54. package/prompts/tasks.prompt.ts +35 -0
  55. package/prompts/testgen.prompt.ts +84 -0
  56. package/prompts/update.prompt.ts +131 -0
  57. package/purpose.docx +0 -0
  58. package/purpose.md +444 -0
  59. package/tsconfig.json +14 -0
  60. package/tsup.config.ts +10 -0
@@ -0,0 +1,361 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs-extra";
3
+ import { SpecDSL, ApiEndpoint, DataModel, ModelField, FieldMap } from "./dsl-types";
4
+
5
+ // ─── Types ────────────────────────────────────────────────────────────────────
6
+
7
+ export interface OpenApiExportOptions {
8
+ /** Output file path. Defaults to openapi.yaml in the project root. */
9
+ outputPath?: string;
10
+ /** API server URL (default: http://localhost:3000) */
11
+ serverUrl?: string;
12
+ /** Output format (default: yaml) */
13
+ format?: "yaml" | "json";
14
+ }
15
+
16
+ // ─── Type Mapping ─────────────────────────────────────────────────────────────
17
+
18
+ interface OASchema {
19
+ type?: string;
20
+ format?: string;
21
+ example?: unknown;
22
+ $ref?: string;
23
+ items?: OASchema;
24
+ }
25
+
26
+ /**
27
+ * Convert a DSL type-description string to an OpenAPI schema object.
28
+ */
29
+ function dslTypeToOASchema(typeDesc: string, fieldName = ""): OASchema {
30
+ const t = typeDesc.toLowerCase();
31
+
32
+ if (t === "string" || t.includes("string")) {
33
+ const name = fieldName.toLowerCase();
34
+ if (name.includes("email")) return { type: "string", format: "email", example: "user@example.com" };
35
+ if (name.includes("url") || name.includes("image")) return { type: "string", format: "uri", example: "https://example.com" };
36
+ if (name.includes("datetime") || name.includes("date") || t.includes("datetime") || t.includes("date")) {
37
+ return { type: "string", format: "date-time", example: "2024-01-15T10:30:00.000Z" };
38
+ }
39
+ if (name.includes("password")) return { type: "string", format: "password" };
40
+ return { type: "string", example: `example_${fieldName}` };
41
+ }
42
+ if (t.includes("int") || t === "number") return { type: "integer", example: 1 };
43
+ if (t.includes("float") || t.includes("decimal") || t.includes("double")) return { type: "number", format: "float", example: 9.99 };
44
+ if (t === "boolean" || t === "bool") return { type: "boolean", example: true };
45
+ if (t.includes("datetime") || t.includes("timestamp")) return { type: "string", format: "date-time", example: "2024-01-15T10:30:00.000Z" };
46
+ if (t.includes("[]") || t.includes("array") || t.includes("list")) return { type: "array", items: { type: "string" } };
47
+ if (t.includes("object") || t.includes("json") || t.includes("record")) return { type: "object" };
48
+
49
+ // If it looks like a model reference (PascalCase)
50
+ if (/^[A-Z][a-zA-Z]+$/.test(typeDesc.trim())) {
51
+ return { $ref: `#/components/schemas/${typeDesc.trim()}` };
52
+ }
53
+
54
+ return { type: "string", example: `example_${fieldName}` };
55
+ }
56
+
57
+ function fieldMapToOAProperties(
58
+ fields: FieldMap,
59
+ required?: string[]
60
+ ): { properties: Record<string, OASchema>; required?: string[] } {
61
+ const properties: Record<string, OASchema> = {};
62
+ for (const [name, type] of Object.entries(fields)) {
63
+ properties[name] = dslTypeToOASchema(type, name);
64
+ }
65
+ const result: { properties: Record<string, OASchema>; required?: string[] } = { properties };
66
+ if (required && required.length > 0) result.required = required;
67
+ return result;
68
+ }
69
+
70
+ function modelToOASchema(model: DataModel): Record<string, unknown> {
71
+ const properties: Record<string, OASchema> = {};
72
+ const requiredFields: string[] = [];
73
+
74
+ for (const field of model.fields) {
75
+ properties[field.name] = dslTypeToOASchema(field.type, field.name);
76
+ if (field.required) requiredFields.push(field.name);
77
+ }
78
+
79
+ const schema: Record<string, unknown> = {
80
+ type: "object",
81
+ properties,
82
+ };
83
+ if (requiredFields.length > 0) schema.required = requiredFields;
84
+ if (model.description) schema.description = model.description;
85
+
86
+ return schema;
87
+ }
88
+
89
+ // ─── Path Parameter Extraction ────────────────────────────────────────────────
90
+
91
+ function extractPathParams(endpointPath: string): string[] {
92
+ const matches = endpointPath.match(/\{([^}]+)\}|:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
93
+ return matches.map((m) => m.replace(/[{}:]/g, ""));
94
+ }
95
+
96
+ /**
97
+ * Normalise DSL path (:id → {id}).
98
+ */
99
+ function normalisePath(endpointPath: string): string {
100
+ return endpointPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
101
+ }
102
+
103
+ // ─── Endpoint to Path Item ────────────────────────────────────────────────────
104
+
105
+ function endpointToPathItem(endpoint: ApiEndpoint): Record<string, unknown> {
106
+ const method = endpoint.method.toLowerCase();
107
+ const pathParams = extractPathParams(endpoint.path);
108
+
109
+ const parameters: unknown[] = pathParams.map((p) => ({
110
+ name: p,
111
+ in: "path",
112
+ required: true,
113
+ schema: { type: "string" },
114
+ description: `${p} identifier`,
115
+ }));
116
+
117
+ // Query params
118
+ if (endpoint.request?.query) {
119
+ for (const [name, typeDesc] of Object.entries(endpoint.request.query)) {
120
+ parameters.push({
121
+ name,
122
+ in: "query",
123
+ required: false,
124
+ schema: dslTypeToOASchema(typeDesc, name),
125
+ description: typeDesc,
126
+ });
127
+ }
128
+ }
129
+
130
+ const operation: Record<string, unknown> = {
131
+ summary: endpoint.description,
132
+ operationId: `${method}_${endpoint.path.replace(/[^a-zA-Z0-9]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "")}`,
133
+ tags: [endpoint.path.split("/").filter(Boolean)[1] ?? "default"],
134
+ };
135
+
136
+ if (parameters.length > 0) operation.parameters = parameters;
137
+
138
+ // Auth
139
+ if (endpoint.auth) {
140
+ operation.security = [{ bearerAuth: [] }];
141
+ }
142
+
143
+ // Request body
144
+ if (endpoint.request?.body && Object.keys(endpoint.request.body).length > 0) {
145
+ const bodyRequired = Object.entries(endpoint.request.body)
146
+ .filter(([, t]) => !t.toLowerCase().includes("optional"))
147
+ .map(([k]) => k);
148
+ operation.requestBody = {
149
+ required: true,
150
+ content: {
151
+ "application/json": {
152
+ schema: {
153
+ type: "object",
154
+ ...fieldMapToOAProperties(endpoint.request.body, bodyRequired),
155
+ },
156
+ },
157
+ },
158
+ };
159
+ }
160
+
161
+ // Responses
162
+ const responses: Record<string, unknown> = {};
163
+
164
+ if (endpoint.successStatus === 204) {
165
+ responses[String(endpoint.successStatus)] = { description: endpoint.successDescription || "No Content" };
166
+ } else {
167
+ responses[String(endpoint.successStatus)] = {
168
+ description: endpoint.successDescription || "Success",
169
+ content: {
170
+ "application/json": {
171
+ schema: { type: "object" },
172
+ },
173
+ },
174
+ };
175
+ }
176
+
177
+ if (endpoint.auth) {
178
+ responses["401"] = {
179
+ description: "Unauthorized — missing or invalid token",
180
+ content: {
181
+ "application/json": {
182
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
183
+ },
184
+ },
185
+ };
186
+ }
187
+
188
+ for (const err of endpoint.errors ?? []) {
189
+ responses[String(err.status)] = {
190
+ description: `${err.code} — ${err.description}`,
191
+ content: {
192
+ "application/json": {
193
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
194
+ },
195
+ },
196
+ };
197
+ }
198
+
199
+ operation.responses = responses;
200
+ return { [method]: operation };
201
+ }
202
+
203
+ // ─── YAML Serialiser (minimal, no external deps) ─────────────────────────────
204
+
205
+ function toYaml(obj: unknown, indent = 0): string {
206
+ const pad = " ".repeat(indent);
207
+ const childPad = " ".repeat(indent + 1);
208
+
209
+ if (obj === null || obj === undefined) return "null";
210
+ if (typeof obj === "boolean") return String(obj);
211
+ if (typeof obj === "number") return String(obj);
212
+ if (typeof obj === "string") {
213
+ // Needs quoting if it contains special chars or looks like a boolean/number
214
+ if (
215
+ obj.includes(":") ||
216
+ obj.includes("#") ||
217
+ obj.includes("\n") ||
218
+ obj.includes("'") ||
219
+ obj === "true" || obj === "false" ||
220
+ /^\d/.test(obj)
221
+ ) {
222
+ return `"${obj.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
223
+ }
224
+ return obj;
225
+ }
226
+
227
+ if (Array.isArray(obj)) {
228
+ if (obj.length === 0) return "[]";
229
+ return obj.map((item) => `\n${pad}- ${toYaml(item, indent + 1).trimStart()}`).join("");
230
+ }
231
+
232
+ if (typeof obj === "object") {
233
+ const entries = Object.entries(obj as Record<string, unknown>);
234
+ if (entries.length === 0) return "{}";
235
+ return entries
236
+ .map(([k, v]) => {
237
+ const valStr = toYaml(v, indent + 1);
238
+ if (typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length > 0) {
239
+ return `\n${pad}${k}:${valStr}`;
240
+ }
241
+ if (Array.isArray(v) && v.length > 0) {
242
+ return `\n${pad}${k}:${valStr}`;
243
+ }
244
+ return `\n${pad}${k}: ${valStr}`;
245
+ })
246
+ .join("");
247
+ }
248
+
249
+ return String(obj);
250
+ }
251
+
252
+ function buildYamlDoc(obj: Record<string, unknown>): string {
253
+ return (
254
+ "openapi: 3.1.0\n" +
255
+ Object.entries(obj)
256
+ .filter(([k]) => k !== "openapi")
257
+ .map(([k, v]) => {
258
+ const valStr = toYaml(v, 1);
259
+ if (typeof v === "object" && v !== null && Object.keys(v).length > 0) {
260
+ return `${k}:${valStr}`;
261
+ }
262
+ return `${k}: ${valStr}`;
263
+ })
264
+ .join("\n") +
265
+ "\n"
266
+ );
267
+ }
268
+
269
+ // ─── Main Export ──────────────────────────────────────────────────────────────
270
+
271
+ /**
272
+ * Convert a SpecDSL to an OpenAPI 3.1.0 document.
273
+ * Returns the document as a plain JS object (can be serialised to YAML or JSON).
274
+ */
275
+ export function dslToOpenApi(dsl: SpecDSL, serverUrl = "http://localhost:3000"): Record<string, unknown> {
276
+ // ── Info ──────────────────────────────────────────────────────────────────
277
+ const info = {
278
+ title: dsl.feature.title,
279
+ description: dsl.feature.description,
280
+ version: "1.0.0",
281
+ };
282
+
283
+ // ── Paths ─────────────────────────────────────────────────────────────────
284
+ const paths: Record<string, unknown> = {};
285
+ for (const endpoint of dsl.endpoints) {
286
+ const normalised = normalisePath(endpoint.path);
287
+ if (!paths[normalised]) paths[normalised] = {};
288
+ Object.assign(paths[normalised] as Record<string, unknown>, endpointToPathItem(endpoint));
289
+ }
290
+
291
+ // ── Schemas ───────────────────────────────────────────────────────────────
292
+ const schemas: Record<string, unknown> = {
293
+ ErrorResponse: {
294
+ type: "object",
295
+ properties: {
296
+ code: { type: "string", example: "ERROR_CODE" },
297
+ message: { type: "string", example: "Human-readable error description" },
298
+ },
299
+ required: ["code", "message"],
300
+ },
301
+ };
302
+
303
+ for (const model of dsl.models) {
304
+ schemas[model.name] = modelToOASchema(model);
305
+ }
306
+
307
+ // ── Security Schemes ──────────────────────────────────────────────────────
308
+ const hasAuth = dsl.endpoints.some((e) => e.auth);
309
+ const securitySchemes: Record<string, unknown> = {};
310
+ if (hasAuth) {
311
+ securitySchemes.bearerAuth = {
312
+ type: "http",
313
+ scheme: "bearer",
314
+ bearerFormat: "JWT",
315
+ };
316
+ }
317
+
318
+ // ── Assemble ──────────────────────────────────────────────────────────────
319
+ const doc: Record<string, unknown> = {
320
+ info,
321
+ servers: [{ url: serverUrl, description: "Development server" }],
322
+ paths,
323
+ components: {
324
+ schemas,
325
+ ...(hasAuth ? { securitySchemes } : {}),
326
+ },
327
+ };
328
+
329
+ return doc;
330
+ }
331
+
332
+ /**
333
+ * Export a SpecDSL to an OpenAPI file (YAML or JSON) in the project directory.
334
+ */
335
+ export async function exportOpenApi(
336
+ dsl: SpecDSL,
337
+ projectDir: string,
338
+ opts: OpenApiExportOptions = {}
339
+ ): Promise<string> {
340
+ const format = opts.format ?? "yaml";
341
+ const serverUrl = opts.serverUrl ?? "http://localhost:3000";
342
+ const defaultName = `openapi.${format}`;
343
+ const outputPath = opts.outputPath
344
+ ? path.isAbsolute(opts.outputPath)
345
+ ? opts.outputPath
346
+ : path.join(projectDir, opts.outputPath)
347
+ : path.join(projectDir, defaultName);
348
+
349
+ const doc = dslToOpenApi(dsl, serverUrl);
350
+
351
+ let content: string;
352
+ if (format === "json") {
353
+ content = JSON.stringify(doc, null, 2);
354
+ } else {
355
+ content = buildYamlDoc(doc);
356
+ }
357
+
358
+ await fs.ensureDir(path.dirname(outputPath));
359
+ await fs.writeFile(outputPath, content, "utf-8");
360
+ return outputPath;
361
+ }
@@ -0,0 +1,198 @@
1
+ import { AIProvider } from "./spec-generator";
2
+ import { WorkspaceConfig, RepoRole } from "./workspace-loader";
3
+ import { ProjectContext } from "./context-loader";
4
+ import { FrontendContext } from "./frontend-context-loader";
5
+ import { decomposeSystemPrompt, buildDecomposePrompt } from "../prompts/decompose.prompt";
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export interface UxDecision {
10
+ /** Throttle delay in ms, e.g. 300 for button clicks */
11
+ throttleMs?: number;
12
+ /** Debounce delay in ms, e.g. 500 for search inputs */
13
+ debounceMs?: number;
14
+ /** Update UI before server confirms */
15
+ optimisticUpdate: boolean;
16
+ /** Which API endpoints to re-fetch on success (empty = none needed) */
17
+ reloadOnSuccess?: string[];
18
+ /** Rollback optimistic update on error */
19
+ errorRollback: boolean;
20
+ /** Show loading indicator during request */
21
+ loadingState: boolean;
22
+ /** Free-form coordination notes */
23
+ notes?: string;
24
+ }
25
+
26
+ export interface RepoRequirement {
27
+ repoName: string;
28
+ role: RepoRole;
29
+ /** The per-repo requirement description */
30
+ specIdea: string;
31
+ /** This repo's DSL becomes the contract for dependent repos */
32
+ isContractProvider: boolean;
33
+ /** Must be processed after these repos */
34
+ dependsOnRepos: string[];
35
+ /** Only for frontend/mobile repos */
36
+ uxDecisions?: UxDecision | null;
37
+ }
38
+
39
+ export interface DecompositionResult {
40
+ originalRequirement: string;
41
+ /** 1-2 sentence analysis of the requirement */
42
+ summary: string;
43
+ repos: RepoRequirement[];
44
+ /** Cross-repo concerns: shared types, timing, state sync */
45
+ coordinationNotes: string;
46
+ }
47
+
48
+ // ─── JSON Parser (same approach as dsl-extractor.ts) ─────────────────────────
49
+
50
+ function parseJsonFromOutput(raw: string): unknown {
51
+ const trimmed = raw.trim();
52
+
53
+ if (trimmed.startsWith("{")) {
54
+ return JSON.parse(trimmed);
55
+ }
56
+
57
+ const fenceStart = trimmed.indexOf("```");
58
+ if (fenceStart !== -1) {
59
+ const afterFence = trimmed.slice(fenceStart + 3);
60
+ const newlinePos = afterFence.indexOf("\n");
61
+ const jsonStart = newlinePos !== -1 ? newlinePos + 1 : 0;
62
+ const fenceEnd = afterFence.lastIndexOf("```");
63
+ if (fenceEnd > jsonStart) {
64
+ const jsonStr = afterFence.slice(jsonStart, fenceEnd).trim();
65
+ return JSON.parse(jsonStr);
66
+ }
67
+ }
68
+
69
+ const objStart = trimmed.indexOf("{");
70
+ const objEnd = trimmed.lastIndexOf("}");
71
+ if (objStart !== -1 && objEnd > objStart) {
72
+ return JSON.parse(trimmed.slice(objStart, objEnd + 1));
73
+ }
74
+
75
+ throw new SyntaxError("No JSON object found in AI output");
76
+ }
77
+
78
+ // ─── Validator ────────────────────────────────────────────────────────────────
79
+
80
+ function validateDecomposition(raw: unknown): DecompositionResult {
81
+ if (typeof raw !== "object" || raw === null) {
82
+ throw new Error("Decomposition output is not an object");
83
+ }
84
+
85
+ const obj = raw as Record<string, unknown>;
86
+
87
+ if (typeof obj.summary !== "string" || !obj.summary) {
88
+ throw new Error('Missing required field: "summary"');
89
+ }
90
+ if (typeof obj.coordinationNotes !== "string") {
91
+ throw new Error('Missing required field: "coordinationNotes"');
92
+ }
93
+ if (!Array.isArray(obj.repos) || obj.repos.length === 0) {
94
+ throw new Error('"repos" must be a non-empty array');
95
+ }
96
+
97
+ const repos: RepoRequirement[] = obj.repos.map((r: unknown, i: number) => {
98
+ if (typeof r !== "object" || r === null) {
99
+ throw new Error(`repos[${i}] is not an object`);
100
+ }
101
+ const repo = r as Record<string, unknown>;
102
+ if (typeof repo.repoName !== "string" || !repo.repoName) {
103
+ throw new Error(`repos[${i}].repoName is required`);
104
+ }
105
+ if (typeof repo.specIdea !== "string" || !repo.specIdea) {
106
+ throw new Error(`repos[${i}].specIdea is required`);
107
+ }
108
+ return {
109
+ repoName: repo.repoName as string,
110
+ role: (repo.role as RepoRole) ?? "backend",
111
+ specIdea: repo.specIdea as string,
112
+ isContractProvider: Boolean(repo.isContractProvider),
113
+ dependsOnRepos: Array.isArray(repo.dependsOnRepos)
114
+ ? (repo.dependsOnRepos as string[])
115
+ : [],
116
+ uxDecisions:
117
+ repo.uxDecisions && typeof repo.uxDecisions === "object"
118
+ ? (repo.uxDecisions as UxDecision)
119
+ : null,
120
+ };
121
+ });
122
+
123
+ return {
124
+ originalRequirement: "",
125
+ summary: obj.summary as string,
126
+ repos,
127
+ coordinationNotes: obj.coordinationNotes as string,
128
+ };
129
+ }
130
+
131
+ // ─── RequirementDecomposer ───────────────────────────────────────────────────
132
+
133
+ export class RequirementDecomposer {
134
+ constructor(private provider: AIProvider) {}
135
+
136
+ /**
137
+ * Decompose a high-level requirement into per-repo specs with UX decisions.
138
+ */
139
+ async decompose(
140
+ requirement: string,
141
+ workspace: WorkspaceConfig,
142
+ contexts: Map<string, ProjectContext>,
143
+ frontendContexts?: Map<string, FrontendContext>
144
+ ): Promise<DecompositionResult> {
145
+ const userPrompt = buildDecomposePrompt(requirement, workspace, contexts, frontendContexts);
146
+
147
+ let rawOutput: string;
148
+ try {
149
+ rawOutput = await this.provider.generate(userPrompt, decomposeSystemPrompt);
150
+ } catch (err) {
151
+ throw new Error(
152
+ `AI call for requirement decomposition failed: ${(err as Error).message}`
153
+ );
154
+ }
155
+
156
+ let parsed: unknown;
157
+ try {
158
+ parsed = parseJsonFromOutput(rawOutput);
159
+ } catch (parseErr) {
160
+ throw new Error(
161
+ `Failed to parse decomposition JSON: ${(parseErr as Error).message}\n\nRaw output:\n${rawOutput.slice(0, 500)}`
162
+ );
163
+ }
164
+
165
+ const result = validateDecomposition(parsed);
166
+ result.originalRequirement = requirement;
167
+ return result;
168
+ }
169
+
170
+ /**
171
+ * Sort repo requirements in dependency order (providers before dependents).
172
+ */
173
+ static sortByDependency(repos: RepoRequirement[]): RepoRequirement[] {
174
+ const sorted: RepoRequirement[] = [];
175
+ const remaining = [...repos];
176
+ const processed = new Set<string>();
177
+
178
+ let maxIterations = repos.length * 2;
179
+
180
+ while (remaining.length > 0 && maxIterations-- > 0) {
181
+ const idx = remaining.findIndex((r) =>
182
+ r.dependsOnRepos.every((dep) => processed.has(dep))
183
+ );
184
+
185
+ if (idx === -1) {
186
+ // Circular dependency or missing dep — add remaining as-is
187
+ sorted.push(...remaining);
188
+ break;
189
+ }
190
+
191
+ const [repo] = remaining.splice(idx, 1);
192
+ sorted.push(repo);
193
+ processed.add(repo.repoName);
194
+ }
195
+
196
+ return sorted;
197
+ }
198
+ }