@comity-dev/validate 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Thin orchestrator over Comity shared validation tooling. Discovers a repository, invokes the specialized engines (schema, dependency-cruiser, ESLint, Semgrep, adapter-peers), and returns a normalized result.
5
+ Thin orchestrator over Comity shared validation tooling. Discovers a repository, invokes the specialized engines (schema, dependency-cruiser, ESLint, Semgrep, adapter-peers, declarations), and returns a normalized result.
6
6
 
7
- This package is intentionally small. It does **not** implement any rule. Its only responsibilities are:
7
+ This package is intentionally small. It does **not** implement source-code rules; the in-process declaration engine
8
+ implements the rules in ADR-035. Its only responsibilities are:
8
9
 
9
10
  1. Discover repository configuration (`comity.config.json`).
10
11
  2. Discover packages.
@@ -23,6 +24,7 @@ The engines wrap shared Development-owned tooling:
23
24
  | `eslint` | ESLint + `@comity-dev/eslint-plugin` | Source-code lint policy. |
24
25
  | `semgrep` | `semgrep` + `@comity-dev/semgrep-rules` | Structural source patterns. |
25
26
  | `adapter-peers` | in-process | Adapter peer-dependency contract. |
27
+ | `declarations` | in-process + TypeScript | Published declaration dependency contract (ADR-035). |
26
28
 
27
29
  Engines report one of `PASS` / `FAIL` / `NOT-RUN` / `TOOL-UNAVAILABLE` / `CONFIGURATION-ERROR` / `EXECUTION-ERROR` / `NOT-APPLICABLE`. `PASS` is only reported when the engine actually executed.
28
30
 
