@rsdk/yarn.constraints 6.0.0-next.4 → 6.0.0-next.41

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 (126) hide show
  1. package/DEPENDENCY_MODEL.md +460 -0
  2. package/README.MD +85 -21
  3. package/__tests__/compatibility.test.ts +321 -0
  4. package/__tests__/config-validation.test.ts +42 -0
  5. package/__tests__/engine.test.ts +1107 -0
  6. package/__tests__/fixtures/imports/bin.js +4 -0
  7. package/__tests__/fixtures/imports/export-entry.mjs +1 -0
  8. package/__tests__/fixtures/imports/lib/lib-entry.js +3 -0
  9. package/__tests__/fixtures/imports/root-entry.js +4 -0
  10. package/__tests__/fixtures/imports/rules/transitive.js +3 -0
  11. package/__tests__/fixtures/imports/src/common.cjs +3 -0
  12. package/__tests__/fixtures/imports/src/common.cts +3 -0
  13. package/__tests__/fixtures/imports/src/component.tsx +4 -0
  14. package/__tests__/fixtures/imports/src/index.ts +13 -0
  15. package/__tests__/fixtures/imports/src/module.mjs +3 -0
  16. package/__tests__/fixtures/imports/src/module.mts +3 -0
  17. package/__tests__/fixtures/imports/src/plain.js +3 -0
  18. package/__tests__/fixtures/imports/src/test-only-usage.ts +1 -0
  19. package/__tests__/fixtures/imports/test/outside.ts +3 -0
  20. package/__tests__/imports.test.ts +218 -0
  21. package/__tests__/manifest-writer.test.ts +157 -0
  22. package/dist/ansi.d.ts +9 -0
  23. package/dist/ansi.js +24 -0
  24. package/dist/ansi.js.map +1 -0
  25. package/dist/bin/depdoc.d.ts +2 -0
  26. package/dist/bin/depdoc.js +157 -0
  27. package/dist/bin/depdoc.js.map +1 -0
  28. package/dist/collectors/config.d.ts +2 -0
  29. package/dist/collectors/config.js +28 -0
  30. package/dist/collectors/config.js.map +1 -0
  31. package/dist/collectors/external-metadata.d.ts +5 -0
  32. package/dist/collectors/external-metadata.js +110 -0
  33. package/dist/collectors/external-metadata.js.map +1 -0
  34. package/dist/collectors/package-extensions.d.ts +3 -0
  35. package/dist/collectors/package-extensions.js +43 -0
  36. package/dist/collectors/package-extensions.js.map +1 -0
  37. package/dist/collectors/type-providers.d.ts +3 -0
  38. package/dist/collectors/type-providers.js +46 -0
  39. package/dist/collectors/type-providers.js.map +1 -0
  40. package/dist/collectors/workspaces.d.ts +2 -0
  41. package/dist/collectors/workspaces.js +90 -0
  42. package/dist/collectors/workspaces.js.map +1 -0
  43. package/dist/dependency-model.d.ts +11 -0
  44. package/dist/dependency-model.js +18 -0
  45. package/dist/dependency-model.js.map +1 -0
  46. package/dist/index.d.ts +9 -5
  47. package/dist/index.js +13 -33
  48. package/dist/index.js.map +1 -1
  49. package/dist/lib/imports.d.ts +11 -0
  50. package/dist/lib/imports.js +342 -0
  51. package/dist/lib/imports.js.map +1 -0
  52. package/dist/lib/package-json.d.ts +21 -0
  53. package/dist/lib/package-json.js +32 -0
  54. package/dist/lib/package-json.js.map +1 -0
  55. package/dist/model/config-validation.d.ts +6 -0
  56. package/dist/model/config-validation.js +31 -0
  57. package/dist/model/config-validation.js.map +1 -0
  58. package/dist/model/diagnostics.d.ts +4 -0
  59. package/dist/model/diagnostics.js +295 -0
  60. package/dist/model/diagnostics.js.map +1 -0
  61. package/dist/model/engine.d.ts +5 -0
  62. package/dist/model/engine.js +52 -0
  63. package/dist/model/engine.js.map +1 -0
  64. package/dist/model/expected.d.ts +20 -0
  65. package/dist/model/expected.js +89 -0
  66. package/dist/model/expected.js.map +1 -0
  67. package/dist/model/peer-propagation.d.ts +2 -0
  68. package/dist/model/peer-propagation.js +124 -0
  69. package/dist/model/peer-propagation.js.map +1 -0
  70. package/dist/model/placement.d.ts +9 -0
  71. package/dist/model/placement.js +210 -0
  72. package/dist/model/placement.js.map +1 -0
  73. package/dist/model/rules.d.ts +15 -0
  74. package/dist/model/rules.js +52 -0
  75. package/dist/model/rules.js.map +1 -0
  76. package/dist/model/types.d.ts +118 -0
  77. package/dist/model/types.js +9 -0
  78. package/dist/model/types.js.map +1 -0
  79. package/dist/model/versions.d.ts +3 -0
  80. package/dist/model/versions.js +77 -0
  81. package/dist/model/versions.js.map +1 -0
  82. package/dist/reporting.d.ts +3 -0
  83. package/dist/reporting.js +80 -0
  84. package/dist/reporting.js.map +1 -0
  85. package/dist/runner.d.ts +2 -0
  86. package/dist/runner.js +70 -0
  87. package/dist/runner.js.map +1 -0
  88. package/dist/writer/manifest-writer.d.ts +2 -0
  89. package/dist/writer/manifest-writer.js +72 -0
  90. package/dist/writer/manifest-writer.js.map +1 -0
  91. package/eslint.config.cjs +3 -0
  92. package/jest.config.js +1 -0
  93. package/package.json +7 -3
  94. package/src/ansi.ts +23 -0
  95. package/src/bin/depdoc.ts +213 -0
  96. package/src/collectors/config.ts +33 -0
  97. package/src/collectors/external-metadata.ts +148 -0
  98. package/src/collectors/package-extensions.ts +52 -0
  99. package/src/collectors/type-providers.ts +51 -0
  100. package/src/collectors/workspaces.ts +107 -0
  101. package/src/dependency-model.ts +26 -0
  102. package/src/index.ts +28 -45
  103. package/src/lib/imports.ts +435 -0
  104. package/src/lib/package-json.ts +46 -0
  105. package/src/model/config-validation.ts +49 -0
  106. package/src/model/diagnostics.ts +358 -0
  107. package/src/model/engine.ts +120 -0
  108. package/src/model/expected.ts +141 -0
  109. package/src/model/peer-propagation.ts +199 -0
  110. package/src/model/placement.ts +378 -0
  111. package/src/model/rules.ts +85 -0
  112. package/src/model/types.ts +165 -0
  113. package/src/model/versions.ts +114 -0
  114. package/src/reporting.ts +117 -0
  115. package/src/runner.ts +102 -0
  116. package/src/writer/manifest-writer.ts +111 -0
  117. package/tsconfig.build.json +1 -0
  118. package/tsconfig.json +6 -1
  119. package/dist/constraint-schema.d.ts +0 -1
  120. package/dist/constraint-schema.js +0 -17
  121. package/dist/constraint-schema.js.map +0 -1
  122. package/dist/dependency-checker.d.ts +0 -8
  123. package/dist/dependency-checker.js +0 -40
  124. package/dist/dependency-checker.js.map +0 -1
  125. package/src/constraint-schema.ts +0 -20
  126. package/src/dependency-checker.ts +0 -41
