@rsdk/yarn.constraints 6.0.0-next.40 → 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 (40) hide show
  1. package/DEPENDENCY_MODEL.md +12 -4
  2. package/README.MD +70 -30
  3. package/__tests__/config-validation.test.ts +42 -0
  4. package/__tests__/engine.test.ts +105 -0
  5. package/__tests__/fixtures/imports/lib/lib-entry.js +3 -0
  6. package/__tests__/fixtures/imports/root-entry.js +1 -0
  7. package/__tests__/fixtures/imports/rules/transitive.js +3 -0
  8. package/__tests__/fixtures/imports/test/outside.ts +3 -0
  9. package/__tests__/imports.test.ts +12 -0
  10. package/dist/collectors/config.js +4 -1
  11. package/dist/collectors/config.js.map +1 -1
  12. package/dist/collectors/workspaces.js +3 -1
  13. package/dist/collectors/workspaces.js.map +1 -1
  14. package/dist/lib/imports.d.ts +2 -0
  15. package/dist/lib/imports.js +124 -31
  16. package/dist/lib/imports.js.map +1 -1
  17. package/dist/model/config-validation.d.ts +6 -0
  18. package/dist/model/config-validation.js +31 -0
  19. package/dist/model/config-validation.js.map +1 -0
  20. package/dist/model/diagnostics.js +22 -0
  21. package/dist/model/diagnostics.js.map +1 -1
  22. package/dist/model/placement.js +12 -7
  23. package/dist/model/placement.js.map +1 -1
  24. package/dist/model/rules.d.ts +1 -0
  25. package/dist/model/rules.js +6 -0
  26. package/dist/model/rules.js.map +1 -1
  27. package/dist/model/types.d.ts +2 -1
  28. package/dist/model/types.js.map +1 -1
  29. package/dist/model/versions.js +5 -1
  30. package/dist/model/versions.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/collectors/config.ts +8 -1
  33. package/src/collectors/workspaces.ts +10 -2
  34. package/src/lib/imports.ts +173 -31
  35. package/src/model/config-validation.ts +49 -0
  36. package/src/model/diagnostics.ts +30 -0
  37. package/src/model/placement.ts +13 -7
  38. package/src/model/rules.ts +12 -0
  39. package/src/model/types.ts +2 -1
  40. package/src/model/versions.ts +7 -2
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import yaml from 'yaml';
11
11
 
12
+ import { assertValidDependencyModelConfig } from '../model/config-validation';
12
13
  import type { DependencyModelConfig } from '../model/types';
13
14
 