@@ -47,7 +49,7 @@ import {
47
49
  ```bash
48
50
  comity-validate --repo /path/to/repo
49
51
  comity-validate --only=schema # one engine only
50
- comity-validate --only=eslint --verbose
52
+ comity-validate --only=declarations
51
53
  ```
52
54
 
53
55
  `--only <category>` runs a single engine. Policies owned by another engine are
@@ -59,7 +61,7 @@ Exit codes:
59
61
 
60
62
  - `0` — validation passed
61
63
  - `1` — validation failed (violations found)
62
- - `2` — configuration / discovery error
64
+ - `2` — configuration / discovery error (may co-occur with findings)
63
65
  - `3` — required tool not installed
64
66
 
65
67
  ## Environment
@@ -10,7 +10,7 @@
10
10
  * --verbose, -v Print underlying tool output
11
11
  * --repo <path> Repository root (default: cwd)
12
12
  * --only <category> Only run a single category
13
- * (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
13
+ * (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers | declarations | declaration-dependencies)
14
14
  * Default: run all categories.
15
15
  *
16
16
  * Exit codes:
@@ -10,7 +10,7 @@
10
10
  * --verbose, -v Print underlying tool output
11
11
  * --repo <path> Repository root (default: cwd)
12
12
  * --only <category> Only run a single category
13
- * (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
13
+ * (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers | declarations | declaration-dependencies)
14
14
  * Default: run all categories.
15
15
  *
16
16
  * Exit codes:
@@ -62,7 +62,7 @@ Options:
62
62
  --verbose, -v Print underlying tool output
63
63
  --repo <path> Repository root (default: cwd)
64
64
  --only <category> Only run a single category
65
- (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
65
+ (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers | declarations | declaration-dependencies)
66
66
 
67
67
  Exit codes:
68
68
  0 validation passed
@@ -83,15 +83,20 @@ async function main() {
83
83
  verbose: args.verbose,
84
84
  only: args.only,
85
85
  });
86
+ const exitCode = exitCodeForResult(result);
86
87
  console.log(formatSummary(result));
87
- if (!result.passed && args.verbose) {
88
+ if (result.violations.length > 0) {
88
89
  console.log("");
89
90
  console.log(formatViolations(result.violations));
90
91
  }
92
+ if (exitCode === ExitCode.CONFIG_ERROR && result.violations.length > 0) {
93
+ console.log("");
94
+ console.log("Configuration error(s) took precedence over validation findings (exit code 2).");
95
+ }
91
96
  // A required tool that could not run must never surface as PASS:
92
97
  // report it with the documented TOOL_MISSING exit code instead of
93
98
  // fabricating a passing result.
94
- process.exit(exitCodeForResult(result));
99
+ process.exit(exitCode);
95
100
  }
96
101
  catch (err) {
97
102
  const message = err instanceof Error ? err.message : String(err);
@@ -39,3 +39,31 @@ export interface DiscoverOptions {
39
39
  * package roots, and produce a normalized Repository handle.
40
40
  */
41
41
  export declare function discoverRepository({ root, }: DiscoverOptions): Promise<Repository>;
42
+ export interface ExcludedPackage {
43
+ /** The absolute path of the excluded package directory. */
44
+ path: string;
45
+ /** The package's `name` field, when it has a readable string name. */
46
+ name: string | null;
47
+ /** Why this package is not publishable. */
48
+ reason: string;
49
+ }
50
+ export interface PublishableDiscovery {
51
+ /** The configured package roots, relative to the repository root. */
52
+ roots: string[];
53
+ /** Whether the default root was used because no explicit roots were configured. */
54
+ usedDefaultRoots: boolean;
55
+ /** Every discovered package with a readable manifest and a string name, including private packages. */
56
+ allPackages: PackageRecord[];
57
+ /** Packages that participate in stage 1 declaration validation. */
58
+ publishable: PackageRecord[];
59
+ /** Packages excluded from validation, with the reason recorded. */
60
+ excluded: ExcludedPackage[];
61
+ }
62
+ /**
63
+ * Discover every package under the configured roots, with no namespace filter.
64
+ *
65
+ * This is the discovery used by the stage 1 and stage 2 engines. It deliberately
66
+ * does not change `discoverRepository`, whose existing engines still use the
67
+ * legacy name-based classification.
68
+ */
69
+ export declare function discoverPublishablePackages(root: string): PublishableDiscovery;
package/dist/discover.js CHANGED
@@ -5,8 +5,9 @@
5
5
  * roots to produce a normalized Repository handle that the orchestrator
6
6
  * passes to specialized validators.
7
7
  */
8
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
9
  import { readdir, readFile, stat } from "node:fs/promises";
9
- import { join, resolve } from "node:path";
10
+ import { join, matchesGlob, relative, resolve } from "node:path";
10
11
  /**
11
12
  * Discover a repository: load comity.config.json, walk the configured
12
13
  * package roots, and produce a normalized Repository handle.
@@ -72,3 +73,128 @@ export async function discoverRepository({ root, }) {
72
73
  config: config ?? {},
73
74
  };
74
75
  }
76
+ function isRecord(value) {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+ function readConfig(root) {
80
+ try {
81
+ const parsed = JSON.parse(readFileSync(join(root, "comity.config.json"), "utf8"));
82
+ return isRecord(parsed) ? parsed : null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ function configuredRoots(config) {
89
+ const packages = isRecord(config) && isRecord(config["packages"])
90
+ ? config["packages"]
91
+ : null;
92
+ const configured = packages?.["roots"];
93
+ const roots = Array.isArray(configured) &&
94
+ configured.length > 0 &&
95
+ configured.every((root) => typeof root === "string")
96
+ ? configured
97
+ : ["packages"];
98
+ const defaulted = !Array.isArray(configured) ||
99
+ configured.length === 0 ||
100
+ !configured.every((root) => typeof root === "string");
101
+ const excludeValue = packages?.["exclude"];
102
+ const exclude = Array.isArray(excludeValue)
103
+ ? excludeValue.filter((pattern) => typeof pattern === "string")
104
+ : [];
105
+ return { roots, usedDefaultRoots: defaulted, exclude };
106
+ }
107
+ function isExcluded(relativePath, patterns) {
108
+ return patterns.some((pattern) => matchesGlob(relativePath, pattern));
109
+ }
110
+ /**
111
+ * Discover every package under the configured roots, with no namespace filter.
112
+ *
113
+ * This is the discovery used by the stage 1 and stage 2 engines. It deliberately
114
+ * does not change `discoverRepository`, whose existing engines still use the
115
+ * legacy name-based classification.
116
+ */
117
+ export function discoverPublishablePackages(root) {
118
+ const absRoot = resolve(root);
119
+ const config = readConfig(absRoot);
120
+ const { roots, usedDefaultRoots, exclude } = configuredRoots(config);
121
+ const allPackages = [];
122
+ const publishable = [];
123
+ const excluded = [];
124
+ for (const relRoot of roots) {
125
+ const dir = join(absRoot, relRoot);
126
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
127
+ continue;
128
+ const entries = readdirSync(dir, { withFileTypes: true })
129
+ .filter((entry) => entry.isDirectory())
130
+ .sort((a, b) => a.name.localeCompare(b.name));
131
+ for (const entry of entries) {
132
+ const pkgDir = join(dir, entry.name);
133
+ const manifestPath = join(pkgDir, "package.json");
134
+ if (!existsSync(manifestPath) || !statSync(manifestPath).isFile())
135
+ continue;
136
+ let manifest;
137
+ try {
138
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
139
+ if (!isRecord(parsed)) {
140
+ excluded.push({
141
+ path: pkgDir,
142
+ name: null,
143
+ reason: "package.json is not a JSON object",
144
+ });
145
+ continue;
146
+ }
147
+ manifest = parsed;
148
+ }
149
+ catch {
150
+ excluded.push({
151
+ path: pkgDir,
152
+ name: null,
153
+ reason: "package.json is not readable JSON",
154
+ });
155
+ continue;
156
+ }
157
+ const name = manifest["name"];
158
+ if (typeof name !== "string" || name.length === 0) {
159
+ excluded.push({
160
+ path: pkgDir,
161
+ name: null,
162
+ reason: "package.json has no string name",
163
+ });
164
+ continue;
165
+ }
166
+ const relativePath = relative(absRoot, pkgDir).split("\\").join("/");
167
+ if (isExcluded(relativePath, exclude)) {
168
+ excluded.push({
169
+ path: pkgDir,
170
+ name,
171
+ reason: "matched packages.exclude",
172
+ });
173
+ continue;
174
+ }
175
+ const comity = isRecord(manifest["comity"]) ? manifest["comity"] : null;
176
+ const layer = comity?.["layer"];
177
+ const record = {
178
+ name,
179
+ path: pkgDir,
180
+ layer: typeof layer === "string" ? layer : null,
181
+ manifest,
182
+ };
183
+ allPackages.push(record);
184
+ if (manifest["private"] === true) {
185
+ excluded.push({
186
+ path: pkgDir,
187
+ name,
188
+ reason: "private package",
189
+ });
190
+ }
191
+ else {
192
+ publishable.push(record);
193
+ }
194
+ }
195
+ }
196
+ allPackages.sort((a, b) => a.path.localeCompare(b.path));
197
+ publishable.sort((a, b) => a.path.localeCompare(b.path));
198
+ excluded.sort((a, b) => a.path.localeCompare(b.path));
199
+ return { roots, usedDefaultRoots, allPackages, publishable, excluded };
200
+ }
@@ -0,0 +1,3 @@
1
+ import type { Repository } from "../discover.js";
2
+ import type { EngineResult } from "./types.js";
3
+ export declare function runDeclarationDependencies(repo: Repository): EngineResult;
@@ -0,0 +1,437 @@
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
+ 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
+ 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
+ function parseDeclaration(file) {
90
+ const text = readFileSync(file, "utf8");
91
+ const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
92
+ const relativeSpecifiers = new Set();
93
+ const externalSpecifiers = new Set();
94
+ const isModule = ts.isExternalModule(sourceFile);
95
+ const add = (specifier) => {
96
+ if (specifier.startsWith("."))
97
+ relativeSpecifiers.add(specifier);
98
+ else
99
+ externalSpecifiers.add(specifier);
100
+ };
101
+ const visit = (node) => {
102
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
103
+ if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
104
+ add(node.moduleSpecifier.text);
105
+ }
106
+ }
107
+ else if (ts.isImportEqualsDeclaration(node)) {
108
+ const reference = node.moduleReference;
109
+ if (ts.isExternalModuleReference(reference) &&
110
+ reference.expression &&
111
+ ts.isStringLiteral(reference.expression)) {
112
+ add(reference.expression.text);
113
+ }
114
+ }
115
+ else if (ts.isImportTypeNode(node)) {
116
+ const argument = node.argument;
117
+ if (ts.isLiteralTypeNode(argument) && ts.isStringLiteral(argument.literal)) {
118
+ add(argument.literal.text);
119
+ }
120
+ }
121
+ else if (isModule &&
122
+ ts.isModuleDeclaration(node) &&
123
+ ts.isStringLiteral(node.name)) {
124
+ add(node.name.text);
125
+ }
126
+ ts.forEachChild(node, visit);
127
+ };
128
+ visit(sourceFile);
129
+ for (const reference of sourceFile.referencedFiles)
130
+ add(reference.fileName);
131
+ for (const reference of sourceFile.typeReferenceDirectives) {
132
+ externalSpecifiers.add(reference.fileName);
133
+ }
134
+ return {
135
+ relative: [...relativeSpecifiers],
136
+ external: [...externalSpecifiers],
137
+ };
138
+ }
139
+ function resolveRelative(specifier, fromFile) {
140
+ const base = resolve(dirname(fromFile), specifier);
141
+ const candidates = [];
142
+ if (base.endsWith(".js"))
143
+ candidates.push(`${base.slice(0, -3)}.d.ts`);
144
+ else if (base.endsWith(".mjs"))
145
+ candidates.push(`${base.slice(0, -4)}.d.mts`);
146
+ else if (base.endsWith(".cjs"))
147
+ candidates.push(`${base.slice(0, -4)}.d.cts`);
148
+ else if (isDeclarationFile(base))
149
+ candidates.push(base);
150
+ candidates.push(`${base}.d.ts`, join(base, "index.d.ts"));
151
+ return candidates.find((candidate) => {
152
+ return existsSync(candidate) && statSync(candidate).isFile();
153
+ }) ?? null;
154
+ }
155
+ function packageNameOf(specifier) {
156
+ const parts = specifier.split("/");
157
+ return specifier.startsWith("@")
158
+ ? parts.slice(0, 2).join("/")
159
+ : (parts[0] ?? specifier);
160
+ }
161
+ function subpathOf(specifier, owner) {
162
+ if (specifier === owner)
163
+ return "";
164
+ return specifier.startsWith(`${owner}/`)
165
+ ? `.${specifier.slice(owner.length)}`
166
+ : "";
167
+ }
168
+ function typesPackageName(owner) {
169
+ if (!owner.startsWith("@"))
170
+ return `@types/${owner}`;
171
+ const [scope, name] = owner.split("/");
172
+ return `@types/${scope?.slice(1) ?? ""}__${name ?? ""}`;
173
+ }
174
+ function matchesSubpath(exportKeys, subpath) {
175
+ return exportKeys.some((key) => {
176
+ if (key === subpath)
177
+ return true;
178
+ if (!key.includes("*"))
179
+ return false;
180
+ const pattern = key
181
+ .split("*")
182
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
183
+ .join(".*");
184
+ return new RegExp(`^${pattern}$`).test(subpath);
185
+ });
186
+ }
187
+ function isBuiltin(specifier) {
188
+ return specifier.startsWith("node:") || BUILTINS.has(specifier);
189
+ }
190
+ function finding(context, rule, specifier, file, message, remediation) {
191
+ return {
192
+ tool: "declarations",
193
+ rule,
194
+ package: context.packageRecord.name,
195
+ file,
196
+ keyword: specifier,
197
+ severity: "error",
198
+ source: SOURCE,
199
+ message,
200
+ remediation,
201
+ };
202
+ }
203
+ function classifyExternal(specifier, file, context) {
204
+ const owner = packageNameOf(specifier);
205
+ if (isBuiltin(specifier))
206
+ return null;
207
+ const { packageRecord, sets } = context;
208
+ const subpath = subpathOf(specifier, owner);
209
+ if (owner === packageRecord.name) {
210
+ if (subpath && !matchesSubpath(sets.exports, subpath)) {
211
+ 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.");
212
+ }
213
+ return null;
214
+ }
215
+ const typesOwner = typesPackageName(owner);
216
+ const declared = sets.dependencies.has(owner) ||
217
+ sets.peers.has(owner) ||
218
+ sets.dependencies.has(typesOwner) ||
219
+ sets.peers.has(typesOwner);
220
+ if (declared) {
221
+ const optionalPeer = sets.optionalPeers.has(owner) && !sets.dependencies.has(owner);
222
+ if (optionalPeer && context.rootReached) {
223
+ 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.");
224
+ }
225
+ const sibling = context.packages.get(owner);
226
+ if (subpath && sibling) {
227
+ const siblingSets = dependencySets(sibling.manifest);
228
+ if (!matchesSubpath(siblingSets.exports, subpath)) {
229
+ 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.");
230
+ }
231
+ }
232
+ return null;
233
+ }
234
+ const devOwner = sets.dev.has(owner)
235
+ ? owner
236
+ : sets.dev.has(typesOwner)
237
+ ? typesOwner
238
+ : null;
239
+ if (devOwner) {
240
+ 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.`);
241
+ }
242
+ if (sets.optional.has(owner) || sets.optional.has(typesOwner)) {
243
+ const optionalOwner = sets.optional.has(owner) ? owner : typesOwner;
244
+ 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.`);
245
+ }
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 "${owner}" in neither dependencies nor peerDependencies`, `Add "${owner}" as a peerDependency (nominal or shared types) or a dependency (private structural types).`);
247
+ }
248
+ function countDeclarationFiles(directory) {
249
+ let count = 0;
250
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
251
+ if (entry.name === "node_modules" || entry.name === ".turbo")
252
+ continue;
253
+ const path = join(directory, entry.name);
254
+ if (entry.isDirectory())
255
+ count += countDeclarationFiles(path);
256
+ else if (entry.isFile() && isDeclarationFile(entry.name))
257
+ count += 1;
258
+ }
259
+ return count;
260
+ }
261
+ function sortFindings(findings) {
262
+ findings.sort((a, b) => {
263
+ return ((a.package ?? "").localeCompare(b.package ?? "") ||
264
+ (a.file ?? "").localeCompare(b.file ?? "") ||
265
+ (a.keyword ?? "").localeCompare(b.keyword ?? "") ||
266
+ a.rule.localeCompare(b.rule));
267
+ });
268
+ }
269
+ export function runDeclarationDependencies(repo) {
270
+ const start = Date.now();
271
+ const discovery = discoverPublishablePackages(repo.root);
272
+ if (discovery.publishable.length === 0) {
273
+ if (discovery.allPackages.length === 0) {
274
+ const defaultNote = discovery.usedDefaultRoots
275
+ ? " The default package root was used."
276
+ : "";
277
+ const excludedNote = discovery.excluded.length > 0
278
+ ? ` Excluded package directories: ${discovery.excluded.map((pkg) => pkg.path).join(", ")}.`
279
+ : "";
280
+ return {
281
+ executed: false,
282
+ exitCode: null,
283
+ passed: false,
284
+ findings: [],
285
+ duration: Date.now() - start,
286
+ status: "CONFIGURATION-ERROR",
287
+ tool: "declarations",
288
+ reason: `No package directory with a readable package.json was found under root(s): ${discovery.roots.join(", ")}.${defaultNote}${excludedNote}`,
289
+ };
290
+ }
291
+ const privatePackages = discovery.excluded
292
+ .filter((pkg) => pkg.reason === "private package")
293
+ .map((pkg) => pkg.name ?? pkg.path);
294
+ return {
295
+ executed: false,
296
+ exitCode: null,
297
+ passed: false,
298
+ findings: [],
299
+ duration: Date.now() - start,
300
+ status: "NOT-APPLICABLE",
301
+ tool: "declarations",
302
+ reason: `All discovered packages are private: ${privatePackages.join(", ")}.`,
303
+ };
304
+ }
305
+ const allPackages = new Map(discovery.allPackages.map((pkg) => [pkg.name, pkg]));
306
+ const findings = [];
307
+ let totalDeclarationFiles = 0;
308
+ let reachableDeclarationFiles = 0;
309
+ let externalReferenceCount = 0;
310
+ const configurationErrors = [];
311
+ for (const pkg of discovery.publishable) {
312
+ totalDeclarationFiles += countDeclarationFiles(pkg.path);
313
+ const targets = collectDeclarationTargets(pkg);
314
+ if (targets.size === 0)
315
+ continue;
316
+ const entries = [...targets.entries()].sort(([a], [b]) => a.localeCompare(b));
317
+ const missingTarget = [...targets.values()]
318
+ .flatMap((files) => [...files])
319
+ .sort()
320
+ .find((file) => !existsSync(file) || !statSync(file).isFile());
321
+ if (missingTarget) {
322
+ configurationErrors.push(`Package "${pkg.name}" declares a missing declaration target: ${relative(pkg.path, missingTarget).split("\\").join("/")}`);
323
+ continue;
324
+ }
325
+ const parsed = new Map();
326
+ const entryKeysByFile = new Map();
327
+ const unresolvedRelative = new Map();
328
+ const queue = [];
329
+ for (const [entry, files] of entries) {
330
+ for (const file of [...files].sort()) {
331
+ queue.push({ file, entry });
332
+ }
333
+ }
334
+ for (let index = 0; index < queue.length; index += 1) {
335
+ const item = queue[index];
336
+ if (!item)
337
+ continue;
338
+ const keys = entryKeysByFile.get(item.file) ?? new Set();
339
+ if (keys.has(item.entry))
340
+ continue;
341
+ keys.add(item.entry);
342
+ entryKeysByFile.set(item.file, keys);
343
+ let declaration = parsed.get(item.file);
344
+ if (!declaration) {
345
+ declaration = parseDeclaration(item.file);
346
+ parsed.set(item.file, declaration);
347
+ }
348
+ for (const specifier of declaration.relative) {
349
+ const resolved = resolveRelative(specifier, item.file);
350
+ if (resolved) {
351
+ queue.push({ file: resolved, entry: item.entry });
352
+ }
353
+ else {
354
+ const entriesForFile = unresolvedRelative.get(`${item.file}\0${specifier}`) ??
355
+ new Set();
356
+ entriesForFile.add(item.entry);
357
+ unresolvedRelative.set(`${item.file}\0${specifier}`, entriesForFile);
358
+ }
359
+ }
360
+ }
361
+ reachableDeclarationFiles += entryKeysByFile.size;
362
+ for (const [key, entryKeys] of unresolvedRelative) {
363
+ const [file, specifier] = key.split("\0");
364
+ if (!file || !specifier)
365
+ continue;
366
+ const entry = [...entryKeys].sort()[0] ?? "?";
367
+ findings.push({
368
+ tool: "declarations",
369
+ rule: "ARCH-DECL-DEP-000",
370
+ package: pkg.name,
371
+ file: relative(pkg.path, file).split("\\").join("/"),
372
+ keyword: specifier,
373
+ severity: "error",
374
+ source: SOURCE,
375
+ 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`,
376
+ remediation: "Rebuild the package, or fix the relative import so it resolves to an emitted declaration.",
377
+ });
378
+ }
379
+ for (const [file, entryKeys] of entryKeysByFile) {
380
+ const declaration = parsed.get(file);
381
+ if (!declaration)
382
+ continue;
383
+ const entry = [...entryKeys].sort()[0] ?? "?";
384
+ const rootReached = entryKeys.has(".");
385
+ const sets = dependencySets(pkg.manifest);
386
+ const context = {
387
+ packageRecord: pkg,
388
+ sets,
389
+ packages: allPackages,
390
+ rootReached,
391
+ entry,
392
+ };
393
+ for (const specifier of [...declaration.external].sort()) {
394
+ externalReferenceCount += 1;
395
+ const result = classifyExternal(specifier, relative(pkg.path, file).split("\\").join("/"), context);
396
+ if (result)
397
+ findings.push(result);
398
+ }
399
+ }
400
+ }
401
+ if (configurationErrors.length > 0) {
402
+ return {
403
+ executed: false,
404
+ exitCode: null,
405
+ passed: false,
406
+ findings: [],
407
+ duration: Date.now() - start,
408
+ status: "CONFIGURATION-ERROR",
409
+ tool: "declarations",
410
+ reason: configurationErrors.join("; "),
411
+ };
412
+ }
413
+ sortFindings(findings);
414
+ const excludedNotes = discovery.excluded.map((pkg) => {
415
+ return `${pkg.name ?? pkg.path} (${pkg.reason})`;
416
+ });
417
+ const notes = [
418
+ `${discovery.publishable.length} publishable package(s)`,
419
+ `${totalDeclarationFiles} declaration file(s) (${reachableDeclarationFiles} entry-reachable)`,
420
+ `${externalReferenceCount} external reference(s)`,
421
+ ];
422
+ if (discovery.usedDefaultRoots)
423
+ notes.push("default package root was used");
424
+ if (excludedNotes.length > 0) {
425
+ notes.push(`excluded: ${excludedNotes.join(", ")}`);
426
+ }
427
+ return {
428
+ executed: true,
429
+ exitCode: 0,
430
+ passed: findings.length === 0,
431
+ findings,
432
+ duration: Date.now() - start,
433
+ status: findings.length === 0 ? "PASS" : "FAIL",
434
+ tool: "declarations",
435
+ reason: notes.join("; "),
436
+ };
437
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 0 = validation passed
5
5
  * 1 = validation failed (violations found)
