@ultimat3/cli 6.0.0 → 7.0.0

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.
@@ -0,0 +1,241 @@
1
+ // The workspace dependency graph, in the two forms that must agree: the edges every manifest
2
+ // DECLARES, the edges every shipped source file actually IMPORTS, and the gate rule that the
3
+ // second is a subset of the first. An edge that lives only in a tsconfig `paths` entry resolves
4
+ // for `tsc` and is invisible to bun, to `--filter` ordering and to any change-detection tool.
5
+
6
+ import { join } from 'node:path';
7
+ import type { Finding } from './output';
8
+ import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
9
+ import { maskLiterals } from './ts-scan';
10
+
11
+ export interface WorkspaceNode {
12
+ /** package.json "name". */
13
+ readonly name: string;
14
+ /** Directory, root-relative and POSIX. */
15
+ readonly dir: string;
16
+ /** Workspace package names this one declares, across every dependency field. */
17
+ readonly dependencies: readonly string[];
18
+ /**
19
+ * package.json "version", `0.0.0` when the manifest states none. Optional on the type and always
20
+ * present in what `readWorkspaceGraph` returns — the range a `fix:` tells an author to pin to has
21
+ * to be the one the workspace really carries, or the edit it names fails `checkLockstep` next.
22
+ */
23
+ readonly version?: string;
24
+ }
25
+
26
+ /**
27
+ * Every field npm installs from. `devDependencies` counts as a declaration here — it resolves the
28
+ * import exactly like `dependencies` does; only the tarball tells them apart, which is
29
+ * `checkPublishShape`'s question and not this one.
30
+ */
31
+ const DEPENDENCY_FIELDS = [
32
+ 'dependencies',
33
+ 'devDependencies',
34
+ 'peerDependencies',
35
+ 'optionalDependencies',
36
+ ] as const;
37
+
38
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
39
+ typeof value === 'object' && value !== null && !Array.isArray(value)
40
+ ? (value as Record<string, unknown>)
41
+ : undefined;
42
+
43
+ const stringsOf = (value: unknown): readonly string[] =>
44
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
45
+
46
+ /**
47
+ * A manifest that will not parse, or will not open, is SKIPPED rather than thrown from. Both this
48
+ * rule and change detection read the whole graph in one pass, so one unparseable package.json
49
+ * anywhere in a monorepo would take the caller down with a `SyntaxError` naming no file — which is
50
+ * issue #281, open against `scripts/lib/workspaces.ts`, and not a defect worth having twice.
51
+ */
52
+ async function readManifest(path: string): Promise<Record<string, unknown> | undefined> {
53
+ try {
54
+ return asRecord(await Bun.file(path).json());
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /** The `workspaces` globs a root manifest declares, in both spellings npm accepts. */
61
+ export function workspaceGlobs(manifest: unknown): readonly string[] {
62
+ const field = asRecord(manifest)?.['workspaces'];
63
+ return Array.isArray(field) ? stringsOf(field) : stringsOf(asRecord(field)?.['packages']);
64
+ }
65
+
66
+ const declaredDepsOf = (manifest: Record<string, unknown>): readonly string[] =>
67
+ DEPENDENCY_FIELDS.flatMap((field) => Object.keys(asRecord(manifest[field]) ?? {}));
68
+
69
+ export interface WorkspaceScan {
70
+ readonly nodes: readonly WorkspaceNode[];
71
+ /**
72
+ * Manifest paths the root's globs claim that do not parse, or name no package. Skipping one is
73
+ * what keeps a caller alive; reporting it is what keeps the skip from being a hiding place — a
74
+ * workspace absent from the graph is a workspace no rule here can see.
75
+ */
76
+ readonly unreadable: readonly string[];
77
+ }
78
+
79
+ /**
80
+ * Every workspace the root manifest claims, sorted by directory. Two workspaces answering to one
81
+ * name keep the first by that order rather than the first the glob happened to yield — a graph a
82
+ * second run disagrees with is worse than either answer.
83
+ */
84
+ export async function scanWorkspaces(root: string): Promise<WorkspaceScan> {
85
+ const rootManifest = await readManifest(join(root, 'package.json'));
86
+ const found: { dir: string; name: string; version: string; declared: readonly string[] }[] = [];
87
+ const unreadable: string[] = [];
88
+ for (const pattern of workspaceGlobs(rootManifest)) {
89
+ for await (const relative of new Bun.Glob(`${pattern}/package.json`).scan({
90
+ cwd: root,
91
+ absolute: false,
92
+ })) {
93
+ if (isVendored(relative)) continue;
94
+ const manifest = await readManifest(join(root, relative));
95
+ const name = manifest?.['name'];
96
+ if (manifest === undefined || typeof name !== 'string' || name === '') {
97
+ unreadable.push(relative.replaceAll('\\', '/'));
98
+ continue;
99
+ }
100
+ const version = manifest['version'];
101
+ found.push({
102
+ dir: relative.replaceAll('\\', '/').slice(0, -'/package.json'.length),
103
+ name,
104
+ version: typeof version === 'string' ? version : '0.0.0',
105
+ declared: declaredDepsOf(manifest),
106
+ });
107
+ }
108
+ }
109
+ found.sort((left, right) => left.dir.localeCompare(right.dir));
110
+ const names = new Set<string>();
111
+ const unique = found.filter((entry) => {
112
+ if (names.has(entry.name)) return false;
113
+ names.add(entry.name);
114
+ return true;
115
+ });
116
+ // Second pass, once every name is known: an edge is only an edge to another WORKSPACE. A
117
+ // dependency on a registry package is a fact about the lockfile, not about this repo's shape.
118
+ const nodes = unique.map(({ dir, name, version, declared }) => ({
119
+ dir,
120
+ name,
121
+ version,
122
+ dependencies: [...new Set(declared)].filter((dep) => dep !== name && names.has(dep)),
123
+ }));
124
+ return { nodes, unreadable: unreadable.sort() };
125
+ }
126
+
127
+ /** The graph alone, for every caller that has nothing to report about the manifests it skipped. */
128
+ export const readWorkspaceGraph = async (root: string): Promise<readonly WorkspaceNode[]> =>
129
+ (await scanWorkspaces(root)).nodes;
130
+
131
+ /**
132
+ * The package a bare specifier names — `@scope/pkg/sub` → `@scope/pkg`, `pkg/sub` → `pkg`. A
133
+ * relative, absolute or protocol specifier (`node:fs`, `bun:test`) names no package at all.
134
+ */
135
+ export function packageOfSpecifier(specifier: string): string | undefined {
136
+ if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) {
137
+ return undefined;
138
+ }
139
+ const [first, second] = specifier.split('/');
140
+ if (first === undefined || first === '') return undefined;
141
+ if (!first.startsWith('@')) return first;
142
+ return second === undefined || second === '' ? undefined : `${first}/${second}`;
143
+ }
144
+
145
+ /**
146
+ * Every form that names a module: `… from '…'`, a bare `import '…'`, `import('…')`, `require('…')`.
147
+ * `from` is matched only where a quote follows it directly, so `from<Row>('posts', …)` — the query
148
+ * builder, which reads exactly like an import — cannot be one.
149
+ */
150
+ const IMPORT_FORM =
151
+ /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|(?:^|[;{])\s*import\s+)['"]([^'"]*)['"]/g;
152
+
153
+ /**
154
+ * Every package a source file imports, deduplicated, in first-appearance order.
155
+ *
156
+ * Read from the MASKED text, because a generator's template literal holds whole programs: every
157
+ * `templates/*.ts` here emits `import … from '@ultimat3/ui'` as a string, and a scan that read
158
+ * those would bill the CLI for the imports of the app it writes. The line a masked hit falls on is
159
+ * then re-read from the real source — the specifier itself is what masking blanks.
160
+ */
161
+ export function importedPackages(source: string): readonly string[] {
162
+ const masked = maskLiterals(source).split('\n');
163
+ const lines = source.split('\n');
164
+ const packages = new Set<string>();
165
+ for (const [index, maskedLine] of masked.entries()) {
166
+ const line = lines[index];
167
+ // `matchAll` clones the regex, so the shared `lastIndex` is never carried between lines.
168
+ if (line === undefined || [...maskedLine.matchAll(IMPORT_FORM)].length === 0) continue;
169
+ for (const match of line.matchAll(IMPORT_FORM)) {
170
+ const name = packageOfSpecifier(match[1] ?? '');
171
+ if (name !== undefined) packages.add(name);
172
+ }
173
+ }
174
+ return [...packages];
175
+ }
176
+
177
+ /**
178
+ * Borrowed, not twinned: `X_APP_PACKAGE_INVALID` already means "this package.json supplies no
179
+ * usable name", and a workspace manifest is that same file one directory down. `bun pm pkg set`
180
+ * is not the fix here — it parses the file it edits, so it fails on exactly the input this
181
+ * reports.
182
+ */
183
+ export const unreadableWorkspaceFinding = (path: string): Finding => ({
184
+ code: 'X_APP_PACKAGE_INVALID',
185
+ cause: `${path} is claimed by the root "workspaces" globs and supplies no readable "name"`,
186
+ fix: `repair the JSON and the "name" in ${path}, or drop its directory from "workspaces" in package.json`,
187
+ docs: 'https://ultimate.dev/errors/X_APP_PACKAGE_INVALID',
188
+ at: path,
189
+ });
190
+
191
+ export const undeclaredWorkspaceDepFinding = (
192
+ from: WorkspaceNode,
193
+ to: WorkspaceNode,
194
+ at: string,
195
+ ): Finding => ({
196
+ code: 'X_WORKSPACE_DEP_UNDECLARED',
197
+ cause: `${at} imports ${to.name}, which ${from.dir}/package.json does not declare`,
198
+ // The exact line to paste, at the version the target really carries: a `workspace:*` range would
199
+ // resolve and then fail `checkLockstep`, which compares a sibling pin against the version.
200
+ fix: `add "${to.name}": "${to.version ?? '0.0.0'}" to "dependencies" in ${from.dir}/package.json`,
201
+ docs: 'https://ultimate.dev/errors/X_WORKSPACE_DEP_UNDECLARED',
202
+ at: `${from.dir}/package.json`,
203
+ });
204
+
205
+ /**
206
+ * The declared graph must cover the real one. `x new` wrote packages that import each other and
207
+ * declared none of it — the imports resolved through the root tsconfig's `paths`, so the edges
208
+ * existed only inside `tsc` and every tool that asks what a change affects answered short.
209
+ *
210
+ * Shipped source only. A test file is excluded from the tarball by every package here
211
+ * (`TEST_EXCLUSION`) and resolves its imports through the ROOT manifest's hoisted devDependencies,
212
+ * which is why no `packages/*` in this repo carries a `devDependencies` block of its own —
213
+ * judging test imports would demand 29 of them to describe a resolution that already works.
214
+ *
215
+ * One finding per (workspace, dependency) pair, never one per import site: the fix is one line in
216
+ * one manifest, and repeating it per file is noise an author has to deduplicate by hand.
217
+ */
218
+ export async function checkWorkspaceDependencies(root: string): Promise<readonly Finding[]> {
219
+ const { nodes: graph, unreadable } = await scanWorkspaces(root);
220
+ const findings: Finding[] = unreadable.map(unreadableWorkspaceFinding);
221
+ if (graph.length === 0) return findings;
222
+ const byName = new Map(graph.map((node) => [node.name, node]));
223
+ // Deepest directory first: a nested workspace's files are its own, never its parent's.
224
+ const owners = [...graph].sort((left, right) => right.dir.length - left.dir.length);
225
+ const reported = new Set<string>();
226
+ for await (const path of eachSourceFile(root)) {
227
+ if (isTest(path) || isGenerated(path)) continue;
228
+ const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
229
+ if (owner === undefined) continue;
230
+ for (const name of importedPackages(await Bun.file(join(root, path)).text())) {
231
+ const target = byName.get(name);
232
+ if (target === undefined || target.name === owner.name) continue;
233
+ if (owner.dependencies.includes(target.name)) continue;
234
+ const pair = `${owner.dir} ${target.name}`;
235
+ if (reported.has(pair)) continue;
236
+ reported.add(pair);
237
+ findings.push(undeclaredWorkspaceDepFinding(owner, target, path));
238
+ }
239
+ }
240
+ return findings;
241
+ }