@rsdk/yarn.constraints 6.0.0-next.39 → 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,293 @@
1
+ /**
2
+ * TypeScript/JavaScript import collectors.
3
+ *
4
+ * These helpers parse source and emitted declaration files into package-level
5
+ * import facts. They do not decide dependency placement; they only normalize
6
+ * specifiers and classify imports as runtime or type-only.
7
+ */
8
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
+ import { builtinModules } from 'node:module';
10
+ import path from 'node:path';
11
+ import ts = require('typescript');
12
+
13
+ import type { PackageJson } from './package-json';
14
+
15
+ export interface ImportEntry {
16
+ packageName: string;
17
+ isTypeOnly: boolean;
18
+ file: string;
19
+ }
20
+
21
+ const BUILTINS = new Set([
22
+ ...builtinModules,
23
+ ...builtinModules.map((m) => `node:${m}`),
24
+ ]);
25
+
26
+ const SOURCE_EXTENSIONS = new Set([
27
+ '.ts',
28
+ '.tsx',
29
+ '.mts',
30
+ '.cts',
31
+ '.js',
32
+ '.jsx',
33
+ '.mjs',
34
+ '.cjs',
35
+ ]);
36
+
37
+ const DTS_RE = /\.d\.(?:c|m)?ts$/;
38
+
39
+ export function getPackageName(specifier: string): string | null {
40
+ if (
41
+ specifier.startsWith('.') ||
42
+ specifier.startsWith('/') ||
43
+ specifier.includes('*')
44
+ )
45
+ return null;
46
+ if (BUILTINS.has(specifier) || specifier.startsWith('node:')) return null;
47
+ if (specifier.startsWith('@')) {
48
+ const scopeSlash = specifier.indexOf('/', 1);
49
+ if (scopeSlash === -1) return null;
50
+ const subpathSlash = specifier.indexOf('/', scopeSlash + 1);
51
+
52
+ return subpathSlash === -1 ? specifier : specifier.slice(0, subpathSlash);
53
+ }
54
+ const slash = specifier.indexOf('/');
55
+
56
+ return slash === -1 ? specifier : specifier.slice(0, slash);
57
+ }
58
+
59
+ function walkFiles(dir: string, filter: (name: string) => boolean): string[] {
60
+ const results: string[] = [];
61
+
62
+ try {
63
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
64
+ const full = path.join(dir, entry.name);
65
+ if (entry.isDirectory() && entry.name !== 'node_modules') {
66
+ results.push(...walkFiles(full, filter));
67
+ } else if (entry.isFile() && filter(entry.name)) {
68
+ results.push(full);
69
+ }
70
+ }
71
+ } catch {
72
+ // dir doesn't exist - return empty
73
+ }
74
+ return results;
75
+ }
76
+
77
+ function isSourceFile(name: string): boolean {
78
+ return SOURCE_EXTENSIONS.has(path.extname(name)) && !DTS_RE.test(name);
79
+ }
80
+
81
+ function getScriptKind(file: string): ts.ScriptKind {
82
+ if (file.endsWith('.tsx')) return ts.ScriptKind.TSX;
83
+ if (file.endsWith('.jsx')) return ts.ScriptKind.JSX;
84
+ if (
85
+ file.endsWith('.js') ||
86
+ file.endsWith('.mjs') ||
87
+ file.endsWith('.cjs')
88
+ )
89
+ return ts.ScriptKind.JS;
90
+ return ts.ScriptKind.TS;
91
+ }
92
+
93
+ function stringLiteralText(node: ts.Node | undefined): string | null {
94
+ return node && ts.isStringLiteralLike(node) ? node.text : null;
95
+ }
96
+
97
+ function addImport(
98
+ results: ImportEntry[],
99
+ file: string,
100
+ specifier: string,
101
+ isTypeOnly: boolean,
102
+ ): void {
103
+ const packageName = getPackageName(specifier);
104
+ if (!packageName) return;
105
+ results.push({ packageName, isTypeOnly, file });
106
+ }
107
+
108
+ function isImportDeclarationTypeOnly(node: ts.ImportDeclaration): boolean {
109
+ const clause = node.importClause;
110
+ if (!clause) return false;
111
+ if (clause.isTypeOnly) return true;
112
+ if (clause.name) return false;
113
+ if (!clause.namedBindings) return true;
114
+ if (ts.isNamespaceImport(clause.namedBindings)) return false;
115
+
116
+ return (
117
+ clause.namedBindings.elements.length > 0 &&
118
+ clause.namedBindings.elements.every((element) => element.isTypeOnly)
119
+ );
120
+ }
121
+
122
+ function isExportDeclarationTypeOnly(node: ts.ExportDeclaration): boolean {
123
+ if (node.isTypeOnly) return true;
124
+ const clause = node.exportClause;
125
+ if (!clause || !ts.isNamedExports(clause)) return false;
126
+
127
+ return (
128
+ clause.elements.length > 0 &&
129
+ clause.elements.every((element) => element.isTypeOnly)
130
+ );
131
+ }
132
+
133
+ function collectExternalModuleReference(
134
+ node: ts.ExternalModuleReference,
135
+ ): string | null {
136
+ return stringLiteralText(node.expression);
137
+ }
138
+
139
+ function extractImports(
140
+ file: string,
141
+ content: string,
142
+ options: { includeDtsForms?: boolean } = {},
143
+ ): ImportEntry[] {
144
+ const results: ImportEntry[] = [];
145
+ const sourceFile = ts.createSourceFile(
146
+ file,
147
+ content,
148
+ ts.ScriptTarget.Latest,
149
+ true,
150
+ getScriptKind(file),
151
+ );
152
+
153
+ if (options.includeDtsForms) {
154
+ for (const reference of sourceFile.typeReferenceDirectives) {
155
+ addImport(results, file, reference.fileName, true);
156
+ }
157
+ }
158
+
159
+ const visit = (node: ts.Node): void => {
160
+ if (ts.isImportDeclaration(node)) {
161
+ const specifier = stringLiteralText(node.moduleSpecifier);
162
+ if (specifier) {
163
+ addImport(results, file, specifier, isImportDeclarationTypeOnly(node));
164
+ }
165
+ } else if (ts.isExportDeclaration(node)) {
166
+ const specifier = stringLiteralText(node.moduleSpecifier);
167
+ if (specifier) {
168
+ addImport(results, file, specifier, isExportDeclarationTypeOnly(node));
169
+ }
170
+ } else if (ts.isImportEqualsDeclaration(node)) {
171
+ if (ts.isExternalModuleReference(node.moduleReference)) {
172
+ const specifier = collectExternalModuleReference(node.moduleReference);
173
+ if (specifier) addImport(results, file, specifier, node.isTypeOnly);
174
+ }
175
+ } else if (ts.isCallExpression(node)) {
176
+ if (
177
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
178
+ node.arguments.length === 1
179
+ ) {
180
+ const specifier = stringLiteralText(node.arguments[0]);
181
+ if (specifier) addImport(results, file, specifier, false);
182
+ } else if (
183
+ ts.isIdentifier(node.expression) &&
184
+ node.expression.text === 'require' &&
185
+ node.arguments.length === 1
186
+ ) {
187
+ const specifier = stringLiteralText(node.arguments[0]);
188
+ if (specifier) addImport(results, file, specifier, false);
189
+ }
190
+ } else if (ts.isImportTypeNode(node)) {
191
+ const argument = node.argument;
192
+ if (ts.isLiteralTypeNode(argument)) {
193
+ const specifier = stringLiteralText(argument.literal);
194
+ if (specifier) addImport(results, file, specifier, true);
195
+ }
196
+ } else if (
197
+ options.includeDtsForms &&
198
+ ts.isModuleDeclaration(node) &&
199
+ ts.isStringLiteral(node.name)
200
+ ) {
201
+ addImport(results, file, node.name.text, true);
202
+ }
203
+
204
+ ts.forEachChild(node, visit);
205
+ };
206
+
207
+ visit(sourceFile);
208
+ return results;
209
+ }
210
+
211
+ function collectEntrypointStrings(value: unknown, result: Set<string>): void {
212
+ if (typeof value === 'string') {
213
+ result.add(value);
214
+ return;
215
+ }
216
+ if (!value || typeof value !== 'object') return;
217
+ if (Array.isArray(value)) {
218
+ for (const item of value) collectEntrypointStrings(item, result);
219
+ return;
220
+ }
221
+ for (const item of Object.values(value as Record<string, unknown>)) {
222
+ collectEntrypointStrings(item, result);
223
+ }
224
+ }
225
+
226
+ function getPackageEntrypointFiles(
227
+ workspaceDir: string,
228
+ pkg: PackageJson,
229
+ ): string[] {
230
+ const candidates = new Set<string>();
231
+ if (pkg.main) candidates.add(pkg.main);
232
+ if (typeof pkg.bin === 'string') {
233
+ candidates.add(pkg.bin);
234
+ } else if (pkg.bin) {
235
+ for (const value of Object.values(pkg.bin)) candidates.add(value);
236
+ }
237
+ collectEntrypointStrings(pkg.exports, candidates);
238
+
239
+ const result = new Set<string>();
240
+
241
+ for (const candidate of candidates) {
242
+ if (!candidate || candidate.includes('*')) continue;
243
+ const full = path.resolve(workspaceDir, candidate);
244
+ const relative = path.relative(workspaceDir, full);
245
+ if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
246
+ if (!existsSync(full) || !isSourceFile(full)) continue;
247
+
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);
252
+ }
253
+
254
+ return [...result];
255
+ }
256
+
257
+ export function collectSourceImports(
258
+ srcDirOrWorkspaceDir: string,
259
+ 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
+ );
267
+
268
+ if (pkg) {
269
+ for (const file of getPackageEntrypointFiles(srcDirOrWorkspaceDir, pkg)) {
270
+ files.add(file);
271
+ }
272
+ }
273
+
274
+ return [...files].flatMap((file) =>
275
+ extractImports(file, readFileSync(file, 'utf8')),
276
+ );
277
+ }
278
+
279
+ export function collectDtsImports(distDir: string): Set<string> {
280
+ const files = walkFiles(distDir, (name) => DTS_RE.test(name));
281
+ const result = new Set<string>();
282
+
283
+ for (const file of files) {
284
+ for (const { packageName } of extractImports(
285
+ file,
286
+ readFileSync(file, 'utf8'),
287
+ { includeDtsForms: true },
288
+ )) {
289
+ result.add(packageName);
290
+ }
291
+ }
292
+ return result;
293
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Package manifest helpers.
3
+ *
4
+ * This module intentionally stays small: it reads `package.json` files and
5
+ * locates the monorepo root. Higher-level workspace discovery and model
6
+ * semantics belong to collectors and the engine.
7
+ */
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ export interface PackageJson {
12
+ name: string;
13
+ private?: boolean;
14
+ role?: 'library' | 'service' | 'cli';
15
+ workspaces?: string[] | { packages: string[] };
16
+ main?: string;
17
+ types?: string;
18
+ typings?: string;
19
+ exports?: unknown;
20
+ bin?: string | Record<string, string>;
21
+ dependencies?: Record<string, string>;
22
+ peerDependencies?: Record<string, string>;
23
+ peerDependenciesMeta?: Record<string, { optional?: boolean }>;
24
+ devDependencies?: Record<string, string>;
25
+ }
26
+
27
+ export function readPackageJson(dir: string): PackageJson {
28
+ return JSON.parse(
29
+ readFileSync(join(dir, 'package.json'), 'utf8'),
30
+ ) as PackageJson;
31
+ }
32
+
33
+ export function findMonorepoRoot(startDir: string): string | null {
34
+ let dir = startDir;
35
+
36
+ while (true) {
37
+ const pkgPath = join(dir, 'package.json');
38
+ if (existsSync(pkgPath)) {
39
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJson;
40
+ if (pkg.workspaces) return dir;
41
+ }
42
+ const parent = dirname(dir);
43
+ if (parent === dir) return null;
44
+ dir = parent;
45
+ }
46
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Manifest diagnostics.
3
+ *
4
+ * The expected graph is compared with actual package manifests here. This
5
+ * module also owns basic shape checks such as root-only sections, workspace
6
+ * roles, forbidden service/CLI sections, and library mirror diagnostics.
7
+ */
8
+ import {
9
+ getDeclaredRange,
10
+ getDeclaredSection,
11
+ getExpectedRange,
12
+ getExpectedSection,
13
+ } from './expected';
14
+ import { isExternal } from './placement';
15
+ import { getEffectiveRule, isWorkspaceRole } from './rules';
16
+ import type {
17
+ DependencyRule,
18
+ DependencyViolation,
19
+ DependencyWarning,
20
+ ExpectedWorkspace,
21
+ WorkspaceFacts,
22
+ } from './types';
23
+ import { SECTIONS } from './types';
24
+
25
+ function getWorkspaceLabel(workspace: WorkspaceFacts): string {
26
+ return workspace.isRoot ? '<root>' : workspace.name;
27
+ }
28
+
29
+ function collectActualDependencyNames(workspace: WorkspaceFacts): Set<string> {
30
+ return new Set(
31
+ SECTIONS.flatMap((section) => Object.keys(workspace.pkg[section] ?? {})),
32
+ );
33
+ }
34
+
35
+ export function compareManifest(
36
+ expected: ExpectedWorkspace,
37
+ violations: DependencyViolation[],
38
+ ): void {
39
+ const workspace = expected.workspace;
40
+ const actualNames = collectActualDependencyNames(workspace);
41
+ const expectedNames = new Set<string>();
42
+
43
+ for (const section of SECTIONS) {
44
+ for (const depIdent of expected.sections[section].keys()) {
45
+ expectedNames.add(depIdent);
46
+ }
47
+ }
48
+
49
+ for (const depIdent of expectedNames) {
50
+ const expectedSection = getExpectedSection(expected, depIdent)!;
51
+ const expectedRange = getExpectedRange(expected, depIdent)!;
52
+ const actualSection = getDeclaredSection(workspace.pkg, depIdent);
53
+ const actualRange = getDeclaredRange(workspace.pkg, depIdent);
54
+
55
+ if (!actualSection) {
56
+ violations.push({
57
+ code: 'missing',
58
+ workspace: getWorkspaceLabel(workspace),
59
+ workspaceLocation: workspace.location,
60
+ dependency: depIdent,
61
+ expectedSection,
62
+ expectedRange,
63
+ message: `${depIdent} is missing from ${expectedSection}`,
64
+ });
65
+ continue;
66
+ }
67
+
68
+ if (actualSection !== expectedSection) {
69
+ violations.push({
70
+ code: expected.workspace.isRoot ? 'root-section' : 'wrong-section',
71
+ workspace: getWorkspaceLabel(workspace),
72
+ workspaceLocation: workspace.location,
73
+ dependency: depIdent,
74
+ actualSection,
75
+ expectedSection,
76
+ actualRange: actualRange ?? undefined,
77
+ expectedRange,
78
+ message: `${depIdent} is in ${actualSection}, expected ${expectedSection}`,
79
+ });
80
+ continue;
81
+ }
82
+
83
+ if (actualRange !== expectedRange) {
84
+ violations.push({
85
+ code: 'wrong-range',
86
+ workspace: getWorkspaceLabel(workspace),
87
+ workspaceLocation: workspace.location,
88
+ dependency: depIdent,
89
+ actualSection,
90
+ expectedSection,
91
+ actualRange: actualRange ?? undefined,
92
+ expectedRange,
93
+ message: `${depIdent} range is ${actualRange}, expected ${expectedRange}`,
94
+ });
95
+ }
96
+ }
97
+
98
+ for (const depIdent of actualNames) {
99
+ if (expectedNames.has(depIdent)) continue;
100
+ const actualSection = getDeclaredSection(workspace.pkg, depIdent);
101
+ const actualRange = getDeclaredRange(workspace.pkg, depIdent);
102
+ const isForbiddenRootSection =
103
+ workspace.isRoot && actualSection && actualSection !== 'devDependencies';
104
+
105
+ violations.push({
106
+ code: isForbiddenRootSection ? 'root-section' : 'stale',
107
+ workspace: getWorkspaceLabel(workspace),
108
+ workspaceLocation: workspace.location,
109
+ dependency: depIdent,
110
+ actualSection: actualSection ?? undefined,
111
+ actualRange: actualRange ?? undefined,
112
+ message: isForbiddenRootSection
113
+ ? `${depIdent} is declared in root ${actualSection}; root package.json may contain only devDependencies`
114
+ : `${depIdent} is declared but not required by the derived model`,
115
+ });
116
+ }
117
+
118
+ for (const depIdent of Object.keys(
119
+ workspace.pkg.peerDependenciesMeta ?? {},
120
+ )) {
121
+ if (expected.sections.peerDependencies.has(depIdent)) continue;
122
+
123
+ violations.push({
124
+ code: 'stale',
125
+ workspace: getWorkspaceLabel(workspace),
126
+ workspaceLocation: workspace.location,
127
+ dependency: depIdent,
128
+ message:
129
+ `${depIdent} has peerDependenciesMeta but is not expected in peerDependencies`,
130
+ });
131
+ }
132
+ }
133
+
134
+ export function validateBasicShape(
135
+ workspaces: WorkspaceFacts[],
136
+ expectedByLocation: Map<string, ExpectedWorkspace>,
137
+ rules: DependencyRule[],
138
+ violations: DependencyViolation[],
139
+ warnings: DependencyWarning[],
140
+ withDts: boolean,
141
+ ): void {
142
+ const workspaceNames = new Set(
143
+ workspaces.filter((ws) => !ws.isRoot).map((ws) => ws.name),
144
+ );
145
+
146
+ for (const workspace of workspaces) {
147
+ if (workspace.isRoot) {
148
+ if (workspace.pkg.private !== true) {
149
+ violations.push({
150
+ code: 'root-private',
151
+ workspace: getWorkspaceLabel(workspace),
152
+ workspaceLocation: workspace.location,
153
+ message: 'root package.json must have private: true',
154
+ });
155
+ }
156
+ continue;
157
+ }
158
+
159
+ if (!workspace.pkg.role) {
160
+ violations.push({
161
+ code: 'role-missing',
162
+ workspace: getWorkspaceLabel(workspace),
163
+ workspaceLocation: workspace.location,
164
+ message: 'workspace package.json must declare role',
165
+ });
166
+ } else if (!isWorkspaceRole(workspace.pkg.role)) {
167
+ violations.push({
168
+ code: 'role-invalid',
169
+ workspace: getWorkspaceLabel(workspace),
170
+ workspaceLocation: workspace.location,
171
+ message: `workspace role must be library, service, or cli; got ${workspace.pkg.role}`,
172
+ });
173
+ }
174
+
175
+ if (workspace.pkg.bin && workspace.role !== 'cli') {
176
+ warnings.push({
177
+ code: 'bin-role-mismatch',
178
+ workspace: getWorkspaceLabel(workspace),
179
+ workspaceLocation: workspace.location,
180
+ message: 'workspace declares bin but role is not cli',
181
+ });
182
+ }
183
+
184
+ if (workspace.hasSrc && !workspace.hasDist && withDts) {
185
+ violations.push({
186
+ code: 'dist-missing',
187
+ workspace: getWorkspaceLabel(workspace),
188
+ workspaceLocation: workspace.location,
189
+ message: 'dist/ not found; build required before --with-dts check',
190
+ });
191
+ }
192
+
193
+ if (workspace.role === 'service' || workspace.role === 'cli') {
194
+ for (const section of ['peerDependencies', 'devDependencies'] as const) {
195
+ for (const depIdent of Object.keys(workspace.pkg[section] ?? {})) {
196
+ violations.push({
197
+ code: 'forbidden-section',
198
+ workspace: getWorkspaceLabel(workspace),
199
+ workspaceLocation: workspace.location,
200
+ dependency: depIdent,
201
+ actualSection: section,
202
+ expectedSection:
203
+ section === 'peerDependencies' ? 'dependencies' : undefined,
204
+ message: `${section} are forbidden for ${workspace.role} workspaces`,
205
+ });
206
+ }
207
+ }
208
+ }
209
+
210
+ if (workspace.role === 'library') {
211
+ for (const depIdent of Object.keys(workspace.pkg.dependencies ?? {})) {
212
+ if (workspace.pkg.peerDependencies?.[depIdent] !== undefined) {
213
+ violations.push({
214
+ code: 'forbidden-section',
215
+ workspace: getWorkspaceLabel(workspace),
216
+ workspaceLocation: workspace.location,
217
+ dependency: depIdent,
218
+ actualSection: 'dependencies',
219
+ expectedSection: 'peerDependencies',
220
+ message: `${depIdent} is declared in both dependencies and peerDependencies`,
221
+ });
222
+ }
223
+ }
224
+
225
+ const expected = expectedByLocation.get(workspace.location);
226
+ if (expected) {
227
+ for (const [depIdent, peerRange] of expected.sections
228
+ .peerDependencies) {
229
+ const devRange = workspace.pkg.devDependencies?.[depIdent];
230
+ if (devRange !== peerRange) {
231
+ violations.push({
232
+ code: 'mirror',
233
+ workspace: getWorkspaceLabel(workspace),
234
+ workspaceLocation: workspace.location,
235
+ dependency: depIdent,
236
+ actualSection: 'devDependencies',
237
+ expectedSection: 'devDependencies',
238
+ actualRange: devRange,
239
+ expectedRange: peerRange,
240
+ message: `${depIdent} must be mirrored from peerDependencies to devDependencies`,
241
+ });
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ for (const depIdent of Object.keys(workspace.pkg.devDependencies ?? {})) {
248
+ if (workspace.pkg.dependencies?.[depIdent] !== undefined) {
249
+ violations.push({
250
+ code: 'forbidden-section',
251
+ workspace: getWorkspaceLabel(workspace),
252
+ workspaceLocation: workspace.location,
253
+ dependency: depIdent,
254
+ actualSection: 'devDependencies',
255
+ expectedSection: 'dependencies',
256
+ message: `${depIdent} is declared in both dependencies and devDependencies`,
257
+ });
258
+ }
259
+ }
260
+
261
+ for (const section of SECTIONS) {
262
+ for (const depIdent of Object.keys(workspace.pkg[section] ?? {})) {
263
+ const rule = getEffectiveRule(
264
+ rules,
265
+ depIdent,
266
+ workspace.name,
267
+ workspace.location,
268
+ );
269
+ const expected = expectedByLocation.get(workspace.location);
270
+ if (
271
+ rule.rootOnly &&
272
+ isExternal(depIdent, workspaceNames) &&
273
+ !expected?.sections[section].has(depIdent)
274
+ ) {
275
+ violations.push({
276
+ code: 'root-only',
277
+ workspace: getWorkspaceLabel(workspace),
278
+ workspaceLocation: workspace.location,
279
+ dependency: depIdent,
280
+ actualSection: section,
281
+ expectedSection: 'devDependencies',
282
+ message: `${depIdent} is rootOnly and must not be declared in workspace package.json`,
283
+ });
284
+ }
285
+ }
286
+ }
287
+
288
+ const expected = expectedByLocation.get(workspace.location);
289
+ if (expected) {
290
+ for (const [depIdent, reasons] of expected.reasons) {
291
+ if (!isExternal(depIdent, workspaceNames)) continue;
292
+ if (!reasons.some((reason) => reason.kind === 'root-only')) continue;
293
+
294
+ violations.push({
295
+ code: 'root-only-usage',
296
+ workspace: getWorkspaceLabel(workspace),
297
+ workspaceLocation: workspace.location,
298
+ dependency: depIdent,
299
+ expectedSection: 'devDependencies',
300
+ message: `${depIdent} is rootOnly and must not be used by workspace source or peer surface`,
301
+ });
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ export function dedupeViolations(
308
+ violations: DependencyViolation[],
309
+ ): DependencyViolation[] {
310
+ const result: DependencyViolation[] = [];
311
+ const seen = new Set<string>();
312
+
313
+ for (const violation of violations) {
314
+ const key = [
315
+ violation.code,
316
+ violation.workspaceLocation,
317
+ violation.dependency ?? '',
318
+ violation.actualSection ?? '',
319
+ violation.expectedSection ?? '',
320
+ violation.expectedRange ?? '',
321
+ ].join('\0');
322
+ if (seen.has(key)) continue;
323
+ seen.add(key);
324
+ result.push(violation);
325
+ }
326
+
327
+ return result;
328
+ }