@domainlang/cli 0.5.2 → 0.7.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 (38) hide show
  1. package/README.md +16 -16
  2. package/out/dependency-commands.d.ts +1 -1
  3. package/out/dependency-commands.js +52 -24
  4. package/out/dependency-commands.js.map +1 -1
  5. package/out/main.d.ts +1 -1
  6. package/out/main.js +1 -1
  7. package/out/main.js.map +1 -1
  8. package/out/services/dependency-analyzer.d.ts +59 -0
  9. package/out/services/dependency-analyzer.js +260 -0
  10. package/out/services/dependency-analyzer.js.map +1 -0
  11. package/out/services/dependency-resolver.d.ts +148 -0
  12. package/out/services/dependency-resolver.js +448 -0
  13. package/out/services/dependency-resolver.js.map +1 -0
  14. package/out/services/git-url-resolver.d.ts +158 -0
  15. package/out/services/git-url-resolver.js +408 -0
  16. package/out/services/git-url-resolver.js.map +1 -0
  17. package/out/services/governance-validator.d.ts +56 -0
  18. package/out/services/governance-validator.js +171 -0
  19. package/out/services/governance-validator.js.map +1 -0
  20. package/out/services/index.d.ts +12 -0
  21. package/out/services/index.js +13 -0
  22. package/out/services/index.js.map +1 -0
  23. package/out/services/semver.d.ts +98 -0
  24. package/out/services/semver.js +195 -0
  25. package/out/services/semver.js.map +1 -0
  26. package/out/services/types.d.ts +58 -0
  27. package/out/services/types.js +8 -0
  28. package/out/services/types.js.map +1 -0
  29. package/package.json +4 -3
  30. package/src/dependency-commands.ts +59 -24
  31. package/src/main.ts +1 -1
  32. package/src/services/dependency-analyzer.ts +329 -0
  33. package/src/services/dependency-resolver.ts +546 -0
  34. package/src/services/git-url-resolver.ts +504 -0
  35. package/src/services/governance-validator.ts +226 -0
  36. package/src/services/index.ts +13 -0
  37. package/src/services/semver.ts +213 -0
  38. package/src/services/types.ts +81 -0
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Dependency Resolution Service (CLI-only)
3
+ *
4
+ * Discovers and resolves transitive dependencies for DomainLang packages.
5
+ * Generates lock files for reproducible builds.
6
+ *
7
+ * Algorithm:
8
+ * 1. Parse root model.yaml
9
+ * 2. Download all direct dependencies
10
+ * 3. Parse each dependency's model.yaml
11
+ * 4. Recursively discover transitive dependencies
12
+ * 5. Resolve version constraints using "Latest Wins" strategy
13
+ * 6. Generate lock file with pinned commit hashes
14
+ *
15
+ * Resolution Strategy ("Latest Wins"):
16
+ * - SemVer tags (same major): Pick highest compatible version
17
+ * - Same branch: No conflict, resolve once
18
+ * - Commit pins: Error (explicit pins are intentional)
19
+ * - Major version mismatch: Error
20
+ * - Tag vs Branch: Error (incompatible intent)
21
+ *
22
+ * This module contains network operations and should ONLY be used in CLI contexts.
23
+ */
24
+
25
+ import path from 'node:path';
26
+ import fs from 'node:fs/promises';
27
+ import YAML from 'yaml';
28
+ import { GitUrlParser, GitUrlResolver } from './git-url-resolver.js';
29
+ import { parseSemVer, pickLatestSemVer, detectRefType } from './semver.js';
30
+ import type { SemVer, ResolvingPackage, LockFile, LockedDependency, DependencyGraph, GitImportInfo } from './types.js';
31
+
32
+ export class DependencyResolver {
33
+ private gitResolver: GitUrlResolver;
34
+ private workspaceRoot: string;
35
+
36
+ constructor(workspaceRoot: string, gitResolver?: GitUrlResolver) {
37
+ this.workspaceRoot = workspaceRoot;
38
+ // Per PRS-010: Project-local cache at .dlang/packages/
39
+ const cacheDir = path.join(workspaceRoot, '.dlang', 'packages');
40
+ this.gitResolver = gitResolver || new GitUrlResolver(cacheDir);
41
+ }
42
+
43
+ /**
44
+ * Resolves all dependencies for a workspace.
45
+ *
46
+ * Process:
47
+ * 1. Load root model.yaml
48
+ * 2. Build dependency graph (discover transitive deps)
49
+ * 3. Resolve version constraints
50
+ * 4. Generate lock file
51
+ * 5. Download all dependencies to cache
52
+ *
53
+ * @returns Generated lock file
54
+ */
55
+ async resolveDependencies(): Promise<LockFile> {
56
+ // Load root package config
57
+ const rootConfig = await this.loadPackageConfig(this.workspaceRoot);
58
+
59
+ if (!rootConfig.dependencies || Object.keys(rootConfig.dependencies).length === 0) {
60
+ // No dependencies
61
+ return { version: '1', dependencies: {} };
62
+ }
63
+
64
+ // Build dependency graph
65
+ const graph = await this.buildDependencyGraph(rootConfig);
66
+
67
+ // Apply overrides before conflict detection
68
+ this.applyOverrides(graph, rootConfig.overrides);
69
+
70
+ // Detect version conflicts and package-level cycles before resolving
71
+ this.detectVersionConflicts(graph);
72
+ this.detectPackageCycles(graph);
73
+
74
+ // Resolve version constraints
75
+ await this.resolveVersions(graph);
76
+
77
+ // Generate and return lock file (caller writes to disk)
78
+ return this.generateLockFile(graph);
79
+ }
80
+
81
+ /**
82
+ * Applies ref overrides from model.yaml to resolve conflicts explicitly.
83
+ *
84
+ * Overrides take precedence over all other constraints.
85
+ *
86
+ * @example
87
+ * ```yaml
88
+ * overrides:
89
+ * domainlang/core: v2.0.0
90
+ * ```
91
+ */
92
+ private applyOverrides(graph: DependencyGraph, overrides?: Record<string, string>): void {
93
+ if (!overrides) return;
94
+
95
+ for (const [pkg, overrideRef] of Object.entries(overrides)) {
96
+ const node = graph.nodes[pkg];
97
+ if (node) {
98
+ // Override replaces all constraints with a single definitive ref
99
+ node.constraints = new Set([overrideRef]);
100
+ node.refConstraint = overrideRef;
101
+
102
+ // Track that this was an override for messaging
103
+ this.overrideMessages.push(`Override applied: ${pkg}@${overrideRef}`);
104
+ }
105
+ }
106
+ }
107
+
108
+ /** Override messages for CLI output */
109
+ private overrideMessages: string[] = [];
110
+
111
+ /**
112
+ * Returns any override messages from the last dependency resolution.
113
+ */
114
+ getOverrideMessages(): string[] {
115
+ return this.overrideMessages;
116
+ }
117
+
118
+ /**
119
+ * Builds the complete dependency graph by recursively discovering transitive dependencies.
120
+ */
121
+ private async buildDependencyGraph(rootConfig: ResolvingPackage): Promise<DependencyGraph> {
122
+ const graph: DependencyGraph = {
123
+ nodes: {},
124
+ root: rootConfig.name || 'root',
125
+ };
126
+
127
+ // Process root dependencies
128
+ const queue: Array<{ packageKey: string; refConstraint: string; parent: string }> = [];
129
+
130
+ for (const [depName, refConstraint] of Object.entries(rootConfig.dependencies || {})) {
131
+ queue.push({
132
+ packageKey: depName,
133
+ refConstraint,
134
+ parent: graph.root
135
+ });
136
+ }
137
+
138
+ // BFS to discover all transitive dependencies
139
+ const visited = new Set<string>();
140
+
141
+ while (queue.length > 0) {
142
+ const entry = queue.shift();
143
+ if (!entry) break;
144
+ const { packageKey, refConstraint, parent } = entry;
145
+
146
+ // Skip if already processed
147
+ if (visited.has(packageKey)) {
148
+ // Update dependents list and record constraint
149
+ const existing = graph.nodes[packageKey];
150
+ if (!existing.dependents.includes(parent)) {
151
+ existing.dependents.push(parent);
152
+ }
153
+ if (!existing.constraints) existing.constraints = new Set<string>();
154
+ existing.constraints.add(refConstraint);
155
+ continue;
156
+ }
157
+ visited.add(packageKey);
158
+
159
+ // Parse package identifier
160
+ const gitInfo = GitUrlParser.parse(packageKey);
161
+
162
+ // Download package to get its model.yaml
163
+ const packagePath = await this.gitResolver.resolve(packageKey);
164
+ const packageDir = path.dirname(packagePath);
165
+
166
+ // Load package config
167
+ const packageConfig = await this.loadPackageConfig(packageDir);
168
+
169
+ // Add to graph
170
+ graph.nodes[packageKey] = {
171
+ packageKey,
172
+ refConstraint,
173
+ constraints: new Set<string>([refConstraint]),
174
+ repoUrl: gitInfo.repoUrl,
175
+ dependencies: packageConfig.dependencies || {},
176
+ dependents: [parent],
177
+ };
178
+
179
+ // Queue transitive dependencies
180
+ for (const [transDepName, transRefConstraint] of Object.entries(packageConfig.dependencies || {})) {
181
+ queue.push({
182
+ packageKey: transDepName,
183
+ refConstraint: transRefConstraint,
184
+ parent: packageKey,
185
+ });
186
+ }
187
+ }
188
+
189
+ return graph;
190
+ }
191
+
192
+ /**
193
+ * Resolves ref constraints to specific commits.
194
+ *
195
+ * Simple algorithm: Use the ref specified in the constraint.
196
+ * Detects refType (tag, branch, or commit) based on format.
197
+ */
198
+ private async resolveVersions(graph: DependencyGraph): Promise<void> {
199
+ for (const [packageKey, node] of Object.entries(graph.nodes)) {
200
+ // Parse package to get repo info
201
+ const gitInfo = GitUrlParser.parse(packageKey);
202
+
203
+ // Extract ref from constraint
204
+ const ref = this.extractRefFromConstraint(node.refConstraint);
205
+
206
+ // Detect ref type based on format
207
+ const refType = detectRefType(ref);
208
+
209
+ // Resolve ref to commit hash
210
+ const commitHash = await this.resolveCommitHash(gitInfo.repoUrl, ref, gitInfo);
211
+
212
+ node.resolvedRef = ref;
213
+ node.refType = refType;
214
+ node.commitHash = commitHash;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Extracts a ref from a constraint string.
220
+ *
221
+ * Examples:
222
+ * - "^1.0.0" → "1.0.0" (treated as tag)
223
+ * - "~2.3.0" → "2.3.0" (treated as tag)
224
+ * - "1.5.0" → "1.5.0" (treated as tag)
225
+ * - "main" → "main" (treated as branch)
226
+ * - "abc123def" → "abc123def" (treated as commit)
227
+ * - "owner/repo@1.0.0" → "1.0.0"
228
+ */
229
+ private extractRefFromConstraint(constraint: string): string {
230
+ // Remove semver operators (legacy support)
231
+ let ref = constraint.replace(/^[\^~>=<]/, '');
232
+
233
+ // Extract ref from full import URL if present
234
+ if (ref.includes('@')) {
235
+ ref = ref.split('@')[1];
236
+ }
237
+
238
+ return ref || 'main';
239
+ }
240
+
241
+ /**
242
+ * Resolves a version (tag/branch) to a commit hash.
243
+ */
244
+ private async resolveCommitHash(_repoUrl: string, _version: string, gitInfo: GitImportInfo): Promise<string> {
245
+ // Use GitUrlResolver to resolve the commit
246
+ const commitHash = await this.gitResolver.resolveCommit(gitInfo);
247
+ return commitHash;
248
+ }
249
+
250
+ /**
251
+ * Generates a lock file from the resolved dependency graph.
252
+ */
253
+ private generateLockFile(graph: DependencyGraph): LockFile {
254
+ const dependencies: Record<string, LockedDependency> = {};
255
+
256
+ for (const [packageKey, node] of Object.entries(graph.nodes)) {
257
+ if (!node.resolvedRef || !node.commitHash) {
258
+ throw new Error(
259
+ `Failed to resolve ref for '${packageKey}'.\n` +
260
+ `Hint: Check that the package exists and the ref is valid.`
261
+ );
262
+ }
263
+
264
+ dependencies[packageKey] = {
265
+ ref: node.resolvedRef,
266
+ refType: node.refType ?? 'commit',
267
+ resolved: node.repoUrl || '',
268
+ commit: node.commitHash,
269
+ // Future: Calculate integrity hash
270
+ };
271
+ }
272
+
273
+ return {
274
+ version: '1',
275
+ dependencies,
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Loads and parses a package's model.yaml file.
281
+ */
282
+ private async loadPackageConfig(packageDir: string): Promise<ResolvingPackage> {
283
+ const yamlPath = path.join(packageDir, 'model.yaml');
284
+
285
+ try {
286
+ const yamlContent = await fs.readFile(yamlPath, 'utf-8');
287
+ return this.parseYaml(yamlContent);
288
+ } catch {
289
+ // No model.yaml found
290
+ return {};
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Parses model.yaml content.
296
+ *
297
+ * Expected structure:
298
+ * model:
299
+ * name: package-name
300
+ * version: 1.0.0
301
+ * entry: index.dlang
302
+ *
303
+ * dependencies:
304
+ * package-name:
305
+ * source: owner/repo
306
+ * ref: v1.0.0
307
+ */
308
+ private parseYaml(content: string): ResolvingPackage {
309
+ const parsed = YAML.parse(content) as {
310
+ model?: {
311
+ name?: string;
312
+ version?: string;
313
+ entry?: string;
314
+ };
315
+ dependencies?: Record<string, { source?: string; ref?: string }>;
316
+ overrides?: Record<string, string>;
317
+ };
318
+
319
+ const config: ResolvingPackage = {};
320
+
321
+ if (parsed.model) {
322
+ config.name = parsed.model.name;
323
+ config.version = parsed.model.version;
324
+ config.entry = parsed.model.entry;
325
+ }
326
+
327
+ if (parsed.dependencies) {
328
+ config.dependencies = {};
329
+ for (const [, depInfo] of Object.entries(parsed.dependencies)) {
330
+ if (depInfo.source) {
331
+ const refConstraint = depInfo.ref || 'main';
332
+ // Store as "source@ref" for consistency with import resolution
333
+ config.dependencies[depInfo.source] = refConstraint;
334
+ }
335
+ }
336
+ }
337
+
338
+ // Parse overrides section for explicit ref control
339
+ if (parsed.overrides) {
340
+ config.overrides = parsed.overrides;
341
+ }
342
+
343
+ return config;
344
+ }
345
+
346
+ /**
347
+ * Detects ref conflicts and applies "Latest Wins" resolution strategy.
348
+ *
349
+ * Resolution Rules:
350
+ * - SemVer tags (same major): Pick highest version automatically
351
+ * - Same branch refs: No conflict, use single resolution
352
+ * - Commit SHA conflicts: Error (explicit pins are intentional)
353
+ * - Major version mismatch: Error (breaking change)
354
+ * - Tag vs Branch: Error (incompatible intent)
355
+ *
356
+ * Modifies graph nodes in-place to set the resolved constraint.
357
+ * Throws an error only for unresolvable conflicts.
358
+ */
359
+ private detectVersionConflicts(graph: DependencyGraph): void {
360
+ const resolutionMessages: string[] = [];
361
+
362
+ for (const [pkg, node] of Object.entries(graph.nodes)) {
363
+ const constraints = node.constraints ?? new Set<string>([node.refConstraint]);
364
+
365
+ if (constraints.size <= 1) continue; // No conflict
366
+
367
+ const refs = Array.from(constraints);
368
+ const refTypes = refs.map(ref => ({
369
+ ref,
370
+ type: detectRefType(ref),
371
+ semver: parseSemVer(ref),
372
+ }));
373
+
374
+ // Check for mixed types (tag vs branch vs commit)
375
+ const types = new Set(refTypes.map(r => r.type));
376
+
377
+ // Case 1: All commits - must be exact match
378
+ if (types.size === 1 && types.has('commit')) {
379
+ this.throwConflictError(pkg, refs, node.dependents,
380
+ 'Explicit commit pins cannot be automatically resolved.\n' +
381
+ 'Add an override in model.yaml:\n\n' +
382
+ ' overrides:\n' +
383
+ ` ${pkg}: ${refs[0]}`
384
+ );
385
+ }
386
+
387
+ // Case 2: Mixed types (tag vs branch or tag vs commit)
388
+ if (types.size > 1) {
389
+ this.throwConflictError(pkg, refs, node.dependents,
390
+ 'Cannot mix ref types (tags, branches, commits).\n' +
391
+ 'Add an override in model.yaml to specify which to use:\n\n' +
392
+ ' overrides:\n' +
393
+ ` ${pkg}: <ref>`
394
+ );
395
+ }
396
+
397
+ // Case 3: All branches - must be same branch
398
+ if (types.size === 1 && types.has('branch')) {
399
+ const uniqueBranches = new Set(refs);
400
+ if (uniqueBranches.size > 1) {
401
+ this.throwConflictError(pkg, refs, node.dependents,
402
+ 'Different branch refs cannot be automatically resolved.\n' +
403
+ 'Add an override in model.yaml:\n\n' +
404
+ ' overrides:\n' +
405
+ ` ${pkg}: ${refs[0]}`
406
+ );
407
+ }
408
+ // Same branch - no conflict, continue
409
+ continue;
410
+ }
411
+
412
+ // Case 4: All SemVer tags - apply "Latest Wins"
413
+ const semvers = refTypes
414
+ .filter((r): r is typeof r & { semver: SemVer } => r.semver !== undefined)
415
+ .map(r => r.semver);
416
+
417
+ if (semvers.length !== refs.length) {
418
+ // Some refs don't parse as SemVer - can't auto-resolve
419
+ this.throwConflictError(pkg, refs, node.dependents,
420
+ 'Not all refs are valid SemVer tags.\n' +
421
+ 'Add an override in model.yaml:\n\n' +
422
+ ' overrides:\n' +
423
+ ` ${pkg}: <ref>`
424
+ );
425
+ }
426
+
427
+ // Check major version compatibility
428
+ const majors = new Set(semvers.map(s => s.major));
429
+ if (majors.size > 1) {
430
+ const majorList = Array.from(majors).sort().join(' vs ');
431
+ this.throwConflictError(pkg, refs, node.dependents,
432
+ `Major version mismatch (v${majorList}). This may indicate breaking changes.\n` +
433
+ 'Add an override in model.yaml if you want to force a version:\n\n' +
434
+ ' overrides:\n' +
435
+ ` ${pkg}: ${refs[refs.length - 1]}`
436
+ );
437
+ }
438
+
439
+ // All same major version - pick latest (Latest Wins!)
440
+ const latest = pickLatestSemVer(refs);
441
+ if (latest && latest !== node.refConstraint) {
442
+ // Update the node to use the resolved ref
443
+ node.refConstraint = latest;
444
+
445
+ // Log the resolution for user feedback
446
+ const otherRefs = refs.filter(r => r !== latest).join(', ');
447
+ resolutionMessages.push(
448
+ `Resolved ${pkg}: using ${latest} (satisfies ${otherRefs})`
449
+ );
450
+ }
451
+ }
452
+
453
+ // Store resolution messages for later output
454
+ if (resolutionMessages.length > 0) {
455
+ this.resolutionMessages = resolutionMessages;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Throws a formatted conflict error with actionable hints.
461
+ */
462
+ private throwConflictError(
463
+ pkg: string,
464
+ refs: string[],
465
+ dependents: string[],
466
+ hint: string
467
+ ): never {
468
+ const depLines = dependents.map((d, i) =>
469
+ ` └─ ${d} requires ${pkg}@${refs[i] || refs[0]}`
470
+ ).join('\n');
471
+
472
+ throw new Error(
473
+ `Dependency ref conflict for '${pkg}'\n` +
474
+ depLines + '\n\n' +
475
+ hint
476
+ );
477
+ }
478
+
479
+ /** Resolution messages from "Latest Wins" auto-resolution */
480
+ private resolutionMessages: string[] = [];
481
+
482
+ /**
483
+ * Returns any resolution messages from the last dependency resolution.
484
+ * Useful for CLI output to inform users about auto-resolved conflicts.
485
+ */
486
+ getResolutionMessages(): string[] {
487
+ return this.resolutionMessages;
488
+ }
489
+
490
+ /**
491
+ * Detects package-level cycles in the dependency graph and throws a clear error.
492
+ */
493
+ private detectPackageCycles(graph: DependencyGraph): void {
494
+ const GRAY = 1, BLACK = 2;
495
+ const color: Record<string, number> = {};
496
+ const stack: string[] = [];
497
+
498
+ const visit = (pkg: string): void => {
499
+ color[pkg] = GRAY;
500
+ stack.push(pkg);
501
+ const deps = Object.keys(graph.nodes[pkg]?.dependencies ?? {});
502
+ for (const dep of deps) {
503
+ if (!graph.nodes[dep]) continue; // Unknown dep will resolve later
504
+ if (color[dep] === GRAY) {
505
+ // Found a back edge: cycle
506
+ const cycleStart = stack.indexOf(dep);
507
+ const cyclePath = [...stack.slice(cycleStart), dep].join(' → ');
508
+ throw new Error(
509
+ `Cyclic package dependency detected:\n` +
510
+ ` ${cyclePath}\n\n` +
511
+ `Hint: Extract shared types into a separate package that both can depend on.`
512
+ );
513
+ }
514
+ if (color[dep] !== BLACK) visit(dep);
515
+ }
516
+ stack.pop();
517
+ color[pkg] = BLACK;
518
+ };
519
+
520
+ for (const pkg of Object.keys(graph.nodes)) {
521
+ if (!color[pkg]) visit(pkg);
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Loads an existing lock file from disk.
527
+ */
528
+ static async loadLockFile(workspaceRoot: string): Promise<LockFile | undefined> {
529
+ const lockPath = path.join(workspaceRoot, 'model.lock');
530
+
531
+ try {
532
+ const content = await fs.readFile(lockPath, 'utf-8');
533
+ return JSON.parse(content) as LockFile;
534
+ } catch {
535
+ // No lock file
536
+ return undefined;
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Parses a lock file from JSON format.
542
+ */
543
+ static parseLockFile(content: string): LockFile {
544
+ return JSON.parse(content) as LockFile;
545
+ }
546
+ }