@webpieces/nx-webpieces-rules 0.0.1

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 (61) hide show
  1. package/LICENSE +373 -0
  2. package/executors.json +124 -0
  3. package/package.json +36 -0
  4. package/src/executor-result.ts +7 -0
  5. package/src/executors/generate/executor.ts +61 -0
  6. package/src/executors/generate/schema.json +14 -0
  7. package/src/executors/help/executor.ts +63 -0
  8. package/src/executors/help/schema.json +7 -0
  9. package/src/executors/validate-architecture-unchanged/executor.ts +253 -0
  10. package/src/executors/validate-architecture-unchanged/schema.json +14 -0
  11. package/src/executors/validate-catch-error-pattern/executor.ts +11 -0
  12. package/src/executors/validate-catch-error-pattern/schema.json +24 -0
  13. package/src/executors/validate-code/executor.ts +11 -0
  14. package/src/executors/validate-code/schema.json +287 -0
  15. package/src/executors/validate-dtos/executor.ts +11 -0
  16. package/src/executors/validate-dtos/schema.json +33 -0
  17. package/src/executors/validate-eslint-sync/executor.ts +87 -0
  18. package/src/executors/validate-eslint-sync/schema.json +7 -0
  19. package/src/executors/validate-modified-files/executor.ts +11 -0
  20. package/src/executors/validate-modified-files/schema.json +25 -0
  21. package/src/executors/validate-modified-methods/executor.ts +11 -0
  22. package/src/executors/validate-modified-methods/schema.json +25 -0
  23. package/src/executors/validate-new-methods/executor.ts +11 -0
  24. package/src/executors/validate-new-methods/schema.json +25 -0
  25. package/src/executors/validate-no-any-unknown/executor.ts +11 -0
  26. package/src/executors/validate-no-any-unknown/schema.json +24 -0
  27. package/src/executors/validate-no-architecture-cycles/executor.ts +63 -0
  28. package/src/executors/validate-no-architecture-cycles/schema.json +8 -0
  29. package/src/executors/validate-no-destructure/executor.ts +11 -0
  30. package/src/executors/validate-no-destructure/schema.json +24 -0
  31. package/src/executors/validate-no-direct-api-resolver/executor.ts +11 -0
  32. package/src/executors/validate-no-direct-api-resolver/schema.json +29 -0
  33. package/src/executors/validate-no-implicit-any/executor.ts +11 -0
  34. package/src/executors/validate-no-implicit-any/schema.json +24 -0
  35. package/src/executors/validate-no-inline-types/executor.ts +11 -0
  36. package/src/executors/validate-no-inline-types/schema.json +24 -0
  37. package/src/executors/validate-no-skiplevel-deps/executor.ts +274 -0
  38. package/src/executors/validate-no-skiplevel-deps/schema.json +8 -0
  39. package/src/executors/validate-no-unmanaged-exceptions/executor.ts +11 -0
  40. package/src/executors/validate-no-unmanaged-exceptions/schema.json +24 -0
  41. package/src/executors/validate-packagejson/executor.ts +76 -0
  42. package/src/executors/validate-packagejson/schema.json +8 -0
  43. package/src/executors/validate-prisma-converters/executor.ts +11 -0
  44. package/src/executors/validate-prisma-converters/schema.json +38 -0
  45. package/src/executors/validate-return-types/executor.ts +11 -0
  46. package/src/executors/validate-return-types/schema.json +24 -0
  47. package/src/executors/validate-ts-in-src/executor.ts +283 -0
  48. package/src/executors/validate-ts-in-src/schema.json +25 -0
  49. package/src/executors/validate-versions-locked/executor.ts +376 -0
  50. package/src/executors/validate-versions-locked/schema.json +8 -0
  51. package/src/executors/visualize/executor.ts +65 -0
  52. package/src/executors/visualize/schema.json +14 -0
  53. package/src/index.ts +9 -0
  54. package/src/lib/graph-comparator.ts +154 -0
  55. package/src/lib/graph-generator.ts +97 -0
  56. package/src/lib/graph-loader.ts +119 -0
  57. package/src/lib/graph-sorter.ts +137 -0
  58. package/src/lib/graph-visualizer.ts +253 -0
  59. package/src/lib/package-validator.ts +184 -0
  60. package/src/plugin.ts +666 -0
  61. package/src/toError.ts +36 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Validate TypeScript Files in src/ Executor