6
- * 2 = configuration / discovery error (cannot run)
6
+ * 2 = configuration / discovery error (cannot run; may co-occur with violations)
7
7
  * 3 = tool not installed (e.g. dependency-cruiser missing)
8
8
  */
9
9
  export declare const ExitCode: Readonly<{
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 0 = validation passed
5
5
  * 1 = validation failed (violations found)
6
- * 2 = configuration / discovery error (cannot run)
6
+ * 2 = configuration / discovery error (cannot run; may co-occur with violations)
7
7
  * 3 = tool not installed (e.g. dependency-cruiser missing)
8
8
  */
9
9
  export const ExitCode = Object.freeze({
package/dist/format.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface FormatInput {
21
21
  semgrep: CategoryResult;
22
22
  /** Adapter peers validation results */
23
23
  adapterPeers: CategoryResult;
24
+ /** Published declaration dependency validation results */
25
+ declarations: CategoryResult;
24
26
  };
25
27
  }
26
28
  /**
package/dist/format.js CHANGED
@@ -6,6 +6,7 @@ const CATEGORY_LABELS = {
6
6
  eslint: "Source rules (ESLint)",
7
7
  semgrep: "Structural patterns (Semgrep)",
8
8
  adapterPeers: "Adapter peers",
9
+ declarations: "Declarations",
9
10
  };
10
11
  function statusBadge(cat) {
11
12
  switch (cat.status) {
@@ -67,6 +68,9 @@ export function formatSummary(result) {
67
68
  cat.status === "NOT-APPLICABLE") {
68
69
  lines.push(` ${label}: ${cat.reason ?? cat.status}`);
69
70
  }
71
+ else if (cat.status === "PASS" && cat.reason) {
72
+ lines.push(` ${label}: ${cat.reason}`);
73
+ }
70
74
  }
71
75
  lines.push("");
72
76
  lines.push(`Result: ${overallResult(result)}`);
package/dist/run.d.ts CHANGED
@@ -45,6 +45,8 @@ export interface RunResult {
45
45
  semgrep: CategoryResult;
46
46
  /** Adapter peers validation results */
47
47
  adapterPeers: CategoryResult;
48
+ /** Published declaration dependency validation results */
49
+ declarations: CategoryResult;
48
50
  };
49
51
  }