@@ -0,0 +1,1107 @@
1
+ import {
2
+ deriveDependencyModel,
3
+ type DependencyModelFacts,
4
+ type WorkspaceFacts,
5
+ type UsageSummary,
6
+ } from '../src/dependency-model';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function makeRoot(pkg?: WorkspaceFacts['pkg']): WorkspaceFacts {
13
+ return {
14
+ name: '@test/root',
15
+ location: '.',
16
+ pkg: pkg ?? {
17
+ name: '@test/root',
18
+ private: true,
19
+ },
20
+ role: undefined,
21
+ isRoot: true,
22
+ sourceUsage: new Map(),
23
+ dtsImports: new Set(),
24
+ hasSrc: false,
25
+ hasDist: false,
26
+ };
27
+ }
28
+
29
+ function makeWorkspace(
30
+ name: string,
31
+ location: string,
32
+ role: WorkspaceFacts['role'],
33
+ pkg?: WorkspaceFacts['pkg'],
34
+ sourceUsage: Map<string, UsageSummary> = new Map(),
35
+ dtsImports: Set<string> = new Set(),
36
+ hasSrc = false,
37
+ hasDist = false,
38
+ sourceFileCount?: number,
39
+ ): WorkspaceFacts {
40
+ const basePkg: WorkspaceFacts['pkg'] = { name };
41
+ if (role !== undefined) basePkg.role = role;
42
+ return {
43
+ name,
44
+ location,
45
+ pkg: pkg ?? basePkg,
46
+ role,
47
+ isRoot: false,
48
+ sourceUsage,
49
+ dtsImports,
50
+ hasSrc,
51
+ hasDist,
52
+ ...(sourceFileCount === undefined ? {} : { sourceFileCount }),
53
+ };
54
+ }
55
+
56
+ function runtimeUsage(files: string[]): UsageSummary {
57
+ return {
58
+ files: new Set(files),
59
+ runtimeFiles: new Set(files),
60
+ typeOnlyFiles: new Set(),
61
+ };
62
+ }
63
+
64
+ function typeOnlyUsage(files: string[]): UsageSummary {
65
+ return {
66
+ files: new Set(files),
67
+ runtimeFiles: new Set(),
68
+ typeOnlyFiles: new Set(files),
69
+ };
70
+ }
71
+
72
+ function emptyFacts(workspaces: WorkspaceFacts[], rules: DependencyModelFacts['rules'] = []): DependencyModelFacts {
73
+ return {
74
+ workspaces,
75
+ externalPeerMetadata: new Map(),
76
+ rules,
77
+ };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Tests
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe('deriveDependencyModel engine', () => {
85
+ // 1. root-shape
86
+ describe('root-shape', () => {
87
+ it('reports root-private when root lacks private: true', () => {
88
+ const root = makeRoot({ name: '@test/root' }); // no private field
89
+ const facts = emptyFacts([root]);
90
+ const result = deriveDependencyModel(facts);
91
+ expect(result.violations.some((v) => v.code === 'root-private')).toBe(true);
92
+ });
93
+
94
+ it('reports root-section when root has dependencies section', () => {
95
+ const root = makeRoot({
96
+ name: '@test/root',
97
+ private: true,
98
+ dependencies: { lodash: '^4.0.0' },
99
+ });
100
+ const facts = emptyFacts([root]);
101
+ const result = deriveDependencyModel(facts);
102
+ expect(result.violations.some((v) => v.code === 'root-section')).toBe(true);
103
+ });
104
+
105
+ it('preserves root devDependencies as the shared dev baseline', () => {
106
+ const root = makeRoot({
107
+ name: '@test/root',
108
+ private: true,
109
+ devDependencies: { typescript: '5.7.3' },
110
+ });
111
+ const result = deriveDependencyModel(emptyFacts([root]));
112
+ const exp = result.expected.get('.')!;
113
+
114
+ expect(exp.sections.devDependencies.get('typescript')).toBe('5.7.3');
115
+ expect(
116
+ result.violations.some((v) => v.dependency === 'typescript'),
117
+ ).toBe(false);
118
+ });
119
+ });
120
+
121
+ // 2. role-required
122
+ describe('role-required', () => {
123
+ it('reports role-missing when workspace has no role field', () => {
124
+ const root = makeRoot();
125
+ const ws = makeWorkspace('@test/noop', 'packages/noop', undefined, { name: '@test/noop' });
126
+ const facts = emptyFacts([root, ws]);
127
+ const result = deriveDependencyModel(facts);
128
+ expect(result.violations.some((v) => v.code === 'role-missing' && v.workspaceLocation === 'packages/noop')).toBe(true);
129
+ });
130
+
131
+ it('reports role-invalid when workspace role is unrecognised', () => {
132
+ const root = makeRoot();
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
+ const ws = makeWorkspace('@test/noop', 'packages/noop', undefined, { name: '@test/noop', role: 'invalid' as any });
135
+ const facts = emptyFacts([root, ws]);
136
+ const result = deriveDependencyModel(facts);
137
+ expect(result.violations.some((v) => v.code === 'role-invalid')).toBe(true);
138
+ });
139
+ });
140
+
141
+ // 3. library-external-default-peer: engine puts runtime import in peerDependencies
142
+ describe('library-external-default-peer', () => {
143
+ it('places runtime-imported external dep in peerDependencies for library', () => {
144
+ const root = makeRoot();
145
+ const ws = makeWorkspace(
146
+ '@test/lib',
147
+ 'packages/lib',
148
+ 'library',
149
+ undefined,
150
+ new Map([['lodash', runtimeUsage(['src/index.ts'])]]),
151
+ );
152
+ const facts = emptyFacts([root, ws], [
153
+ { match: 'lodash', version: '^4.17.21' },
154
+ ]);
155
+ const result = deriveDependencyModel(facts);
156
+ const exp = result.expected.get('packages/lib')!;
157
+ expect(exp.sections.peerDependencies.get('lodash')).toBe('^4.17.21');
158
+ expect(exp.sections.devDependencies.get('lodash')).toBe('^4.17.21'); // mirror
159
+ expect(exp.sections.dependencies.has('lodash')).toBe(false);
160
+ });
161
+ });
162
+
163
+ // 4. library-implementation-override: rule forces section: dependencies
164
+ describe('library-implementation-override', () => {
165
+ it('respects section:dependencies rule override for library', () => {
166
+ const root = makeRoot();
167
+ const ws = makeWorkspace(
168
+ '@test/lib',
169
+ 'packages/lib',
170
+ 'library',
171
+ undefined,
172
+ new Map([['some-lib', runtimeUsage(['src/index.ts'])]]),
173
+ );
174
+ const facts = emptyFacts([root, ws], [
175
+ { match: 'some-lib', section: 'dependencies', version: '1.0.0' },
176
+ ]);
177
+ const result = deriveDependencyModel(facts);
178
+ const exp = result.expected.get('packages/lib')!;
179
+ expect(exp.sections.dependencies.get('some-lib')).toBe('1.0.0');
180
+ expect(exp.sections.peerDependencies.has('some-lib')).toBe(false);
181
+ expect(exp.sections.devDependencies.has('some-lib')).toBe(false);
182
+ });
183
+
184
+ it(
185
+ 'reports stale peerDependenciesMeta when a rule moves a peer to dependencies',
186
+ () => {
187
+ const root = makeRoot();
188
+ const ws = makeWorkspace(
189
+ '@test/lib',
190
+ 'packages/lib',
191
+ 'library',
192
+ {
193
+ name: '@test/lib',
194
+ role: 'library',
195
+ dependencies: { 'some-lib': '1.0.0' },
196
+ peerDependenciesMeta: { 'some-lib': { optional: true } },
197
+ },
198
+ new Map([['some-lib', runtimeUsage(['src/index.ts'])]]),
199
+ );
200
+ const facts = emptyFacts([root, ws], [
201
+ { match: 'some-lib', section: 'dependencies', version: '1.0.0' },
202
+ ]);
203
+ const result = deriveDependencyModel(facts);
204
+
205
+ expect(
206
+ result.violations.some(
207
+ (v) => v.code === 'stale' && v.dependency === 'some-lib',
208
+ ),
209
+ ).toBe(true);
210
+ },
211
+ );
212
+ });
213
+
214
+ // 5. service-runtime-deps
215
+ describe('service-runtime-deps', () => {
216
+ it('places runtime-imported external dep in dependencies for service', () => {
217
+ const root = makeRoot();
218
+ const ws = makeWorkspace(
219
+ '@test/svc',
220
+ 'packages/svc',
221
+ 'service',
222
+ undefined,
223
+ new Map([['express', runtimeUsage(['src/main.ts'])]]),
224
+ );
225
+ const facts = emptyFacts([root, ws], [
226
+ { match: 'express', version: '^4.0.0' },
227
+ ]);
228
+ const result = deriveDependencyModel(facts);
229
+ const exp = result.expected.get('packages/svc')!;
230
+ expect(exp.sections.dependencies.get('express')).toBe('^4.0.0');
231
+ expect(exp.sections.peerDependencies.has('express')).toBe(false);
232
+ expect(exp.sections.devDependencies.has('express')).toBe(false);
233
+ });
234
+ });
235
+
236
+ describe('declaration-independent-expected-graph', () => {
237
+ it('reports a stale workspace dependency when it is declared but unused', () => {
238
+ const root = makeRoot();
239
+ const ws = makeWorkspace(
240
+ '@test/svc',
241
+ 'packages/svc',
242
+ 'service',
243
+ {
244
+ name: '@test/svc',
245
+ role: 'service',
246
+ dependencies: { lodash: '^4.17.21' },
247
+ },
248
+ );
249
+ const result = deriveDependencyModel(emptyFacts([root, ws], [
250
+ { match: 'lodash', version: '^4.17.21' },
251
+ ]));
252
+
253
+ expect(
254
+ result.violations.some(
255
+ (v) => v.code === 'stale' && v.dependency === 'lodash',
256
+ ),
257
+ ).toBe(true);
258
+ });
259
+
260
+ it('keeps an unused declared dependency when a rule marks it required', () => {
261
+ const root = makeRoot();
262
+ const ws = makeWorkspace(
263
+ '@test/svc',
264
+ 'packages/svc',
265
+ 'service',
266
+ {
267
+ name: '@test/svc',
268
+ role: 'service',
269
+ dependencies: { 'indirect-runtime': '^1.0.0' },
270
+ },
271
+ );
272
+ const result = deriveDependencyModel(emptyFacts([root, ws], [
273
+ { match: 'indirect-runtime', version: '^1.0.0', required: true },
274
+ ]));
275
+ const exp = result.expected.get('packages/svc')!;
276
+
277
+ expect(exp.sections.dependencies.get('indirect-runtime')).toBe('^1.0.0');
278
+ expect(
279
+ result.violations.some(
280
+ (v) => v.code === 'stale' && v.dependency === 'indirect-runtime',
281
+ ),
282
+ ).toBe(false);
283
+ });
284
+
285
+ it('applies required rules only to matching workspaces', () => {
286
+ const root = makeRoot();
287
+ const fastify = makeWorkspace(
288
+ '@test/fastify',
289
+ 'packages/fastify',
290
+ 'library',
291
+ {
292
+ name: '@test/fastify',
293
+ role: 'library',
294
+ dependencies: { '@fastify/static': '^6.12.0' },
295
+ },
296
+ );
297
+ const other = makeWorkspace(
298
+ '@test/other',
299
+ 'packages/other',
300
+ 'library',
301
+ {
302
+ name: '@test/other',
303
+ role: 'library',
304
+ dependencies: { '@fastify/static': '^6.12.0' },
305
+ },
306
+ );
307
+ const result = deriveDependencyModel(emptyFacts([root, fastify, other], [
308
+ {
309
+ match: '@fastify/static',
310
+ workspace: '@test/fastify',
311
+ section: 'dependencies',
312
+ version: '^6.12.0',
313
+ required: true,
314
+ },
315
+ ]));
316
+ const exp = result.expected.get('packages/fastify')!;
317
+
318
+ expect(exp.sections.dependencies.get('@fastify/static')).toBe('^6.12.0');
319
+ expect(exp.sections.peerDependencies.has('@fastify/static')).toBe(false);
320
+ expect(exp.sections.devDependencies.has('@fastify/static')).toBe(false);
321
+ expect(
322
+ result.violations.some(
323
+ (v) =>
324
+ v.workspace === '@test/other' &&
325
+ v.code === 'stale' &&
326
+ v.dependency === '@fastify/static',
327
+ ),
328
+ ).toBe(true);
329
+ });
330
+
331
+ it('moves a wrong-section dependency only when usage requires it', () => {
332
+ const root = makeRoot();
333
+ const used = makeWorkspace(
334
+ '@test/used',
335
+ 'packages/used',
336
+ 'service',
337
+ {
338
+ name: '@test/used',
339
+ role: 'service',
340
+ devDependencies: { express: '^4.0.0' },
341
+ },
342
+ new Map([['express', runtimeUsage(['src/main.ts'])]]),
343
+ );
344
+ const unused = makeWorkspace(
345
+ '@test/unused',
346
+ 'packages/unused',
347
+ 'service',
348
+ {
349
+ name: '@test/unused',
350
+ role: 'service',
351
+ devDependencies: { express: '^4.0.0' },
352
+ },
353
+ );
354
+ const result = deriveDependencyModel(emptyFacts([root, used, unused], [
355
+ { match: 'express', version: '^4.0.0' },
356
+ ]));
357
+
358
+ expect(
359
+ result.violations.some(
360
+ (v) =>
361
+ v.workspace === '@test/used' &&
362
+ v.code === 'wrong-section' &&
363
+ v.dependency === 'express' &&
364
+ v.expectedSection === 'dependencies',
365
+ ),
366
+ ).toBe(true);
367
+ expect(
368
+ result.violations.some(
369
+ (v) =>
370
+ v.workspace === '@test/unused' &&
371
+ v.code === 'stale' &&
372
+ v.dependency === 'express',
373
+ ),
374
+ ).toBe(true);
375
+ });
376
+ });
377
+
378
+ // 6. local-workspace-deps
379
+ describe('local-workspace-deps', () => {
380
+ it('places local workspace dep in dependencies with workspace:*', () => {
381
+ const root = makeRoot();
382
+ const lib = makeWorkspace('@test/shared', 'packages/shared', 'library');
383
+ const svc = makeWorkspace(
384
+ '@test/svc',
385
+ 'packages/svc',
386
+ 'service',
387
+ undefined,
388
+ new Map([['@test/shared', runtimeUsage(['src/main.ts'])]]),
389
+ );
390
+ const facts = emptyFacts([root, lib, svc]);
391
+ const result = deriveDependencyModel(facts);
392
+ const exp = result.expected.get('packages/svc')!;
393
+ expect(exp.sections.dependencies.get('@test/shared')).toBe('workspace:*');
394
+ });
395
+ });
396
+
397
+ // 7. peer-propagation-local-library
398
+ describe('peer-propagation-local-library', () => {
399
+ it('propagates peerDependencies from local library to consuming service', () => {
400
+ const root = makeRoot();
401
+ // foo is a library with peerDep on react
402
+ const foo = makeWorkspace(
403
+ '@test/foo',
404
+ 'packages/foo',
405
+ 'library',
406
+ { name: '@test/foo', role: 'library' },
407
+ new Map([['react', runtimeUsage(['src/index.ts'])]]),
408
+ );
409
+ // bar is a service that depends on foo at runtime
410
+ const bar = makeWorkspace(
411
+ '@test/bar',
412
+ 'packages/bar',
413
+ 'service',
414
+ { name: '@test/bar', role: 'service', dependencies: { '@test/foo': 'workspace:*' } },
415
+ new Map([['@test/foo', runtimeUsage(['src/index.ts'])]]),
416
+ );
417
+
418
+ const facts: DependencyModelFacts = {
419
+ workspaces: [root, foo, bar],
420
+ externalPeerMetadata: new Map(),
421
+ rules: [{ match: 'react', version: '>=18' }],
422
+ };
423
+ const result = deriveDependencyModel(facts);
424
+ const exp = result.expected.get('packages/bar')!;
425
+ // service gets react propagated into dependencies
426
+ expect(exp.sections.dependencies.get('react')).toBeDefined();
427
+ });
428
+ });
429
+
430
+ // 8. rootonly-violation
431
+ describe('rootonly-violation', () => {
432
+ it('reports root-only violation when workspace declares a rootOnly dep', () => {
433
+ const root = makeRoot();
434
+ const ws = makeWorkspace(
435
+ '@test/svc',
436
+ 'packages/svc',
437
+ 'service',
438
+ { name: '@test/svc', role: 'service', dependencies: { 'some-tool': '^1.0.0' } },
439
+ );
440
+ const facts = emptyFacts([root, ws], [
441
+ { match: 'some-tool', rootOnly: true },
442
+ ]);
443
+ const result = deriveDependencyModel(facts);
444
+ expect(result.violations.some((v) => v.code === 'root-only' && v.dependency === 'some-tool')).toBe(true);
445
+ });
446
+
447
+ it('reports root-only-usage when workspace source imports a rootOnly dep', () => {
448
+ const root = makeRoot();
449
+ const ws = makeWorkspace(
450
+ '@test/svc',
451
+ 'packages/svc',
452
+ 'service',
453
+ { name: '@test/svc', role: 'service' },
454
+ new Map([['some-tool', runtimeUsage(['src/main.ts'])]]),
455
+ );
456
+ const facts = emptyFacts([root, ws], [
457
+ { match: 'some-tool', rootOnly: true },
458
+ ]);
459
+ const result = deriveDependencyModel(facts);
460
+
461
+ expect(
462
+ result.violations.some(
463
+ (v) =>
464
+ v.code === 'root-only-usage' &&
465
+ v.dependency === 'some-tool' &&
466
+ v.workspace === '@test/svc',
467
+ ),
468
+ ).toBe(true);
469
+ });
470
+
471
+ it('keeps required rootOnly dependencies in root devDependencies without root-only-usage', () => {
472
+ const root = makeRoot({
473
+ name: '@test/root',
474
+ private: true,
475
+ devDependencies: { 'test-harness': '^1.0.0' },
476
+ });
477
+ const ws = makeWorkspace(
478
+ '@test/svc',
479
+ 'packages/svc',
480
+ 'service',
481
+ { name: '@test/svc', role: 'service' },
482
+ );
483
+ const result = deriveDependencyModel(emptyFacts([root, ws], [
484
+ {
485
+ match: 'test-harness',
486
+ workspace: '@test/svc',
487
+ version: '^1.0.0',
488
+ required: true,
489
+ rootOnly: true,
490
+ },
491
+ ]));
492
+ const rootExp = result.expected.get('.')!;
493
+ const wsExp = result.expected.get('packages/svc')!;
494
+
495
+ expect(rootExp.sections.devDependencies.get('test-harness')).toBe('^1.0.0');
496
+ expect(wsExp.sections.dependencies.has('test-harness')).toBe(false);
497
+ expect(
498
+ result.violations.some(
499
+ (v) =>
500
+ v.code === 'root-only-usage' &&
501
+ v.dependency === 'test-harness',
502
+ ),
503
+ ).toBe(false);
504
+ });
505
+ });
506
+
507
+ // 9. library-dev-mirror-exact
508
+ describe('library-dev-mirror-exact', () => {
509
+ it('derives devDependencies as exact mirror of peerDependencies', () => {
510
+ const root = makeRoot();
511
+ const ws = makeWorkspace(
512
+ '@test/lib',
513
+ 'packages/lib',
514
+ 'library',
515
+ undefined,
516
+ new Map([
517
+ ['react', runtimeUsage(['src/index.ts'])],
518
+ ['vue', runtimeUsage(['src/index.ts'])],
519
+ ]),
520
+ );
521
+ const facts = emptyFacts([root, ws], [
522
+ { match: 'react', version: '^18.0.0' },
523
+ { match: 'vue', version: '^3.0.0' },
524
+ ]);
525
+ const result = deriveDependencyModel(facts);
526
+ const exp = result.expected.get('packages/lib')!;
527
+ expect([...exp.sections.devDependencies.entries()]).toEqual(
528
+ [...exp.sections.peerDependencies.entries()],
529
+ );
530
+ });
531
+
532
+ it('reports mirror violation when devDependencies range differs from peerDependencies', () => {
533
+ const root = makeRoot();
534
+ const ws = makeWorkspace(
535
+ '@test/lib',
536
+ 'packages/lib',
537
+ 'library',
538
+ {
539
+ name: '@test/lib',
540
+ role: 'library',
541
+ peerDependencies: { react: '^18.0.0' },
542
+ devDependencies: { react: '^17.0.0' },
543
+ },
544
+ new Map([['react', runtimeUsage(['src/index.ts'])]]),
545
+ );
546
+ const facts = emptyFacts([root, ws], [
547
+ { match: 'react', version: '^18.0.0' },
548
+ ]);
549
+ const result = deriveDependencyModel(facts);
550
+ expect(
551
+ result.violations.some((v) => v.code === 'mirror' && v.dependency === 'react'),
552
+ ).toBe(true);
553
+ });
554
+
555
+ it('removes extra devDependencies not present in peerDependencies', () => {
556
+ const root = makeRoot();
557
+ const ws = makeWorkspace(
558
+ '@test/lib',
559
+ 'packages/lib',
560
+ 'library',
561
+ {
562
+ name: '@test/lib',
563
+ role: 'library',
564
+ peerDependencies: { react: '^18.0.0' },
565
+ devDependencies: { react: '^18.0.0', 'extra-dev': '^1.0.0' },
566
+ },
567
+ new Map([['react', runtimeUsage(['src/index.ts'])]]),
568
+ );
569
+ const facts = emptyFacts([root, ws], [
570
+ { match: 'react', version: '^18.0.0' },
571
+ ]);
572
+ const result = deriveDependencyModel(facts);
573
+ const exp = result.expected.get('packages/lib')!;
574
+ expect(exp.sections.devDependencies.has('extra-dev')).toBe(false);
575
+ expect(exp.sections.devDependencies.get('react')).toBe('^18.0.0');
576
+ });
577
+ });
578
+
579
+ // 10. library-private-type-root-dev
580
+ describe('library-private-type-root-dev', () => {
581
+ it('sends type-only imports not in dts to root devDependencies', () => {
582
+ const root = makeRoot();
583
+ const ws = makeWorkspace(
584
+ '@test/lib',
585
+ 'packages/lib',
586
+ 'library',
587
+ undefined,
588
+ new Map([['some-types', typeOnlyUsage(['src/index.ts'])]]),
589
+ new Set(), // NOT in dts
590
+ );
591
+ const facts = emptyFacts([root, ws], [
592
+ { match: 'some-types', version: '^1.0.0' },
593
+ ]);
594
+ const result = deriveDependencyModel(facts);
595
+ const rootExp = result.expected.get('.')!;
596
+ const wsExp = result.expected.get('packages/lib')!;
597
+ expect(rootExp.sections.devDependencies.get('some-types')).toBe('^1.0.0');
598
+ expect(wsExp.sections.peerDependencies.has('some-types')).toBe(false);
599
+ expect(wsExp.sections.devDependencies.has('some-types')).toBe(false);
600
+ });
601
+ });
602
+
603
+ // 11. library-dts-type-leak
604
+ describe('library-dts-type-leak', () => {
605
+ it('promotes type-only dep appearing in dts to public contract (peer + mirror)', () => {
606
+ const root = makeRoot();
607
+ const ws = makeWorkspace(
608
+ '@test/lib',
609
+ 'packages/lib',
610
+ 'library',
611
+ undefined,
612
+ new Map([['some-types', typeOnlyUsage(['src/index.ts'])]]),
613
+ new Set(['some-types']), // present in dts → public surface
614
+ );
615
+ const facts = emptyFacts([root, ws], [
616
+ { match: 'some-types', version: '^1.0.0' },
617
+ ]);
618
+ const result = deriveDependencyModel(facts);
619
+ const wsExp = result.expected.get('packages/lib')!;
620
+ expect(wsExp.sections.peerDependencies.get('some-types')).toBe('^1.0.0');
621
+ expect(wsExp.sections.devDependencies.get('some-types')).toBe('^1.0.0'); // mirror
622
+ const rootExp = result.expected.get('.')!;
623
+ expect(rootExp.sections.devDependencies.has('some-types')).toBe(false);
624
+ });
625
+ });
626
+
627
+ // 12. cli-runtime-deps
628
+ describe('cli-runtime-deps', () => {
629
+ it('places runtime dep in dependencies for cli', () => {
630
+ const root = makeRoot();
631
+ const ws = makeWorkspace(
632
+ '@test/cli',
633
+ 'packages/cli',
634
+ 'cli',
635
+ undefined,
636
+ new Map([['commander', runtimeUsage(['src/index.ts'])]]),
637
+ );
638
+ const facts = emptyFacts([root, ws], [
639
+ { match: 'commander', version: '^12.0.0' },
640
+ ]);
641
+ const result = deriveDependencyModel(facts);
642
+ const exp = result.expected.get('packages/cli')!;
643
+ expect(exp.sections.dependencies.get('commander')).toBe('^12.0.0');
644
+ expect(exp.sections.peerDependencies.size).toBe(0);
645
+ expect(exp.sections.devDependencies.size).toBe(0);
646
+ });
647
+
648
+ it('reports forbidden-section when cli declares peerDependencies', () => {
649
+ const root = makeRoot();
650
+ const ws = makeWorkspace(
651
+ '@test/cli',
652
+ 'packages/cli',
653
+ 'cli',
654
+ { name: '@test/cli', role: 'cli', peerDependencies: { commander: '^12.0.0' } },
655
+ );
656
+ const facts = emptyFacts([root, ws]);
657
+ const result = deriveDependencyModel(facts);
658
+ expect(
659
+ result.violations.some(
660
+ (v) => v.code === 'forbidden-section' && v.workspace === '@test/cli',
661
+ ),
662
+ ).toBe(true);
663
+ });
664
+ });
665
+
666
+ // 13. peer-propagation-external-package
667
+ describe('peer-propagation-external-package', () => {
668
+ it('propagates required peers of an external package to consuming service', () => {
669
+ const root = makeRoot();
670
+ const ws = makeWorkspace(
671
+ '@test/svc',
672
+ 'packages/svc',
673
+ 'service',
674
+ undefined,
675
+ new Map([['some-framework', runtimeUsage(['src/index.ts'])]]),
676
+ );
677
+ const facts: DependencyModelFacts = {
678
+ workspaces: [root, ws],
679
+ externalPeerMetadata: new Map([
680
+ ['some-framework', new Map([['react', '^18.0.0']])],
681
+ ]),
682
+ rules: [
683
+ { match: 'some-framework', version: '^1.0.0' },
684
+ { match: 'react', version: '^18.0.0' },
685
+ ],
686
+ };
687
+ const result = deriveDependencyModel(facts);
688
+ const exp = result.expected.get('packages/svc')!;
689
+ expect(exp.sections.dependencies.get('react')).toBe('^18.0.0');
690
+ });
691
+
692
+ it('propagates required external peers as peer + mirror for library consumers', () => {
693
+ const root = makeRoot();
694
+ const ws = makeWorkspace(
695
+ '@test/lib',
696
+ 'packages/lib',
697
+ 'library',
698
+ undefined,
699
+ new Map([['some-framework', runtimeUsage(['src/index.ts'])]]),
700
+ );
701
+ const facts: DependencyModelFacts = {
702
+ workspaces: [root, ws],
703
+ externalPeerMetadata: new Map([
704
+ ['some-framework', new Map([['react', '^18.0.0']])],
705
+ ]),
706
+ rules: [
707
+ { match: 'some-framework', section: 'dependencies', version: '^1.0.0' },
708
+ { match: 'react', version: '^18.0.0' },
709
+ ],
710
+ };
711
+ const result = deriveDependencyModel(facts);
712
+ const exp = result.expected.get('packages/lib')!;
713
+ expect(exp.sections.peerDependencies.get('react')).toBe('^18.0.0');
714
+ expect(exp.sections.devDependencies.get('react')).toBe('^18.0.0'); // mirror
715
+ });
716
+ });
717
+
718
+ // 14. optional-peers-ignored
719
+ describe('optional-peers-ignored', () => {
720
+ it('does not propagate optional peers (null entry in externalPeerMetadata)', () => {
721
+ const root = makeRoot();
722
+ const ws = makeWorkspace(
723
+ '@test/svc',
724
+ 'packages/svc',
725
+ 'service',
726
+ undefined,
727
+ new Map([['some-pkg', runtimeUsage(['src/index.ts'])]]),
728
+ );
729
+ // null = коллектор отфильтровал все optional peers; required peers отсутствуют
730
+ const facts: DependencyModelFacts = {
731
+ workspaces: [root, ws],
732
+ externalPeerMetadata: new Map([['some-pkg', null]]),
733
+ rules: [{ match: 'some-pkg', version: '^3.0.0' }],
734
+ };
735
+ const result = deriveDependencyModel(facts);
736
+ const exp = result.expected.get('packages/svc')!;
737
+ expect(exp.sections.dependencies.get('some-pkg')).toBe('^3.0.0');
738
+ expect(exp.sections.dependencies.size).toBe(1); // никакие peers не добавились
739
+ });
740
+ });
741
+
742
+ // 15. repeated-dep-needs-version-rule
743
+ describe('repeated-dep-needs-version-rule', () => {
744
+ it('emits unconstrained-version violation when dep appears in multiple workspaces without a global version rule', () => {
745
+ const root = makeRoot();
746
+ const ws1 = makeWorkspace(
747
+ '@test/a', 'packages/a', 'service',
748
+ { name: '@test/a', role: 'service' },
749
+ new Map([['lodash', runtimeUsage(['src/a.ts'])]]),
750
+ );
751
+ const ws2 = makeWorkspace(
752
+ '@test/b', 'packages/b', 'service',
753
+ { name: '@test/b', role: 'service' },
754
+ new Map([['lodash', runtimeUsage(['src/b.ts'])]]),
755
+ );
756
+ const facts = emptyFacts([root, ws1, ws2]); // нет правил → нет версионного правила для lodash
757
+ const result = deriveDependencyModel(facts);
758
+ expect(
759
+ result.violations.some(
760
+ (v) => v.code === 'unconstrained-version' && v.dependency === 'lodash',
761
+ ),
762
+ ).toBe(true);
763
+ });
764
+
765
+ it('does not warn when a global version rule covers the dep', () => {
766
+ const root = makeRoot();
767
+ const ws1 = makeWorkspace(
768
+ '@test/a', 'packages/a', 'service',
769
+ { name: '@test/a', role: 'service', dependencies: { lodash: '^4.17.21' } },
770
+ );
771
+ const ws2 = makeWorkspace(
772
+ '@test/b', 'packages/b', 'service',
773
+ { name: '@test/b', role: 'service', dependencies: { lodash: '^4.17.21' } },
774
+ );
775
+ const facts = emptyFacts([root, ws1, ws2], [
776
+ { match: 'lodash', version: '^4.17.21' },
777
+ ]);
778
+ const result = deriveDependencyModel(facts);
779
+ expect(
780
+ result.violations.some(
781
+ (v) => v.code === 'unconstrained-version' && v.dependency === 'lodash',
782
+ ),
783
+ ).toBe(false);
784
+ });
785
+ });
786
+
787
+ // 16. rule-precedence-last-wins
788
+ describe('rule-precedence-last-wins', () => {
789
+ it('later version rule overrides earlier one for the same dep', () => {
790
+ const root = makeRoot();
791
+ const ws = makeWorkspace(
792
+ '@test/lib',
793
+ 'packages/lib',
794
+ 'library',
795
+ undefined,
796
+ new Map([['react', runtimeUsage(['src/index.ts'])]]),
797
+ );
798
+ const facts = emptyFacts([root, ws], [
799
+ { match: 'react', version: '^17.0.0' },
800
+ { match: 'react', version: '^18.0.0' }, // последнее правило побеждает
801
+ ]);
802
+ const result = deriveDependencyModel(facts);
803
+ const exp = result.expected.get('packages/lib')!;
804
+ expect(exp.sections.peerDependencies.get('react')).toBe('^18.0.0');
805
+ });
806
+
807
+ it('workspace-scoped rule overrides global section rule', () => {
808
+ const root = makeRoot();
809
+ const ws = makeWorkspace(
810
+ '@test/lib',
811
+ 'packages/lib',
812
+ 'library',
813
+ undefined,
814
+ new Map([['some-pkg', runtimeUsage(['src/index.ts'])]]),
815
+ );
816
+ const facts = emptyFacts([root, ws], [
817
+ { match: 'some-pkg', version: '^1.0.0' },
818
+ // workspace-overrides переводит dep из peerDeps в deps для конкретной библиотеки
819
+ { match: 'some-pkg', workspace: '@test/lib', section: 'dependencies', version: '^1.0.0' },
820
+ ]);
821
+ const result = deriveDependencyModel(facts);
822
+ const exp = result.expected.get('packages/lib')!;
823
+ expect(exp.sections.dependencies.has('some-pkg')).toBe(true);
824
+ expect(exp.sections.peerDependencies.has('some-pkg')).toBe(false);
825
+ });
826
+ });
827
+
828
+ // 17. wildcard-version-rule (V2)
829
+ describe('wildcard-version-rule', () => {
830
+ it('wildcard pattern counts as a version rule and suppresses unconstrained-version violation', () => {
831
+ const root = makeRoot();
832
+ const ws1 = makeWorkspace(
833
+ '@test/a', 'packages/a', 'service',
834
+ { name: '@test/a', role: 'service' },
835
+ new Map([['@nestjs/core', runtimeUsage(['src/a.ts'])]]),
836
+ );
837
+ const ws2 = makeWorkspace(
838
+ '@test/b', 'packages/b', 'service',
839
+ { name: '@test/b', role: 'service' },
840
+ new Map([['@nestjs/core', runtimeUsage(['src/b.ts'])]]),
841
+ );
842
+ const facts = emptyFacts([root, ws1, ws2], [
843
+ { match: '@nestjs/*', version: '^10.0.0' },
844
+ ]);
845
+ const result = deriveDependencyModel(facts);
846
+ expect(
847
+ result.violations.some((v) => v.code === 'unconstrained-version'),
848
+ ).toBe(false);
849
+ });
850
+
851
+ it('wildcard version rule applies the version to matching deps', () => {
852
+ const root = makeRoot();
853
+ const ws = makeWorkspace(
854
+ '@test/svc', 'packages/svc', 'service',
855
+ undefined,
856
+ new Map([['@nestjs/core', runtimeUsage(['src/main.ts'])]]),
857
+ );
858
+ const facts = emptyFacts([root, ws], [
859
+ { match: '@nestjs/*', version: '^10.3.0' },
860
+ ]);
861
+ const result = deriveDependencyModel(facts);
862
+ const exp = result.expected.get('packages/svc')!;
863
+ expect(exp.sections.dependencies.get('@nestjs/core')).toBe('^10.3.0');
864
+ });
865
+ });
866
+
867
+ describe('peer-propagation-strictness', () => {
868
+ it('does not propagate optional peers from a local library', () => {
869
+ const root = makeRoot();
870
+ const provider = makeWorkspace(
871
+ '@test/provider',
872
+ 'packages/provider',
873
+ 'library',
874
+ {
875
+ name: '@test/provider',
876
+ role: 'library',
877
+ peerDependenciesMeta: { react: { optional: true } },
878
+ },
879
+ new Map([['react', runtimeUsage(['src/index.ts'])]]),
880
+ );
881
+ const consumer = makeWorkspace(
882
+ '@test/consumer',
883
+ 'packages/consumer',
884
+ 'service',
885
+ { name: '@test/consumer', role: 'service' },
886
+ new Map([['@test/provider', runtimeUsage(['src/main.ts'])]]),
887
+ );
888
+ const result = deriveDependencyModel(emptyFacts([root, provider, consumer], [
889
+ { match: 'react', version: '^18.0.0' },
890
+ ]));
891
+ const exp = result.expected.get('packages/consumer')!;
892
+
893
+ expect(exp.sections.dependencies.has('react')).toBe(false);
894
+ });
895
+
896
+ it('propagates transitive external peer metadata when complete facts are provided', () => {
897
+ const root = makeRoot();
898
+ const ws = makeWorkspace(
899
+ '@test/svc',
900
+ 'packages/svc',
901
+ 'service',
902
+ undefined,
903
+ new Map([['framework-a', runtimeUsage(['src/main.ts'])]]),
904
+ );
905
+ const facts: DependencyModelFacts = {
906
+ workspaces: [root, ws],
907
+ externalPeerMetadata: new Map([
908
+ ['framework-a', new Map([['framework-b', '^2.0.0']])],
909
+ ['framework-b', new Map([['framework-c', '^3.0.0']])],
910
+ ['framework-c', null],
911
+ ]),
912
+ rules: [
913
+ { match: 'framework-a', version: '^1.0.0' },
914
+ { match: 'framework-b', version: '^2.0.0' },
915
+ { match: 'framework-c', version: '^3.0.0' },
916
+ ],
917
+ };
918
+ const result = deriveDependencyModel(facts);
919
+ const exp = result.expected.get('packages/svc')!;
920
+
921
+ expect(exp.sections.dependencies.get('framework-b')).toBe('^2.0.0');
922
+ expect(exp.sections.dependencies.get('framework-c')).toBe('^3.0.0');
923
+ });
924
+ });
925
+
926
+ describe('dts-mode', () => {
927
+ it('does not report missing dist when withDts is disabled', () => {
928
+ const root = makeRoot();
929
+ const ws = makeWorkspace(
930
+ '@test/lib',
931
+ 'packages/lib',
932
+ 'library',
933
+ { name: '@test/lib', role: 'library' },
934
+ new Map(),
935
+ new Set(),
936
+ true,
937
+ false,
938
+ );
939
+ const result = deriveDependencyModel(emptyFacts([root, ws]));
940
+
941
+ expect(result.violations.some((v) => v.code === 'dist-missing')).toBe(false);
942
+ expect(result.warnings.length).toBe(0);
943
+ });
944
+
945
+ it('uses available dts facts even when withDts is disabled', () => {
946
+ const root = makeRoot();
947
+ const ws = makeWorkspace(
948
+ '@test/lib',
949
+ 'packages/lib',
950
+ 'library',
951
+ { name: '@test/lib', role: 'library' },
952
+ new Map(),
953
+ new Set(['some-types']),
954
+ true,
955
+ true,
956
+ );
957
+ const result = deriveDependencyModel(emptyFacts([root, ws], [
958
+ { match: 'some-types', version: '^1.0.0' },
959
+ ]));
960
+ const exp = result.expected.get('packages/lib')!;
961
+
962
+ expect(exp.sections.peerDependencies.get('some-types')).toBe('^1.0.0');
963
+ expect(exp.sections.devDependencies.get('some-types')).toBe('^1.0.0');
964
+ expect(result.violations.some((v) => v.code === 'dist-missing')).toBe(false);
965
+ });
966
+
967
+ it('reports missing dist when withDts is enabled', () => {
968
+ const root = makeRoot();
969
+ const ws = makeWorkspace(
970
+ '@test/lib',
971
+ 'packages/lib',
972
+ 'library',
973
+ { name: '@test/lib', role: 'library' },
974
+ new Map(),
975
+ new Set(),
976
+ true,
977
+ false,
978
+ );
979
+ const facts = emptyFacts([root, ws]);
980
+ facts.withDts = true;
981
+ const result = deriveDependencyModel(facts);
982
+
983
+ expect(result.violations.some((v) => v.code === 'dist-missing')).toBe(true);
984
+ });
985
+
986
+ it('promotes matching DefinitelyTyped provider for public dts imports', () => {
987
+ const root = makeRoot({
988
+ name: '@test/root',
989
+ private: true,
990
+ devDependencies: { '@types/lodash': '^4.17.13' },
991
+ });
992
+ const ws = makeWorkspace(
993
+ '@test/lib',
994
+ 'packages/lib',
995
+ 'library',
996
+ {
997
+ name: '@test/lib',
998
+ role: 'library',
999
+ peerDependencies: {
1000
+ lodash: '^4.17.21',
1001
+ '@types/lodash': '^4.17.13',
1002
+ },
1003
+ devDependencies: {
1004
+ lodash: '^4.17.21',
1005
+ '@types/lodash': '^4.17.13',
1006
+ },
1007
+ },
1008
+ new Map(),
1009
+ new Set(['lodash']),
1010
+ );
1011
+ const facts: DependencyModelFacts = {
1012
+ workspaces: [root, ws],
1013
+ externalPeerMetadata: new Map(),
1014
+ externalTypeMetadata: new Map([
1015
+ ['lodash', { hasBundledTypes: false }],
1016
+ ]),
1017
+ typeProviderPackages: new Set(['@types/lodash']),
1018
+ rules: [
1019
+ { match: 'lodash', version: '^4.17.21' },
1020
+ { match: '@types/*', rootOnly: true },
1021
+ { match: '@types/lodash', version: '^4.17.13' },
1022
+ ],
1023
+ withDts: true,
1024
+ };
1025
+ const result = deriveDependencyModel(facts);
1026
+ const exp = result.expected.get('packages/lib')!;
1027
+
1028
+ expect(exp.sections.peerDependencies.get('@types/lodash')).toBe('^4.17.13');
1029
+ expect(exp.sections.devDependencies.get('@types/lodash')).toBe('^4.17.13');
1030
+ expect(
1031
+ result.violations.some(
1032
+ (v) =>
1033
+ v.code === 'root-only-usage' &&
1034
+ v.dependency === '@types/lodash',
1035
+ ),
1036
+ ).toBe(false);
1037
+ });
1038
+ });
1039
+
1040
+ describe('source-usage-safety', () => {
1041
+ it('warns when source files were scanned but no external usage was found', () => {
1042
+ const root = makeRoot();
1043
+ const ws = makeWorkspace(
1044
+ '@test/svc',
1045
+ 'packages/svc',
1046
+ 'service',
1047
+ { name: '@test/svc', role: 'service' },
1048
+ new Map(),
1049
+ new Set(),
1050
+ false,
1051
+ false,
1052
+ 2,
1053
+ );
1054
+ const result = deriveDependencyModel(emptyFacts([root, ws]));
1055
+
1056
+ expect(
1057
+ result.warnings.some(
1058
+ (warning) =>
1059
+ warning.code === 'zero-external-dependencies' &&
1060
+ warning.workspace === '@test/svc',
1061
+ ),
1062
+ ).toBe(true);
1063
+ });
1064
+
1065
+ it('treats imports from external test roots as private dev dependencies', () => {
1066
+ const root = makeRoot();
1067
+ const ws = makeWorkspace(
1068
+ '@test/svc',
1069
+ 'packages/svc',
1070
+ 'service',
1071
+ { name: '@test/svc', role: 'service' },
1072
+ new Map([['test-harness', runtimeUsage(['test/main.spec.ts'])]]),
1073
+ );
1074
+ const result = deriveDependencyModel(emptyFacts([root, ws], [
1075
+ { match: 'test-harness', version: '^1.0.0' },
1076
+ ]));
1077
+ const rootExp = result.expected.get('.')!;
1078
+ const wsExp = result.expected.get('packages/svc')!;
1079
+
1080
+ expect(rootExp.sections.devDependencies.get('test-harness')).toBe('^1.0.0');
1081
+ expect(wsExp.sections.dependencies.has('test-harness')).toBe(false);
1082
+ });
1083
+
1084
+ it('lists repeated dependency occurrences and explains workspace-scoped version rules', () => {
1085
+ const root = makeRoot();
1086
+ const ws1 = makeWorkspace(
1087
+ '@test/a', 'packages/a', 'service',
1088
+ { name: '@test/a', role: 'service' },
1089
+ new Map([['lodash', runtimeUsage(['src/a.ts'])]]),
1090
+ );
1091
+ const ws2 = makeWorkspace(
1092
+ '@test/b', 'packages/b', 'service',
1093
+ { name: '@test/b', role: 'service' },
1094
+ new Map([['lodash', runtimeUsage(['src/b.ts'])]]),
1095
+ );
1096
+ const result = deriveDependencyModel(emptyFacts([root, ws1, ws2], [
1097
+ { match: 'lodash', workspace: '@test/a', version: '^4.17.21' },
1098
+ ]));
1099
+ const violation = result.violations.find(
1100
+ (v) => v.code === 'unconstrained-version' && v.dependency === 'lodash',
1101
+ );
1102
+
1103
+ expect(violation?.message).toContain('occurrences: packages/a:dependencies, packages/b:dependencies');
1104
+ expect(violation?.message).toContain('workspace-scoped version rules do not satisfy V1');
1105
+ });
1106
+ });
1107
+ });