3
+ *
4
+ * Two-layer rule:
5
+ * Layer 1: every .ts file inside an Nx project must live under src/
6
+ * (jest.config.ts at project root is the only exception)
7
+ * Layer 2: every .ts file anywhere in the workspace must belong to some
8
+ * Nx project. Orphan files (at workspace root or in a non-project
9
+ * directory) fail the rule unless explicitly allowlisted.
10
+ *
11
+ * Configurable via nx.json targetDefaults:
12
+ * "validate-ts-in-src": {
13
+ * "options": {
14
+ * "mode": "ON",
15
+ * "excludePaths": [...],
16
+ * "allowedRootFiles": [...]
17
+ * }
18
+ * }
19
+ *
20
+ * Usage: nx run architecture:validate-ts-in-src
21
+ */
22
+
23
+ import type { ExecutorContext } from '@nx/devkit';
24
+ import { createProjectGraphAsync, readProjectsConfigurationFromProjectGraph } from '@nx/devkit';
25
+ import { loadConfig } from '@webpieces/rules-config';
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+
29
+ export type ValidateTsInSrcMode = 'ON' | 'OFF';
30
+
31
+ export interface ValidateTsInSrcOptions {
32
+ mode?: ValidateTsInSrcMode;
33
+ excludePaths?: string[];
34
+ allowedRootFiles?: string[];
35
+ }
36
+
37
+ export interface ExecutorResult {
38
+ success: boolean;
39
+ }
40
+
41
+ const DEFAULT_EXCLUDE_PATHS: string[] = [
42
+ 'node_modules', 'dist', '.nx', '.git',
43
+ 'architecture', 'tmp', 'scripts',
44
+ ];
45
+
46
+ const DEFAULT_ALLOWED_ROOT_FILES: string[] = ['jest.setup.ts'];
47
+
48
+ class LayerOneViolation {
49
+ filePath: string;
50
+ projectName: string;
51
+
52
+ constructor(filePath: string, projectName: string) {
53
+ this.filePath = filePath;
54
+ this.projectName = projectName;
55
+ }
56
+ }
57
+
58
+ class LayerTwoViolation {
59
+ filePath: string;
60
+
61
+ constructor(filePath: string) {
62
+ this.filePath = filePath;
63
+ }
64
+ }
65
+
66
+ function isNodeModulesDir(name: string): boolean {
67
+ return name === 'node_modules' || name.startsWith('node_modules_');
68
+ }
69
+
70
+ function shouldSkipTopLevelDir(name: string, excludePaths: string[]): boolean {
71
+ if (isNodeModulesDir(name)) return true;
72
+ return excludePaths.includes(name);
73
+ }
74
+
75
+ async function getProjectRoots(workspaceRoot: string): Promise<string[]> {
76
+ const projectGraph = await createProjectGraphAsync();
77
+ const projectsConfig = readProjectsConfigurationFromProjectGraph(projectGraph);
78
+ const roots: string[] = [];
79
+ for (const cfg of Object.values(projectsConfig.projects)) {
80
+ if (cfg.root === '' || cfg.root === '.') continue;
81
+ if (cfg.root === 'architecture') continue;
82
+ roots.push(path.join(workspaceRoot, cfg.root));
83
+ }
84
+ return roots;
85
+ }
86
+
87
+ function findTsFilesOutsideSrc(projectDir: string): string[] {
88
+ const violations: string[] = [];
89
+ if (!fs.existsSync(projectDir)) return violations;
90
+ const entries = fs.readdirSync(projectDir, { withFileTypes: true });
91
+
92
+ for (const entry of entries) {
93
+ if (entry.name === 'src') continue;
94
+ if (isNodeModulesDir(entry.name)) continue;
95
+ if (entry.name === 'dist') continue;
96
+
97
+ if (entry.isFile() && entry.name.endsWith('.ts')) {
98
+ if (entry.name === 'jest.config.ts') continue;
99
+ violations.push(path.join(projectDir, entry.name));
100
+ }
101
+
102
+ if (entry.isDirectory()) {
103
+ const tsFiles = findTsFilesRecursively(path.join(projectDir, entry.name));
104
+ violations.push(...tsFiles);
105
+ }
106
+ }
107
+
108
+ return violations;
109
+ }
110
+
111
+ function findTsFilesRecursively(dir: string): string[] {
112
+ const results: string[] = [];
113
+ if (!fs.existsSync(dir)) return results;
114
+
115
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
116
+ for (const entry of entries) {
117
+ if (isNodeModulesDir(entry.name)) continue;
118
+ if (entry.name === 'dist') continue;
119
+ const fullPath = path.join(dir, entry.name);
120
+ if (entry.isFile() && entry.name.endsWith('.ts')) {
121
+ results.push(fullPath);
122
+ } else if (entry.isDirectory()) {
123
+ results.push(...findTsFilesRecursively(fullPath));
124
+ }
125
+ }
126
+ return results;
127
+ }
128
+
129
+ function findOrphanTsFiles(
130
+ dir: string,
131
+ projectRootSet: Set<string>,
132
+ workspaceRoot: string,
133
+ results: string[],
134
+ ): void {
135
+ if (!fs.existsSync(dir)) return;
136
+
137
+ const relDir = path.relative(workspaceRoot, dir);
138
+ if (projectRootSet.has(relDir)) return;
139
+
140
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
141
+ for (const entry of entries) {
142
+ if (isNodeModulesDir(entry.name)) continue;
143
+ if (entry.name === 'dist') continue;
144
+ const fullPath = path.join(dir, entry.name);
145
+ if (entry.isFile() && entry.name.endsWith('.ts')) {
146
+ results.push(fullPath);
147
+ } else if (entry.isDirectory()) {
148
+ findOrphanTsFiles(fullPath, projectRootSet, workspaceRoot, results);
149
+ }
150
+ }
151
+ }
152
+
153
+ function checkLayerOne(projectRoots: string[], workspaceRoot: string): LayerOneViolation[] {
154
+ const violations: LayerOneViolation[] = [];
155
+ for (const projectDir of projectRoots) {
156
+ const projectName = path.relative(workspaceRoot, projectDir);
157
+ const tsFiles = findTsFilesOutsideSrc(projectDir);
158
+ for (const tsFile of tsFiles) {
159
+ const relativePath = path.relative(workspaceRoot, tsFile);
160
+ violations.push(new LayerOneViolation(relativePath, projectName));
161
+ }
162
+ }
163
+ return violations;
164
+ }
165
+
166
+ function checkLayerTwo(
167
+ workspaceRoot: string,
168
+ projectRoots: string[],
169
+ excludePaths: string[],
170
+ allowedRootFiles: string[],
171
+ ): LayerTwoViolation[] {
172
+ const violations: LayerTwoViolation[] = [];
173
+ const projectRootSet = new Set(
174
+ projectRoots.map((p) => path.relative(workspaceRoot, p)),
175
+ );
176
+
177
+ const entries = fs.readdirSync(workspaceRoot, { withFileTypes: true });
178
+
179
+ for (const entry of entries) {
180
+ if (entry.isFile()) {
181
+ if (!entry.name.endsWith('.ts')) continue;
182
+ if (allowedRootFiles.includes(entry.name)) continue;
183
+ violations.push(new LayerTwoViolation(entry.name));
184
+ continue;
185
+ }
186
+ if (!entry.isDirectory()) continue;
187
+ if (shouldSkipTopLevelDir(entry.name, excludePaths)) continue;
188
+
189
+ const orphans: string[] = [];
190
+ findOrphanTsFiles(
191
+ path.join(workspaceRoot, entry.name),
192
+ projectRootSet,
193
+ workspaceRoot,
194
+ orphans,
195
+ );
196
+ for (const orphan of orphans) {
197
+ violations.push(new LayerTwoViolation(path.relative(workspaceRoot, orphan)));
198
+ }
199
+ }
200
+
201
+ return violations;
202
+ }
203
+
204
+ function reportLayerOneFailure(violations: LayerOneViolation[]): void {
205
+ console.error('❌ TypeScript files found outside src/ directory!\n');
206
+ console.error('All .ts source files must be inside the project\'s src/ directory.');
207
+ console.error('This enforces the standard project structure:\n');
208
+ console.error(' packages/{category}/{name}/');
209
+ console.error(' ├── src/ ← ALL .ts files here');
210
+ console.error(' ├── package.json');
211
+ console.error(' ├── project.json');
212
+ console.error(' └── tsconfig.json\n');
213
+
214
+ for (const v of violations) {
215
+ console.error(` ❌ ${v.filePath}`);
216
+ }
217
+
218
+ console.error('\nTo fix: Move the .ts file(s) into the src/ directory');
219
+ console.error('Only exception: jest.config.ts at project root\n');
220
+ }
221
+
222
+ function reportLayerTwoFailure(violations: LayerTwoViolation[]): void {
223
+ console.error('❌ TypeScript files found outside any Nx project!\n');
224
+ console.error('Every .ts file must belong to an Nx project so it is compiled,');
225
+ console.error('linted, and tested under a known project config. Orphan files are');
226
+ console.error('invisible to the build graph and will rot.\n');
227
+
228
+ for (const v of violations) {
229
+ console.error(` ❌ ${v.filePath}`);
230
+ }
231
+
232
+ console.error('\nTo fix, pick one:');
233
+ console.error(' (a) Move the file into an existing project\'s src/ directory');
234
+ console.error(' (b) Create a new project (add project.json) that owns the directory');
235
+ console.error(' (c) Add the containing top-level directory to validate-ts-in-src.excludePaths');
236
+ console.error(' in nx.json targetDefaults, or add the filename to allowedRootFiles');
237
+ console.error(' if it is a legitimate workspace-root file (e.g., jest.setup.ts)\n');
238
+ }
239
+
240
+ export default async function runExecutor(
241
+ _nxOptions: ValidateTsInSrcOptions,
242
+ context: ExecutorContext,
243
+ ): Promise<ExecutorResult> {
244
+ // Config comes from webpieces.config.json — same source as ai-hooks
245
+ // and validate-code — via @webpieces/rules-config.
246
+ const shared = loadConfig(context.root);
247
+ const rule = shared.rules.get('validate-ts-in-src');
248
+
249
+ if (rule && rule.enabled === false) {
250
+ console.log('\n⏭️ Skipping validate-ts-in-src (enabled: false)\n');
251
+ return { success: true };
252
+ }
253
+
254
+ const workspaceRoot = context.root;
255
+ const excludePaths =
256
+ (rule?.options['excludePaths'] as string[] | undefined) ?? DEFAULT_EXCLUDE_PATHS;
257
+ const allowedRootFiles =
258
+ (rule?.options['allowedRootFiles'] as string[] | undefined) ?? DEFAULT_ALLOWED_ROOT_FILES;
259
+
260
+ console.log('\n📁 Validating TypeScript files are in src/ and owned by a project\n');
261
+
262
+ const projectRoots = await getProjectRoots(workspaceRoot);
263
+
264
+ const layerOneViolations = checkLayerOne(projectRoots, workspaceRoot);
265
+ const layerTwoViolations = checkLayerTwo(
266
+ workspaceRoot, projectRoots, excludePaths, allowedRootFiles,
267
+ );
268
+
269
+ if (layerOneViolations.length === 0 && layerTwoViolations.length === 0) {
270
+ console.log('✅ All .ts files are inside a project\'s src/ directory\n');
271
+ return { success: true };
272
+ }
273
+
274
+ if (layerOneViolations.length > 0) {
275
+ reportLayerOneFailure(layerOneViolations);
276
+ }
277
+ if (layerTwoViolations.length > 0) {
278
+ reportLayerTwoFailure(layerTwoViolations);
279
+ }
280
+
281
+ console.error('To disable: set rules["validate-ts-in-src"].enabled to false in webpieces.config.json\n');
282
+ return { success: false };
283
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "title": "Validate TypeScript Files in src/ Executor",
4
+ "description": "Validates (1) every .ts file inside an Nx project lives under src/, and (2) every .ts file in the workspace is owned by some Nx project.",
5
+ "type": "object",
6
+ "properties": {
7
+ "mode": {
8
+ "type": "string",
9
+ "enum": ["ON", "OFF"],
10
+ "default": "ON",
11
+ "description": "ON = enforce both layers, OFF = skip validation"
12
+ },
13
+ "excludePaths": {
14
+ "type": "array",
15
+ "items": { "type": "string" },
16
+ "description": "Top-level workspace directory names to skip entirely when looking for orphan .ts files. Defaults: node_modules, dist, .nx, .git, architecture, tmp, scripts. node_modules_* backup directories are always skipped regardless of this list. Override replaces the defaults — re-list any defaults you want to keep."
17
+ },
18
+ "allowedRootFiles": {
19
+ "type": "array",
20
+ "items": { "type": "string" },
21
+ "description": "Workspace-root .ts filenames that are exempt from the 'must be owned by a project' rule. Defaults: jest.setup.ts. Override replaces the defaults."
22
+ }
23
+ },
24
+ "required": []
25
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Validate Versions Locked Executor
3
+ *
4
+ * Validates that package.json versions are:
5
+ * 1. LOCKED (exact versions, no semver ranges like ^, ~, *)
6
+ * 2. CONSISTENT across all package.json files (no version conflicts)
7
+ *
8
+ * Why locked versions matter:
9
+ * - Micro bugs ARE introduced via patch versions (1.4.5 → 1.4.6)
10
+ * - git bisect fails when software changes OUTSIDE of git
11
+ * - Library upgrades must be explicit via PR/commit, not implicit drift
12
+ *
13
+ * Usage:
14
+ * nx run architecture:validate-versions-locked
15
+ */
16
+
17
+ import type { ExecutorContext } from '@nx/devkit';
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { toError } from '../../toError';
21
+
22
+ export interface ValidateVersionsLockedOptions {
23
+ // No options needed
24
+ }
25
+
26
+ export interface ExecutorResult {
27
+ success: boolean;
28
+ }
29
+
30
+ // webpieces-disable max-lines-new-methods -- Existing method from renamed validate-versions file
31
+ // Find all package.json files except node_modules, dist, .nx, .angular
32
+ function findPackageJsonFiles(dir: string, basePath = ''): string[] {
33
+ const files: string[] = [];
34
+ const items = fs.readdirSync(dir);
35
+
36
+ for (const item of items) {
37
+ const fullPath = path.join(dir, item);
38
+ const relativePath = path.join(basePath, item);
39
+
40
+ // Skip these directories
41
+ if (
42
+ ['node_modules', 'dist', '.nx', '.angular', 'tmp', '.git'].includes(
43
+ item,
44
+ )
45
+ ) {
46
+ continue;
47
+ }
48
+
49
+ // Skip platform-specific node_modules backups (node_modules_mac, node_modules_linux, etc.)
50
+ if (item.startsWith('node_modules_')) {
51
+ continue;
52
+ }
53
+
54
+ // Skip all hidden directories (starting with .)
55
+ if (item.startsWith('.')) {
56
+ continue;
57
+ }
58
+
59
+ const stat = fs.statSync(fullPath);
60
+ if (stat.isDirectory()) {
61
+ files.push(...findPackageJsonFiles(fullPath, relativePath));
62
+ } else if (item === 'package.json') {
63
+ files.push(fullPath);
64
+ }
65
+ }
66
+
67
+ return files;
68
+ }
69
+
70
+ // Check if a version string uses semver ranges
71
+ function hasSemverRange(version: string): boolean {
72
+ // Allow workspace protocol
73
+ if (version.startsWith('workspace:')) {
74
+ return false;
75
+ }
76
+
77
+ // Allow file: protocol (for local packages)
78
+ if (version.startsWith('file:')) {
79
+ return false;
80
+ }
81
+
82
+ // Check for common semver range patterns
83
+ const semverPatterns = [
84
+ /^\^/, // ^1.2.3
85
+ /^~/, // ~1.2.3
86
+ /^\+/, // +1.2.3
87
+ /^\*/, // *
88
+ /^>/, // >1.2.3
89
+ /^</, // <1.2.3
90
+ /^>=/, // >=1.2.3
91
+ /^<=/, // <=1.2.3
92
+ /\|\|/, // 1.2.3 || 2.x
93
+ / - /, // 1.2.3 - 2.3.4
94
+ /^\d+\.x/, // 1.x, 1.2.x
95
+ /^latest$/, // latest
96
+ /^next$/, // next
97
+ ];
98
+
99
+ return semverPatterns.some((pattern) => pattern.test(version));
100
+ }
101
+
102
+ // webpieces-disable max-lines-new-methods -- Existing method from renamed validate-versions file
103
+ // Validate a single package.json file for semver ranges
104
+ function validatePackageJson(filePath: string): string[] {
105
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
106
+ try {
107
+ const content = fs.readFileSync(filePath, 'utf-8');
108
+ const pkg = JSON.parse(content);
109
+ const errors: string[] = [];
110
+
111
+ // Check dependencies
112
+ if (pkg.dependencies) {
113
+ for (const [name, version] of Object.entries(pkg.dependencies)) {
114
+ // Skip internal workspace packages
115
+ if (name.startsWith('@webpieces/')) {
116
+ continue;
117
+ }
118
+
119
+ if (hasSemverRange(version as string)) {
120
+ errors.push(
121
+ `dependencies.${name}: "${version}" uses semver range (must be locked to exact version)`,
122
+ );
123
+ }
124
+ }
125
+ }
126
+
127
+ // Check devDependencies
128
+ if (pkg.devDependencies) {
129
+ for (const [name, version] of Object.entries(pkg.devDependencies)) {
130
+ // Skip internal workspace packages
131
+ if (name.startsWith('@webpieces/')) {
132
+ continue;
133
+ }
134
+
135
+ if (hasSemverRange(version as string)) {
136
+ errors.push(
137
+ `devDependencies.${name}: "${version}" uses semver range (must be locked to exact version)`,
138
+ );
139
+ }
140
+ }
141
+ }
142
+
143
+ // Check peerDependencies (these can have ranges for compatibility)
144
+ // We don't validate peerDependencies for semver ranges since they're meant to be flexible
145
+
146
+ return errors;
147
+ } catch (err: unknown) {
148
+ const error = toError(err);
149
+ return [`Failed to parse ${filePath}: ${error.message}`];
150
+ }
151
+ }
152
+
153
+ // Track all dependency versions across the monorepo
154
+ interface DependencyUsage {
155
+ version: string;
156
+ file: string;
157
+ type: 'dependencies' | 'devDependencies';
158
+ }
159
+
160
+ // webpieces-disable max-lines-new-methods -- Collecting dependencies from all package.json files
161
+ // Collect all dependency versions from all package.json files
162
+ function collectAllDependencies(workspaceRoot: string): Map<string, DependencyUsage[]> {
163
+ const dependencyMap = new Map<string, DependencyUsage[]>();
164
+ const packageFiles = findPackageJsonFiles(workspaceRoot);
165
+
166
+ for (const filePath of packageFiles) {
167
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
168
+ try {
169
+ const content = fs.readFileSync(filePath, 'utf-8');
170
+ const pkg = JSON.parse(content);
171
+ const relativePath = path.relative(workspaceRoot, filePath);
172
+
173
+ // Collect dependencies
174
+ if (pkg.dependencies) {
175
+ for (const [name, version] of Object.entries(pkg.dependencies)) {
176
+ // Skip internal workspace packages
177
+ if (name.startsWith('@webpieces/')) continue;
178
+
179
+ const usage: DependencyUsage = {
180
+ version: version as string,
181
+ file: relativePath,
182
+ type: 'dependencies'
183
+ };
184
+
185
+ if (!dependencyMap.has(name)) {
186
+ dependencyMap.set(name, []);
187
+ }
188
+ dependencyMap.get(name)!.push(usage);
189
+ }
190
+ }
191
+
192
+ // Collect devDependencies
193
+ if (pkg.devDependencies) {
194
+ for (const [name, version] of Object.entries(pkg.devDependencies)) {
195
+ // Skip internal workspace packages
196
+ if (name.startsWith('@webpieces/')) continue;
197
+
198
+ const usage: DependencyUsage = {
199
+ version: version as string,
200
+ file: relativePath,
201
+ type: 'devDependencies'
202
+ };
203
+
204
+ if (!dependencyMap.has(name)) {
205
+ dependencyMap.set(name, []);
206
+ }
207
+ dependencyMap.get(name)!.push(usage);
208
+ }
209
+ }
210
+ } catch (err: unknown) {
211
+ // const error = toError(err);
212
+ // Intentionally skip files that can't be parsed - this is expected for some package.json files
213
+ }
214
+ }
215
+
216
+ return dependencyMap;
217
+ }
218
+
219
+ // webpieces-disable max-lines-new-methods -- Simple iteration logic, splitting would reduce clarity
220
+ // Check for version conflicts across package.json files
221
+ function checkVersionConflicts(workspaceRoot: string): string[] {
222
+ console.log('\n🔍 Checking for version conflicts across package.json files:');
223
+
224
+ const dependencyMap = collectAllDependencies(workspaceRoot);
225
+ const conflicts: string[] = [];
226
+
227
+ for (const [packageName, usages] of dependencyMap.entries()) {
228
+ // Get unique versions (ignoring workspace: and file: protocols)
229
+ const versions = new Set(
230
+ usages
231
+ .map(u => u.version)
232
+ .filter(v => !v.startsWith('workspace:') && !v.startsWith('file:'))
233
+ );
234
+
235
+ if (versions.size > 1) {
236
+ const conflictDetails = usages
237
+ .filter(u => !u.version.startsWith('workspace:') && !u.version.startsWith('file:'))
238
+ .map(u => ` ${u.file} (${u.type}): ${u.version}`)
239
+ .join('\n');
240
+
241
+ conflicts.push(` ❌ ${packageName} has ${versions.size} different versions:\n${conflictDetails}`);
242
+ }
243
+ }
244
+
245
+ if (conflicts.length === 0) {
246
+ console.log(' ✅ No version conflicts found');
247
+ } else {
248
+ for (const conflict of conflicts) {
249
+ console.log(conflict);
250
+ }
251
+ }
252
+
253
+ return conflicts;
254
+ }
255
+
256
+ /**
257
+ * Prints the educational message explaining why semver ranges are forbidden.
258
+ * This helps developers understand the rationale behind locked versions.
259
+ */
260
+ // webpieces-disable max-lines-new-methods -- Educational message template, splitting reduces clarity
261
+ function printSemverRangeEducationalMessage(semverErrors: number): void {
262
+ console.log(`
263
+ ❌ SEMVER RANGES DETECTED - BUILD FAILED
264
+
265
+ Found ${semverErrors} package(s) using semver ranges (^, ~, *, etc.) instead of locked versions.
266
+
267
+ WHY THIS IS A HARD FAILURE:
268
+ ═══════════════════════════════════════════════════════════════════════════════
269
+
270
+ 1. MICRO BUGS ARE REAL
271
+ Thinking that patch versions (1.4.5 → 1.4.6) don't introduce bugs is wrong.
272
+ They do. Sometimes what looks like an "easy fix" breaks things in subtle ways.
273
+
274
+ 2. GIT BISECT BECOMES USELESS
275
+ When you run "git bisect" to find when a bug was introduced, it fails if
276
+ software changed OUTSIDE of git. You checkout an old commit, but node_modules
277
+ has different versions than when that commit was made. The bug persists even
278
+ in "known good" commits because the library versions drifted.
279
+
280
+ 3. THE "MAGIC BUG" PROBLEM
281
+ You checkout code from 6 months ago to debug an issue. The bug is still there!
282
+ But it wasn't there 6 months ago... The culprit: a minor version upgrade that
283
+ happened silently without any PR or git commit. Impossible to track down.
284
+
285
+ 4. CHANGES OUTSIDE GIT = BAD
286
+ Every change to your software should be tracked in version control.
287
+ Implicit library upgrades via semver ranges violate this principle.
288
+
289
+ THE SOLUTION:
290
+ ═══════════════════════════════════════════════════════════════════════════════
291
+
292
+ Use LOCKED (exact) versions for all dependencies:
293
+ ❌ "lodash": "^4.17.21" <- BAD: allows 4.17.22, 4.18.0, etc.
294
+ ❌ "lodash": "~4.17.21" <- BAD: allows 4.17.22, 4.17.23, etc.
295
+ ✅ "lodash": "4.17.21" <- GOOD: locked to this exact version
296
+
297
+ To upgrade libraries, use an explicit process:
298
+ 1. Run: npm update <package-name>
299
+ 2. Test thoroughly
300
+ 3. Commit the package.json AND package-lock.json changes
301
+ 4. Create a PR so the upgrade is reviewed and tracked in git history
302
+
303
+ This way, every library change is:
304
+ • Intentional (not accidental)
305
+ • Reviewed (via PR)
306
+ • Tracked (in git history)
307
+ • Bisectable (git bisect works correctly)
308
+
309
+ `);
310
+ }
311
+
312
+ type SemverRangeResult = { errors: number };
313
+
314
+ // Check semver ranges in all package.json files - FAILS if any found
315
+ function checkSemverRanges(workspaceRoot: string): SemverRangeResult {
316
+ console.log('\n📋 Checking for unlocked versions (semver ranges):');
317
+ const packageFiles = findPackageJsonFiles(workspaceRoot);
318
+ let semverErrors = 0;
319
+
320
+ for (const filePath of packageFiles) {
321
+ const relativePath = path.relative(workspaceRoot, filePath);
322
+ const errors = validatePackageJson(filePath);
323
+
324
+ if (errors.length > 0) {
325
+ console.log(` ❌ ${relativePath}:`);
326
+ for (const error of errors) {
327
+ console.log(` ${error}`);
328
+ }
329
+ semverErrors += errors.length;
330
+ } else {
331
+ console.log(` ✅ ${relativePath}`);
332
+ }
333
+ }
334
+
335
+ return { errors: semverErrors };
336
+ }
337
+
338
+ export default async function runExecutor(
339
+ _options: ValidateVersionsLockedOptions,
340
+ context: ExecutorContext
341
+ ): Promise<ExecutorResult> {
342
+ console.log('\n🔒 Validating Package Versions are LOCKED and CONSISTENT\n');
343
+
344
+ const workspaceRoot = context.root;
345
+
346
+ // Step 1: Check for semver ranges (FAILS if any found)
347
+ const semverResult = checkSemverRanges(workspaceRoot);
348
+ const semverErrors = semverResult.errors;
349
+ const packageFiles = findPackageJsonFiles(workspaceRoot);
350
+
351
+ // Step 2: Check for version conflicts across package.json files
352
+ const versionConflicts = checkVersionConflicts(workspaceRoot);
353
+
354
+ // Summary
355
+ console.log(`\n📊 Summary:`);
356
+ console.log(` Files checked: ${packageFiles.length}`);
357
+ console.log(` Unlocked versions (semver ranges): ${semverErrors}`);
358
+ console.log(` Version conflicts: ${versionConflicts.length}`);
359
+
360
+ // Fail on semver ranges with educational message
361
+ if (semverErrors > 0) {
362
+ printSemverRangeEducationalMessage(semverErrors);
363
+ return { success: false };
364
+ }
365
+
366
+ // Fail on version conflicts
367
+ if (versionConflicts.length > 0) {
368
+ console.log('\n❌ VALIDATION FAILED!');
369
+ console.log(' Fix version conflicts - all package.json files must use the same version for each dependency.');
370
+ console.log(' This prevents "works on my machine" bugs where different projects use different library versions.\n');
371
+ return { success: false };
372
+ }
373
+
374
+ console.log('\n✅ VALIDATION PASSED! All versions are locked and consistent.');
375
+ return { success: true };
376
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "title": "Validate Versions Locked Executor",
4
+ "description": "Validates package.json versions are locked (no semver ranges) and consistent across all projects",
5
+ "type": "object",
6
+ "properties": {},
7
+ "required": []
8
+ }