50
52
  /**
package/dist/run.js CHANGED
@@ -2,6 +2,7 @@ import { dirname, resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { discoverRepository } from "./discover.js";
4
4
  import { runAdapterPeers } from "./engines/adapter-peers.js";
5
+ import { runDeclarationDependencies } from "./engines/declaration-dependencies.js";
5
6
  import { runDepcruise } from "./engines/depcruise.js";
6
7
  import { runEslint } from "./engines/eslint.js";
7
8
  import { runSchema } from "./engines/schema.js";
@@ -115,6 +116,15 @@ export async function runValidation(options) {
115
116
  else {
116
117
  adapterResult = emptyEngineResult("adapter-peers engine skipped");
117
118
  }
119
+ let declarationResult;
120
+ if (!only ||
121
+ only === "declarations" ||
122
+ only === "declaration-dependencies") {
123
+ declarationResult = runDeclarationDependencies(repo);
124
+ }
125
+ else {
126
+ declarationResult = emptyEngineResult("declarations engine skipped");
127
+ }
118
128
  const violations = [];
119
129
  for (const r of [
120
130
  schemaResult,
@@ -122,12 +132,19 @@ export async function runValidation(options) {
122
132
  eslintResult,
123
133
  semgrepResult,
124
134
  adapterResult,
135
+ declarationResult,
125
136
  ]) {
126
137
  if (Array.isArray(r.findings)) {
127
138
  violations.push(...r.findings);
128
139
  }
129
140
  }
130
- const allExecuted = [depResult, eslintResult, semgrepResult, adapterResult];
141
+ const allExecuted = [
142
+ depResult,
143
+ eslintResult,
144
+ semgrepResult,
145
+ adapterResult,
146
+ declarationResult,
147
+ ];
131
148
  const allPassed = allExecuted.every(categoryIsAcceptable) &&
132
149
  categoryIsAcceptable(metaResult) &&
133
150
  categoryIsAcceptable(configResult);
@@ -142,6 +159,7 @@ export async function runValidation(options) {
142
159
  eslint: categoryOf(eslintResult),
143
160
  semgrep: categoryOf(semgrepResult),
144
161
  adapterPeers: categoryOf(adapterResult),
162
+ declarations: categoryOf(declarationResult),
145
163
  },
146
164
  };
147
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comity-dev/validate",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Thin orchestrator over Comity shared validation tooling. Development tooling.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -31,20 +31,20 @@
31
31
  "./dist"
32
32
  ],
33
33
  "dependencies": {
34
- "@typescript-eslint/parser": "^8.69.0",
35
- "dependency-cruiser": "^16.10.4",
36
- "eslint": "^9.39.5",
37
34
  "@comity-dev/schemas": "0.2.0",
38
35
  "@comity-dev/dependency-rules": "0.2.0",
39
36
  "@comity-dev/eslint-plugin": "0.3.0",
40
- "@comity-dev/semgrep-rules": "0.2.2"
37
+ "@comity-dev/semgrep-rules": "0.2.3",
38
+ "@typescript-eslint/parser": "^8.69.0",
39
+ "dependency-cruiser": "^16.10.4",
40
+ "eslint": "^9.39.5",
41
+ "typescript": "^5.9.3"
41
42
  },
42
43
  "devDependencies": {
43
- "@types/node": "^24.13.3",
44
- "typescript": "^5.9.3"
44
+ "@types/node": "^24.13.3"
45
45
  },
46
46
  "scripts": {
47
- "build": "tsc -p tsconfig.json",
47
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
48
48
  "test": "vitest run"
49
49
  }
50
50
  }