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

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