14
15
  export function loadConfig(
@@ -22,5 +23,11 @@ export function loadConfig(
22
23
 
23
24
  if (!existsSync(filepath)) return { rules: [] };
24
25
 
25
- return yaml.parse(readFileSync(filepath, 'utf8')) as DependencyModelConfig;
26
+ const config = yaml.parse(
27
+ readFileSync(filepath, 'utf8'),
28
+ ) as DependencyModelConfig;
29
+
30
+ assertValidDependencyModelConfig(config);
31
+
32
+ return config;
26
33
  }
@@ -9,7 +9,11 @@ import { execSync } from 'node:child_process';
9
9
  import { existsSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
 
12
- import { collectDtsImports, collectSourceImports } from '../lib/imports';
12
+ import {
13
+ collectDtsImports,
14
+ collectSourceFiles,
15
+ collectSourceImportsFromFiles,
16
+ } from '../lib/imports';
13
17
  import { readPackageJson } from '../lib/package-json';
14
18
  import { isWorkspaceRole } from '../model/rules';
15
19
  import type { WorkspaceContext } from '../model/types';
@@ -20,7 +24,11 @@ interface YarnWorkspaceInfo {
20
24
  }
21
25
 
22
26
  function collectUsage(workspace: WorkspaceContext, withDts: boolean): void {
23
- for (const entry of collectSourceImports(workspace.dir, workspace.pkg)) {
27
+ const sourceFiles = collectSourceFiles(workspace.dir, workspace.pkg);
28
+
29
+ workspace.sourceFileCount = sourceFiles.length;
30
+
31
+ for (const entry of collectSourceImportsFromFiles(sourceFiles)) {
24
32
  if (!workspace.sourceUsage.has(entry.packageName)) {
25
33
  workspace.sourceUsage.set(entry.packageName, {
26
34
  files: new Set(),
@@ -18,6 +18,11 @@ export interface ImportEntry {
18
18
  file: string;
19
19
  }
20
20
 
21
+ interface ModuleReference {
22
+ specifier: string;
23
+ isTypeOnly: boolean;
24
+ }
25
+
21
26
  const BUILTINS = new Set([
22
27
  ...builtinModules,
23
28
  ...builtinModules.map((m) => `node:${m}`),
@@ -35,6 +40,19 @@ const SOURCE_EXTENSIONS = new Set([
35
40
  ]);
36
41
 
37
42
  const DTS_RE = /\.d\.(?:c|m)?ts$/;
43
+ const DEFAULT_SOURCE_ROOTS = ['src', 'test', 'tests', '__tests__'];
44
+ const IGNORED_ENTRYPOINT_DIRS = new Set([
45
+ 'node_modules',
46
+ 'dist',
47
+ 'build',
48
+ 'coverage',
49
+ ]);
50
+ const IGNORED_WALK_DIRS = new Set([
51
+ 'node_modules',
52
+ 'fixtures',
53
+ '__fixtures__',
54
+ '__mocks__',
55
+ ]);
38
56
 
39
57
  export function getPackageName(specifier: string): string | null {
40
58
  if (
@@ -62,7 +80,7 @@ function walkFiles(dir: string, filter: (name: string) => boolean): string[] {
62
80
  try {
63
81
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
64
82
  const full = path.join(dir, entry.name);
65
- if (entry.isDirectory() && entry.name !== 'node_modules') {
83
+ if (entry.isDirectory() && !IGNORED_WALK_DIRS.has(entry.name)) {
66
84
  results.push(...walkFiles(full, filter));
67
85
  } else if (entry.isFile() && filter(entry.name)) {
68
86
  results.push(full);
@@ -74,6 +92,22 @@ function walkFiles(dir: string, filter: (name: string) => boolean): string[] {
74
92
  return results;
75
93
  }
76
94
 
95
+ function isInside(parent: string, child: string): boolean {
96
+ const relative = path.relative(parent, child);
97
+
98
+ return (
99
+ relative !== '' &&
100
+ !relative.startsWith('..') &&
101
+ !path.isAbsolute(relative)
102
+ );
103
+ }
104
+
105
+ function startsWithIgnoredEntrypointDir(relative: string): boolean {
106
+ const [first] = relative.split('/');
107
+
108
+ return first !== undefined && IGNORED_ENTRYPOINT_DIRS.has(first);
109
+ }
110
+
77
111
  function isSourceFile(name: string): boolean {
78
112
  return SOURCE_EXTENSIONS.has(path.extname(name)) && !DTS_RE.test(name);
79
113
  }
@@ -105,6 +139,15 @@ function addImport(
105
139
  results.push({ packageName, isTypeOnly, file });
106
140
  }
107
141
 
142
+ function addModuleReference(
143
+ results: ModuleReference[],
144
+ specifier: string | null,
145
+ isTypeOnly: boolean,
146
+ ): void {
147
+ if (!specifier) return;
148
+ results.push({ specifier, isTypeOnly });
149
+ }
150
+
108
151
  function isImportDeclarationTypeOnly(node: ts.ImportDeclaration): boolean {
109
152
  const clause = node.importClause;
110
153
  if (!clause) return false;
@@ -136,12 +179,12 @@ function collectExternalModuleReference(
136
179
  return stringLiteralText(node.expression);
137
180
  }
138
181
 
139
- function extractImports(
182
+ function extractModuleReferences(
140
183
  file: string,
141
184
  content: string,
142
185
  options: { includeDtsForms?: boolean } = {},
143
- ): ImportEntry[] {
144
- const results: ImportEntry[] = [];
186
+ ): ModuleReference[] {
187
+ const results: ModuleReference[] = [];
145
188
  const sourceFile = ts.createSourceFile(
146
189
  file,
147
190
  content,
@@ -152,25 +195,21 @@ function extractImports(
152
195
 
153
196
  if (options.includeDtsForms) {
154
197
  for (const reference of sourceFile.typeReferenceDirectives) {
155
- addImport(results, file, reference.fileName, true);
198
+ addModuleReference(results, reference.fileName, true);
156
199
  }
157
200
  }
158
201
 
159
202
  const visit = (node: ts.Node): void => {
160
203
  if (ts.isImportDeclaration(node)) {
161
204
  const specifier = stringLiteralText(node.moduleSpecifier);
162
- if (specifier) {
163
- addImport(results, file, specifier, isImportDeclarationTypeOnly(node));
164
- }
205
+ addModuleReference(results, specifier, isImportDeclarationTypeOnly(node));
165
206
  } else if (ts.isExportDeclaration(node)) {
166
207
  const specifier = stringLiteralText(node.moduleSpecifier);
167
- if (specifier) {
168
- addImport(results, file, specifier, isExportDeclarationTypeOnly(node));
169
- }
208
+ addModuleReference(results, specifier, isExportDeclarationTypeOnly(node));
170
209
  } else if (ts.isImportEqualsDeclaration(node)) {
171
210
  if (ts.isExternalModuleReference(node.moduleReference)) {
172
211
  const specifier = collectExternalModuleReference(node.moduleReference);
173
- if (specifier) addImport(results, file, specifier, node.isTypeOnly);
212
+ addModuleReference(results, specifier, node.isTypeOnly);
174
213
  }
175
214
  } else if (ts.isCallExpression(node)) {
176
215
  if (
@@ -178,27 +217,27 @@ function extractImports(
178
217
  node.arguments.length === 1
179
218
  ) {
180
219
  const specifier = stringLiteralText(node.arguments[0]);
181
- if (specifier) addImport(results, file, specifier, false);
220
+ addModuleReference(results, specifier, false);
182
221
  } else if (
183
222
  ts.isIdentifier(node.expression) &&
184
223
  node.expression.text === 'require' &&
185
224
  node.arguments.length === 1
186
225
  ) {
187
226
  const specifier = stringLiteralText(node.arguments[0]);
188
- if (specifier) addImport(results, file, specifier, false);
227
+ addModuleReference(results, specifier, false);
189
228
  }
190
229
  } else if (ts.isImportTypeNode(node)) {
191
230
  const argument = node.argument;
192
231
  if (ts.isLiteralTypeNode(argument)) {
193
232
  const specifier = stringLiteralText(argument.literal);
194
- if (specifier) addImport(results, file, specifier, true);
233
+ addModuleReference(results, specifier, true);
195
234
  }
196
235
  } else if (
197
236
  options.includeDtsForms &&
198
237
  ts.isModuleDeclaration(node) &&
199
238
  ts.isStringLiteral(node.name)
200
239
  ) {
201
- addImport(results, file, node.name.text, true);
240
+ addModuleReference(results, node.name.text, true);
202
241
  }
203
242
 
204
243
  ts.forEachChild(node, visit);
@@ -208,6 +247,20 @@ function extractImports(
208
247
  return results;
209
248
  }
210
249
 
250
+ function extractImports(
251
+ file: string,
252
+ content: string,
253
+ options: { includeDtsForms?: boolean } = {},
254
+ ): ImportEntry[] {
255
+ const results: ImportEntry[] = [];
256
+
257
+ for (const reference of extractModuleReferences(file, content, options)) {
258
+ addImport(results, file, reference.specifier, reference.isTypeOnly);
259
+ }
260
+
261
+ return results;
262
+ }
263
+
211
264
  function collectEntrypointStrings(value: unknown, result: Set<string>): void {
212
265
  if (typeof value === 'string') {
213
266
  result.add(value);
@@ -243,39 +296,128 @@ function getPackageEntrypointFiles(
243
296
  const full = path.resolve(workspaceDir, candidate);
244
297
  const relative = path.relative(workspaceDir, full);
245
298
  if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
299
+ const normalized = relative.split(path.sep).join('/');
300
+ if (startsWithIgnoredEntrypointDir(normalized)) continue;
246
301
  if (!existsSync(full) || !isSourceFile(full)) continue;
247
302
 
248
- const normalized = relative.split(path.sep).join('/');
249
- const isSrc = normalized.startsWith('src/');
250
- const isRoot = !normalized.includes('/');
251
- if (isSrc || isRoot) result.add(full);
303
+ result.add(full);
252
304
  }
253
305
 
254
306
  return [...result];
255
307
  }
256
308
 
257
- export function collectSourceImports(
309
+ function isRelativeSpecifier(specifier: string): boolean {
310
+ return specifier.startsWith('./') || specifier.startsWith('../');
311
+ }
312
+
313
+ function getJsToTsCandidates(file: string): string[] {
314
+ const ext = path.extname(file);
315
+ const withoutExt = file.slice(0, -ext.length);
316
+
317
+ if (ext === '.js') return [`${withoutExt}.ts`, `${withoutExt}.tsx`, file];
318
+ if (ext === '.jsx') return [`${withoutExt}.tsx`, file];
319
+ if (ext === '.mjs') return [`${withoutExt}.mts`, file];
320
+ if (ext === '.cjs') return [`${withoutExt}.cts`, file];
321
+
322
+ return [file];
323
+ }
324
+
325
+ function resolveRelativeSourceFile(
326
+ workspaceDir: string,
327
+ importer: string,
328
+ specifier: string,
329
+ ): string | null {
330
+ const target = path.resolve(path.dirname(importer), specifier);
331
+ const candidates: string[] = [];
332
+ const ext = path.extname(target);
333
+
334
+ if (ext) {
335
+ candidates.push(...getJsToTsCandidates(target));
336
+ } else {
337
+ for (const sourceExt of SOURCE_EXTENSIONS) {
338
+ candidates.push(`${target}${sourceExt}`);
339
+ }
340
+ for (const sourceExt of SOURCE_EXTENSIONS) {
341
+ candidates.push(path.join(target, `index${sourceExt}`));
342
+ }
343
+ }
344
+
345
+ for (const candidate of candidates) {
346
+ if (!isInside(workspaceDir, candidate)) continue;
347
+ if (existsSync(candidate) && isSourceFile(candidate)) return candidate;
348
+ }
349
+
350
+ return null;
351
+ }
352
+
353
+ function collectEntrypointGraphFiles(
354
+ workspaceDir: string,
355
+ pkg: PackageJson,
356
+ ): string[] {
357
+ const result = new Set<string>();
358
+ const stack = getPackageEntrypointFiles(workspaceDir, pkg);
359
+
360
+ while (stack.length > 0) {
361
+ const file = stack.pop()!;
362
+ if (result.has(file)) continue;
363
+ result.add(file);
364
+
365
+ for (const reference of extractModuleReferences(
366
+ file,
367
+ readFileSync(file, 'utf8'),
368
+ )) {
369
+ if (!isRelativeSpecifier(reference.specifier)) continue;
370
+ const resolved = resolveRelativeSourceFile(
371
+ workspaceDir,
372
+ file,
373
+ reference.specifier,
374
+ );
375
+ if (resolved && !result.has(resolved)) stack.push(resolved);
376
+ }
377
+ }
378
+
379
+ return [...result];
380
+ }
381
+
382
+ export function collectSourceFiles(
258
383
  srcDirOrWorkspaceDir: string,
259
384
  pkg?: PackageJson,
260
- ): ImportEntry[] {
261
- const srcDir = pkg
262
- ? path.join(srcDirOrWorkspaceDir, 'src')
263
- : srcDirOrWorkspaceDir;
264
- const files = new Set(
265
- walkFiles(srcDir, (name) => isSourceFile(name)),
266
- );
385
+ ): string[] {
386
+ if (!pkg) return walkFiles(srcDirOrWorkspaceDir, (name) => isSourceFile(name));
387
+
388
+ const files = new Set<string>();
267
389
 
268
- if (pkg) {
269
- for (const file of getPackageEntrypointFiles(srcDirOrWorkspaceDir, pkg)) {
390
+ for (const sourceRoot of DEFAULT_SOURCE_ROOTS) {
391
+ for (const file of walkFiles(
392
+ path.join(srcDirOrWorkspaceDir, sourceRoot),
393
+ (name) => isSourceFile(name),
394
+ )) {
270
395
  files.add(file);
271
396
  }
272
397
  }
273
398
 
274
- return [...files].flatMap((file) =>
399
+ for (const file of collectEntrypointGraphFiles(srcDirOrWorkspaceDir, pkg)) {
400
+ files.add(file);
401
+ }
402
+
403
+ return [...files].sort();
404
+ }
405
+
406
+ export function collectSourceImportsFromFiles(files: string[]): ImportEntry[] {
407
+ return files.flatMap((file) =>
275
408
  extractImports(file, readFileSync(file, 'utf8')),
276
409
  );
277
410
  }
278
411
 
412
+ export function collectSourceImports(
413
+ srcDirOrWorkspaceDir: string,
414
+ pkg?: PackageJson,
415
+ ): ImportEntry[] {
416
+ return collectSourceImportsFromFiles(
417
+ collectSourceFiles(srcDirOrWorkspaceDir, pkg),
418
+ );
419
+ }
420
+
279
421
  export function collectDtsImports(distDir: string): Set<string> {
280
422
  const files = walkFiles(distDir, (name) => DTS_RE.test(name));
281
423
  const result = new Set<string>();
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Config validation for rule shapes that the model cannot interpret safely.
3
+ */
4
+ import type { DependencyModelConfig, DependencyRule } from './types';
5
+
6
+ function getRuleMatches(rule: DependencyRule): string[] {
7
+ return Array.isArray(rule.match) ? rule.match : [rule.match];
8
+ }
9
+
10
+ function getRuleLabel(rule: DependencyRule, index: number): string {
11
+ return `rules[${index}] (${getRuleMatches(rule).join(', ')})`;
12
+ }
13
+
14
+ export function validateDependencyRules(rules: DependencyRule[]): string[] {
15
+ const errors: string[] = [];
16
+
17
+ rules.forEach((rule, index) => {
18
+ const label = getRuleLabel(rule, index);
19
+ const matches = getRuleMatches(rule);
20
+
21
+ if (rule.required === true && matches.some((match) => match.includes('*'))) {
22
+ errors.push(
23
+ `${label}: required: true requires concrete match patterns; wildcard matches are ignored by required dependency expansion`,
24
+ );
25
+ }
26
+
27
+ if (rule.section === 'devDependencies' && rule.rootOnly !== true) {
28
+ errors.push(
29
+ `${label}: section: devDependencies is only supported together with rootOnly: true; workspace devDependencies are derived by the model`,
30
+ );
31
+ }
32
+ });
33
+
34
+ return errors;
35
+ }
36
+
37
+ export function assertValidDependencyModelConfig(
38
+ config: DependencyModelConfig,
39
+ ): void {
40
+ const errors = validateDependencyRules(config.rules ?? []);
41
+
42
+ if (errors.length === 0) return;
43
+
44
+ throw new Error(
45
+ ['Invalid dependency constraints config:', ...errors.map((e) => `- ${e}`)].join(
46
+ '\n',
47
+ ),
48
+ );
49
+ }
@@ -32,6 +32,21 @@ function collectActualDependencyNames(workspace: WorkspaceFacts): Set<string> {
32
32
  );
33
33
  }
34
34
 
35
+ function hasExternalCollectedUsage(
36
+ workspace: WorkspaceFacts,
37
+ workspaceNames: Set<string>,
38
+ ): boolean {
39
+ for (const depIdent of workspace.sourceUsage.keys()) {
40
+ if (isExternal(depIdent, workspaceNames)) return true;
41
+ }
42
+
43
+ for (const depIdent of workspace.dtsImports) {
44
+ if (isExternal(depIdent, workspaceNames)) return true;
45
+ }
46
+
47
+ return false;
48
+ }
49
+
35
50
  export function compareManifest(
36
51
  expected: ExpectedWorkspace,
37
52
  violations: DependencyViolation[],
@@ -181,6 +196,21 @@ export function validateBasicShape(
181
196
  });
182
197
  }
183
198
 
199
+ if (
200
+ workspace.role &&
201
+ workspace.sourceFileCount !== undefined &&
202
+ workspace.sourceFileCount > 0 &&
203
+ !hasExternalCollectedUsage(workspace, workspaceNames)
204
+ ) {
205
+ warnings.push({
206
+ code: 'zero-external-dependencies',
207
+ workspace: getWorkspaceLabel(workspace),
208
+ workspaceLocation: workspace.location,
209
+ message:
210
+ `no external source imports were collected from ${workspace.sourceFileCount} source file(s); check source roots and entrypoints before trusting autofix`,
211
+ });
212
+ }
213
+
184
214
  if (workspace.hasSrc && !workspace.hasDist && withDts) {
185
215
  violations.push({
186
216
  code: 'dist-missing',
@@ -71,7 +71,7 @@ function addPublicDependency(
71
71
  rules: DependencyRule[],
72
72
  depIdent: string,
73
73
  reason: Reason,
74
- options: { allowRootOnly?: boolean } = {},
74
+ options: { allowRootOnly?: boolean; rootOnlyRequired?: boolean } = {},
75
75
  ): void {
76
76
  const workspace = expected.workspace;
77
77
  const rule = getEffectiveRule(
@@ -88,15 +88,20 @@ function addPublicDependency(
88
88
  addRuleReason(expected, depIdent, rule);
89
89
 
90
90
  if (rule.rootOnly && !isLocal && !options.allowRootOnly) {
91
+ addRuleReason(rootExpected, depIdent, rule);
91
92
  setExpectedDependency(rootExpected, 'devDependencies', depIdent, range, {
92
- kind: 'root-only',
93
- detail: `${depIdent} is marked rootOnly`,
93
+ kind: options.rootOnlyRequired ? reason.kind : 'root-only',
94
+ detail: options.rootOnlyRequired
95
+ ? `${reason.detail} for ${workspace.name}; rootOnly keeps it in root devDependencies`
96
+ : `${depIdent} is marked rootOnly`,
94
97
  });
95
98
  addReason(expected, depIdent, reason);
96
- addReason(expected, depIdent, {
97
- kind: 'root-only',
98
- detail: `${depIdent} is marked rootOnly and cannot be used by ${workspace.name}`,
99
- });
99
+ if (!options.rootOnlyRequired) {
100
+ addReason(expected, depIdent, {
101
+ kind: 'root-only',
102
+ detail: `${depIdent} is marked rootOnly and cannot be used by ${workspace.name}`,
103
+ });
104
+ }
100
105
  return;
101
106
  }
102
107
 
@@ -326,6 +331,7 @@ export function addRequiredRuleDependencies(
326
331
  kind: 'required-rule',
327
332
  detail: `${depIdent} is marked required by dependency rule`,
328
333
  },
334
+ { rootOnlyRequired: rule.rootOnly },
329
335
  );
330
336
  }
331
337
  }
@@ -66,6 +66,18 @@ export function hasGlobalVersionRule(
66
66
  );
67
67
  }
68
68
 
69
+ export function hasWorkspaceVersionRule(
70
+ rules: DependencyRule[],
71
+ depIdent: string,
72
+ ): boolean {
73
+ return rules.some(
74
+ (rule) =>
75
+ rule.workspace &&
76
+ rule.version !== undefined &&
77
+ matchesPatterns(depIdent, rule.match),
78
+ );
79
+ }
80
+
69
81
  export function isWorkspaceRole(
70
82
  value: string | undefined,
71
83
  ): value is WorkspaceRole {
@@ -69,7 +69,7 @@ export interface DependencyViolation {
69
69
  }
70
70
 
71
71
  export interface DependencyWarning {
72
- code: 'bin-role-mismatch';
72
+ code: 'bin-role-mismatch' | 'zero-external-dependencies';
73
73
  workspace?: string | undefined;
74
74
  workspaceLocation?: string | undefined;
75
75
  dependency?: string | undefined;
@@ -92,6 +92,7 @@ export interface WorkspaceFacts {
92
92
  dtsImports: Set<string>;
93
93
  hasSrc: boolean;
94
94
  hasDist: boolean;
95
+ sourceFileCount?: number;
95
96
  }
96
97
 
97
98
  export interface WorkspaceContext extends WorkspaceFacts {
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { addReason } from './expected';
9
9
  import { isExternal, resolveExpectedRangeAfterRulePass } from './placement';
10
- import { hasGlobalVersionRule } from './rules';
10
+ import { hasGlobalVersionRule, hasWorkspaceVersionRule } from './rules';
11
11
  import type {
12
12
  DependencyRule,
13
13
  DependencyViolation,
@@ -97,13 +97,18 @@ export function collectUnconstrainedVersionViolations(
97
97
  )
98
98
  .map(([depIdent, byOccurrence]) => {
99
99
  const rangeList = [...(ranges.get(depIdent) ?? [])].join(', ');
100
+ const occurrencesList = [...byOccurrence].sort().join(', ');
101
+ const workspaceRuleHint = hasWorkspaceVersionRule(rules, depIdent)
102
+ ? '; workspace-scoped version rules do not satisfy V1'
103
+ : '';
100
104
 
101
105
  return {
102
106
  code: 'unconstrained-version' as const,
103
107
  workspace: '<root>',
104
108
  workspaceLocation: '.',
105
109
  dependency: depIdent,
106
- message: `${depIdent} appears ${byOccurrence.size} times without a global version rule (${rangeList})`,
110
+ message:
111
+ `${depIdent} appears ${byOccurrence.size} times without a global version rule (${rangeList}); occurrences: ${occurrencesList}${workspaceRuleHint}`,
107
112
  };
108
113
  });
109
114
  }