@comity-dev/validate 0.2.3 → 0.4.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,439 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { builtinModules } from "node:module";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import ts from "typescript";
5
+ import { discoverPublishablePackages } from "../discover.js";
6
+ const SOURCE = "ADR-035";
7
+ const BUILTINS = new Set(builtinModules);
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ function stringSet(value) {
12
+ if (!isRecord(value))
13
+ return new Set();
14
+ return new Set(Object.keys(value));
15
+ }
16
+ export function dependencySets(manifest) {
17
+ const peers = stringSet(manifest["peerDependencies"]);
18
+ const meta = isRecord(manifest["peerDependenciesMeta"])
19
+ ? manifest["peerDependenciesMeta"]
20
+ : {};
21
+ return {
22
+ dependencies: stringSet(manifest["dependencies"]),
23
+ peers,
24
+ optionalPeers: new Set([...peers].filter((name) => {
25
+ const entry = meta[name];
26
+ return isRecord(entry) && entry["optional"] === true;
27
+ })),
28
+ dev: stringSet(manifest["devDependencies"]),
29
+ optional: stringSet(manifest["optionalDependencies"]),
30
+ exports: isRecord(manifest["exports"]) ? Object.keys(manifest["exports"]) : [],
31
+ };
32
+ }
33
+ export function isDeclarationFile(path) {
34
+ return /\.d\.(?:ts|mts|cts)$/.test(path);
35
+ }
36
+ function collectDeclarationTargets(pkg) {
37
+ const targets = new Map();
38
+ const add = (entry, value) => {
39
+ if (value.includes("*") || !isDeclarationFile(value))
40
+ return;
41
+ const absolute = resolve(pkg.path, value);
42
+ const files = targets.get(entry) ?? new Set();
43
+ files.add(absolute);
44
+ targets.set(entry, files);
45
+ };
46
+ const walk = (entry, value) => {
47
+ if (typeof value === "string") {
48
+ add(entry, value);
49
+ return;
50
+ }
51
+ if (Array.isArray(value)) {
52
+ for (const item of value)
53
+ walk(entry, item);
54
+ return;
55
+ }
56
+ if (isRecord(value)) {
57
+ for (const item of Object.values(value))
58
+ walk(entry, item);
59
+ }
60
+ };
61
+ const exportsField = pkg.manifest["exports"];
62
+ if (typeof exportsField === "string") {
63
+ walk(".", exportsField);
64
+ }
65
+ else if (isRecord(exportsField)) {
66
+ for (const [entry, value] of Object.entries(exportsField))
67
+ walk(entry, value);
68
+ }
69
+ const types = pkg.manifest["types"];
70
+ if (typeof types === "string")
71
+ add(".", types);
72
+ const typesVersions = pkg.manifest["typesVersions"];
73
+ if (isRecord(typesVersions)) {
74
+ for (const mapping of Object.values(typesVersions)) {
75
+ if (!isRecord(mapping))
76
+ continue;
77
+ for (const [subpath, value] of Object.entries(mapping)) {
78
+ const entry = subpath.startsWith(".")
79
+ ? subpath
80
+ : subpath === "*"
81
+ ? "*"
82
+ : `./${subpath}`;
83
+ walk(entry, value);
84
+ }
85
+ }
86
+ }
87
+ return targets;
88
+ }
89
+ export function parseDeclarationText(file, text) {
90
+ const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
91
+ const relativeSpecifiers = new Set();
92
+ const externalSpecifiers = new Set();
93
+ const isModule = ts.isExternalModule(sourceFile);
94
+ const add = (specifier) => {
95
+ if (specifier.startsWith("."))
96
+ relativeSpecifiers.add(specifier);
97
+ else
98
+ externalSpecifiers.add(specifier);
99
+ };
100
+ const visit = (node) => {
101
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
102
+ if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
103
+ add(node.moduleSpecifier.text);
104
+ }
105
+ }
106
+ else if (ts.isImportEqualsDeclaration(node)) {
107
+ const reference = node.moduleReference;
108
+ if (ts.isExternalModuleReference(reference) &&
109
+ reference.expression &&
110
+ ts.isStringLiteral(reference.expression)) {
111
+ add(reference.expression.text);
112
+ }
113
+ }
114
+ else if (ts.isImportTypeNode(node)) {
115
+ const argument = node.argument;
116
+ if (ts.isLiteralTypeNode(argument) && ts.isStringLiteral(argument.literal)) {
117
+ add(argument.literal.text);
118
+ }
119
+ }
120
+ else if (isModule &&
121
+ ts.isModuleDeclaration(node) &&
122
+ ts.isStringLiteral(node.name)) {
123
+ add(node.name.text);
124
+ }
125
+ ts.forEachChild(node, visit);
126
+ };
127
+ visit(sourceFile);
128
+ for (const reference of sourceFile.referencedFiles)
129
+ add(reference.fileName);
130
+ for (const reference of sourceFile.typeReferenceDirectives) {
131
+ externalSpecifiers.add(reference.fileName);
132
+ }
133
+ return {
134
+ relative: [...relativeSpecifiers],
135
+ external: [...externalSpecifiers],
136
+ };
137
+ }
138
+ function parseDeclaration(file) {
139
+ return parseDeclarationText(file, readFileSync(file, "utf8"));
140
+ }
141
+ function resolveRelative(specifier, fromFile) {
142
+ const base = resolve(dirname(fromFile), specifier);
143
+ const candidates = [];
144
+ if (base.endsWith(".js"))
145
+ candidates.push(`${base.slice(0, -3)}.d.ts`);
146
+ else if (base.endsWith(".mjs"))
147
+ candidates.push(`${base.slice(0, -4)}.d.mts`);
148
+ else if (base.endsWith(".cjs"))
149
+ candidates.push(`${base.slice(0, -4)}.d.cts`);
150
+ else if (isDeclarationFile(base))
151
+ candidates.push(base);
152
+ candidates.push(`${base}.d.ts`, join(base, "index.d.ts"));
153
+ return candidates.find((candidate) => {
154
+ return existsSync(candidate) && statSync(candidate).isFile();
155
+ }) ?? null;
156
+ }
157
+ function packageNameOf(specifier) {
158
+ const parts = specifier.split("/");
159
+ return specifier.startsWith("@")
160
+ ? parts.slice(0, 2).join("/")
161
+ : (parts[0] ?? specifier);
162
+ }
163
+ function subpathOf(specifier, owner) {
164
+ if (specifier === owner)
165
+ return "";
166
+ return specifier.startsWith(`${owner}/`)
167
+ ? `.${specifier.slice(owner.length)}`
168
+ : "";
169
+ }
170
+ function typesPackageName(owner) {
171
+ if (!owner.startsWith("@"))
172
+ return `@types/${owner}`;
173
+ const [scope, name] = owner.split("/");
174
+ return `@types/${scope?.slice(1) ?? ""}__${name ?? ""}`;
175
+ }
176
+ function matchesSubpath(exportKeys, subpath) {
177
+ return exportKeys.some((key) => {
178
+ if (key === subpath)
179
+ return true;
180
+ if (!key.includes("*"))
181
+ return false;
182
+ const pattern = key
183
+ .split("*")
184
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
185
+ .join(".*");
186
+ return new RegExp(`^${pattern}$`).test(subpath);
187
+ });
188
+ }
189
+ function isBuiltin(specifier) {
190
+ return specifier.startsWith("node:") || BUILTINS.has(specifier);
191
+ }
192
+ function finding(context, rule, specifier, file, message, remediation) {
193
+ return {
194
+ tool: context.tool ?? "declarations",
195
+ rule,
196
+ package: context.packageRecord.name,
197
+ file,
198
+ keyword: specifier,
199
+ severity: "error",
200
+ source: SOURCE,
201
+ message,
202
+ remediation,
203
+ };
204
+ }
205
+ export function classifyExternal(specifier, file, context) {
206
+ const owner = packageNameOf(specifier);
207
+ if (isBuiltin(specifier))
208
+ return null;
209
+ const { packageRecord, sets } = context;
210
+ const subpath = subpathOf(specifier, owner);
211
+ if (owner === packageRecord.name) {
212
+ if (subpath && !matchesSubpath(sets.exports, subpath)) {
213
+ return finding(context, "ARCH-DECL-DEP-004", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but references a subpath that is not exported by the package`, "Export the subpath or fix the reference.");
214
+ }
215
+ return null;
216
+ }
217
+ const typesOwner = typesPackageName(owner);
218
+ const declared = sets.dependencies.has(owner) ||
219
+ sets.peers.has(owner) ||
220
+ sets.dependencies.has(typesOwner) ||
221
+ sets.peers.has(typesOwner);
222
+ if (declared) {
223
+ const optionalPeer = sets.optionalPeers.has(owner) && !sets.dependencies.has(owner);
224
+ if (optionalPeer && context.rootReached) {
225
+ return finding(context, "ARCH-DECL-DEP-003", specifier, file, `Package "${packageRecord.name}" exposes optional peer "${owner}" in published declarations (${file}, via entry "${context.entry}") reachable from the root entry "."; consumers without it get unresolved types`, "Make the peer required, or move the reference behind an opt-in subpath.");
226
+ }
227
+ const sibling = context.packages.get(owner);
228
+ if (subpath && sibling) {
229
+ const siblingSets = dependencySets(sibling.manifest);
230
+ if (!matchesSubpath(siblingSets.exports, subpath)) {
231
+ return finding(context, "ARCH-DECL-DEP-005", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but "${subpath}" is not an exported subpath of "${owner}"`, "Import an exported subpath.");
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+ const devOwner = sets.dev.has(owner)
237
+ ? owner
238
+ : sets.dev.has(typesOwner)
239
+ ? typesOwner
240
+ : null;
241
+ if (devOwner) {
242
+ return finding(context, "ARCH-DECL-DEP-002", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but "${devOwner}" is only a devDependency; consumers get TS2307, or "any" under skipLibCheck`, `Move or add "${devOwner}" to peerDependencies or dependencies.`);
243
+ }
244
+ if (sets.optional.has(owner) || sets.optional.has(typesOwner)) {
245
+ const optionalOwner = sets.optional.has(owner) ? owner : typesOwner;
246
+ return finding(context, "ARCH-DECL-DEP-001", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but declares "${optionalOwner}" only in optionalDependencies; optional dependencies are not guaranteed to be installed for consumers`, `Move or add "${optionalOwner}" to peerDependencies or dependencies.`);
247
+ }
248
+ return finding(context, "ARCH-DECL-DEP-001", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but declares "${owner}" in neither dependencies nor peerDependencies`, `Add "${owner}" as a peerDependency (nominal or shared types) or a dependency (private structural types).`);
249
+ }
250
+ function countDeclarationFiles(directory) {
251
+ let count = 0;
252
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
253
+ if (entry.name === "node_modules" || entry.name === ".turbo")
254
+ continue;
255
+ const path = join(directory, entry.name);
256
+ if (entry.isDirectory())
257
+ count += countDeclarationFiles(path);
258
+ else if (entry.isFile() && isDeclarationFile(entry.name))
259
+ count += 1;
260
+ }
261
+ return count;
262
+ }
263
+ export function sortFindings(findings) {
264
+ findings.sort((a, b) => {
265
+ return ((a.package ?? "").localeCompare(b.package ?? "") ||
266
+ (a.file ?? "").localeCompare(b.file ?? "") ||
267
+ (a.keyword ?? "").localeCompare(b.keyword ?? "") ||
268
+ a.rule.localeCompare(b.rule));
269
+ });
270
+ }
271
+ export function runDeclarationDependencies(repo) {
272
+ const start = Date.now();
273
+ const discovery = discoverPublishablePackages(repo.root);
274
+ if (discovery.publishable.length === 0) {
275
+ if (discovery.allPackages.length === 0) {
276
+ const defaultNote = discovery.usedDefaultRoots
277
+ ? " The default package root was used."
278
+ : "";
279
+ const excludedNote = discovery.excluded.length > 0
280
+ ? ` Excluded package directories: ${discovery.excluded.map((pkg) => pkg.path).join(", ")}.`
281
+ : "";
282
+ return {
283
+ executed: false,
284
+ exitCode: null,
285
+ passed: false,
286
+ findings: [],
287
+ duration: Date.now() - start,
288
+ status: "CONFIGURATION-ERROR",
289
+ tool: "declarations",
290
+ reason: `No package directory with a readable package.json was found under root(s): ${discovery.roots.join(", ")}.${defaultNote}${excludedNote}`,
291
+ };
292
+ }
293
+ const privatePackages = discovery.excluded
294
+ .filter((pkg) => pkg.reason === "private package")
295
+ .map((pkg) => pkg.name ?? pkg.path);
296
+ return {
297
+ executed: false,
298
+ exitCode: null,
299
+ passed: false,
300
+ findings: [],
301
+ duration: Date.now() - start,
302
+ status: "NOT-APPLICABLE",
303
+ tool: "declarations",
304
+ reason: `All discovered packages are private: ${privatePackages.join(", ")}.`,
305
+ };
306
+ }
307
+ const allPackages = new Map(discovery.allPackages.map((pkg) => [pkg.name, pkg]));
308
+ const findings = [];
309
+ let totalDeclarationFiles = 0;
310
+ let reachableDeclarationFiles = 0;
311
+ let externalReferenceCount = 0;
312
+ const configurationErrors = [];
313
+ for (const pkg of discovery.publishable) {
314
+ totalDeclarationFiles += countDeclarationFiles(pkg.path);
315
+ const targets = collectDeclarationTargets(pkg);
316
+ if (targets.size === 0)
317
+ continue;
318
+ const entries = [...targets.entries()].sort(([a], [b]) => a.localeCompare(b));
319
+ const missingTarget = [...targets.values()]
320
+ .flatMap((files) => [...files])
321
+ .sort()
322
+ .find((file) => !existsSync(file) || !statSync(file).isFile());
323
+ if (missingTarget) {
324
+ configurationErrors.push(`Package "${pkg.name}" declares a missing declaration target: ${relative(pkg.path, missingTarget).split("\\").join("/")}`);
325
+ continue;
326
+ }
327
+ const parsed = new Map();
328
+ const entryKeysByFile = new Map();
329
+ const unresolvedRelative = new Map();
330
+ const queue = [];
331
+ for (const [entry, files] of entries) {
332
+ for (const file of [...files].sort()) {
333
+ queue.push({ file, entry });
334
+ }
335
+ }
336
+ for (let index = 0; index < queue.length; index += 1) {
337
+ const item = queue[index];
338
+ if (!item)
339
+ continue;
340
+ const keys = entryKeysByFile.get(item.file) ?? new Set();
341
+ if (keys.has(item.entry))
342
+ continue;
343
+ keys.add(item.entry);
344
+ entryKeysByFile.set(item.file, keys);
345
+ let declaration = parsed.get(item.file);
346
+ if (!declaration) {
347
+ declaration = parseDeclaration(item.file);
348
+ parsed.set(item.file, declaration);
349
+ }
350
+ for (const specifier of declaration.relative) {
351
+ const resolved = resolveRelative(specifier, item.file);
352
+ if (resolved) {
353
+ queue.push({ file: resolved, entry: item.entry });
354
+ }
355
+ else {
356
+ const entriesForFile = unresolvedRelative.get(`${item.file}\0${specifier}`) ??
357
+ new Set();
358
+ entriesForFile.add(item.entry);
359
+ unresolvedRelative.set(`${item.file}\0${specifier}`, entriesForFile);
360
+ }
361
+ }
362
+ }
363
+ reachableDeclarationFiles += entryKeysByFile.size;
364
+ for (const [key, entryKeys] of unresolvedRelative) {
365
+ const [file, specifier] = key.split("\0");
366
+ if (!file || !specifier)
367
+ continue;
368
+ const entry = [...entryKeys].sort()[0] ?? "?";
369
+ findings.push({
370
+ tool: "declarations",
371
+ rule: "ARCH-DECL-DEP-000",
372
+ package: pkg.name,
373
+ file: relative(pkg.path, file).split("\\").join("/"),
374
+ keyword: specifier,
375
+ severity: "error",
376
+ source: SOURCE,
377
+ message: `Package "${pkg.name}" has an unresolvable relative reference "${specifier}" in declaration "${relative(pkg.path, file).split("\\").join("/")}" (reached via entry "${entry}"); the built declaration graph is broken`,
378
+ remediation: "Rebuild the package, or fix the relative import so it resolves to an emitted declaration.",
379
+ });
380
+ }
381
+ for (const [file, entryKeys] of entryKeysByFile) {
382
+ const declaration = parsed.get(file);
383
+ if (!declaration)
384
+ continue;
385
+ const entry = [...entryKeys].sort()[0] ?? "?";
386
+ const rootReached = entryKeys.has(".");
387
+ const sets = dependencySets(pkg.manifest);
388
+ const context = {
389
+ packageRecord: pkg,
390
+ sets,
391
+ packages: allPackages,
392
+ rootReached,
393
+ entry,
394
+ };
395
+ for (const specifier of [...declaration.external].sort()) {
396
+ externalReferenceCount += 1;
397
+ const result = classifyExternal(specifier, relative(pkg.path, file).split("\\").join("/"), context);
398
+ if (result)
399
+ findings.push(result);
400
+ }
401
+ }
402
+ }
403
+ if (configurationErrors.length > 0) {
404
+ return {
405
+ executed: false,
406
+ exitCode: null,
407
+ passed: false,
408
+ findings: [],
409
+ duration: Date.now() - start,
410
+ status: "CONFIGURATION-ERROR",
411
+ tool: "declarations",
412
+ reason: configurationErrors.join("; "),
413
+ };
414
+ }
415
+ sortFindings(findings);
416
+ const excludedNotes = discovery.excluded.map((pkg) => {
417
+ return `${pkg.name ?? pkg.path} (${pkg.reason})`;
418
+ });
419
+ const notes = [
420
+ `${discovery.publishable.length} publishable package(s)`,
421
+ `${totalDeclarationFiles} declaration file(s) (${reachableDeclarationFiles} entry-reachable)`,
422
+ `${externalReferenceCount} external reference(s)`,
423
+ ];
424
+ if (discovery.usedDefaultRoots)
425
+ notes.push("default package root was used");
426
+ if (excludedNotes.length > 0) {
427
+ notes.push(`excluded: ${excludedNotes.join(", ")}`);
428
+ }
429
+ return {
430
+ executed: true,
431
+ exitCode: 0,
432
+ passed: findings.length === 0,
433
+ findings,
434
+ duration: Date.now() - start,
435
+ status: findings.length === 0 ? "PASS" : "FAIL",
436
+ tool: "declarations",
437
+ reason: notes.join("; "),
438
+ };
439
+ }
@@ -0,0 +1,22 @@
1
+ import type { PackageRecord, Repository } from "../discover.js";
2
+ import type { EngineResult, Finding } from "./types.js";
3
+ interface TarPackage {
4
+ files: string[];
5
+ fileSet: Set<string>;
6
+ read(file: string): string;
7
+ }
8
+ declare function readTarball(tarballPath: string): TarPackage;
9
+ /**
10
+ * Rules `ARCH-PACK-004` (unrewritten `workspace:`/`file:`/`link:` specifiers)
11
+ * and `ARCH-PACK-005` (exact sibling pins without a recorded ADR-033 exception),
12
+ * evaluated against the **packed** manifest.
13
+ *
14
+ * Exported so the `workspace:` branch of `ARCH-PACK-004` has direct test
15
+ * coverage: a real `pnpm pack` always rewrites `workspace:` protocols (and fails
16
+ * outright when it cannot resolve them), so a published tarball carrying a
17
+ * `workspace:` specifier can only come from a broken publish path, never from
18
+ * the packer this engine invokes.
19
+ */
20
+ export declare function packedDependencySpecifierFindings(pkg: Pick<PackageRecord, "name" | "path">, manifest: Record<string, unknown>, siblingNames: ReadonlySet<string>): Finding[];
21
+ export declare function runPackedSurface(repo: Repository): EngineResult;
22
+ export { readTarball };