@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.
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,8 @@ 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). |
28
+ | `packed-surface` | in-process + `pnpm pack` + system `tar` | Packed tarball surface: rules `ARCH-PACK-001…006` (ADR-035 stage 2). |
26
29
 
27
30
  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
31
 
@@ -47,7 +50,7 @@ import {
47
50
  ```bash
48
51
  comity-validate --repo /path/to/repo
49
52
  comity-validate --only=schema # one engine only
50
- comity-validate --only=eslint --verbose
53
+ comity-validate --only=declarations
51
54
  ```
52
55
 
53
56
  `--only <category>` runs a single engine. Policies owned by another engine are
@@ -55,11 +58,27 @@ not evaluated: `--only semgrep` does not check ESLint-owned policy, including
55
58
  the required `@internal` marker rule (`@comity-dev/require-internal-marker`).
56
59
  Use the default run (no `--only`) for full policy coverage.
57
60
 
61
+ ### `packed-surface` (ADR-035 stage 2)
62
+
63
+ `--only=packed-surface` packs every publishable package with `pnpm pack`, reads the resulting tarball (file list,
64
+ contents and packed `package.json`) with the system `tar`, and applies `ARCH-PACK-001…006`. It never reads the
65
+ workspace `dist`, so it observes the effect of the `files` field, `exports` and `workspace:` rewriting — which stage 1
66
+ cannot see.
67
+
68
+ It is deliberately **not part of the default run**: it packs every package (slower) and it runs in the producer
69
+ repository after `pnpm build` and before publication. Both release workflows run it after `Build` and before the
70
+ Changesets `Release` step.
71
+
72
+ **Enforcement is gated.** The release-workflow step blocks publication only when the repository variable
73
+ `COMITY_PACKED_SURFACE_ENFORCEMENT` is `true`. It must stay unset until **WP-A1** (Community `workspace:*` migration),
74
+ **WP-A4** (Development `@comity-dev/*` migration) and **WP-B** formal ratification are complete, because `pnpm pack`
75
+ rewrites `workspace:*` to an exact pin and `ARCH-PACK-005` reports those pins.
76
+
58
77
  Exit codes:
59
78
 
60
79
  - `0` — validation passed
61
80
  - `1` — validation failed (violations found)
62
- - `2` — configuration / discovery error
81
+ - `2` — configuration / discovery error (may co-occur with findings)
63
82
  - `3` — required tool not installed
64
83
 
65
84
  ## 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 | packed-surface)
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 | packed-surface)
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 | packed-surface)
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,29 @@
1
+ import type { PackageRecord, Repository } from "../discover.js";
2
+ import type { EngineResult, Finding } from "./types.js";
3
+ interface DependencySets {
4
+ dependencies: Set<string>;
5
+ peers: Set<string>;
6
+ optionalPeers: Set<string>;
7
+ dev: Set<string>;
8
+ optional: Set<string>;
9
+ exports: string[];
10
+ }
11
+ interface ParsedDeclaration {
12
+ relative: string[];
13
+ external: string[];
14
+ }
15
+ export interface QueryContext {
16
+ packageRecord: PackageRecord;
17
+ sets: DependencySets;
18
+ packages: Map<string, PackageRecord>;
19
+ rootReached: boolean;
20
+ entry: string;
21
+ tool?: string | undefined;
22
+ }
23
+ export declare function dependencySets(manifest: Record<string, unknown>): DependencySets;
24
+ export declare function isDeclarationFile(path: string): boolean;
25
+ export declare function parseDeclarationText(file: string, text: string): ParsedDeclaration;
26
+ export declare function classifyExternal(specifier: string, file: string, context: QueryContext): Finding | null;
27
+ export declare function sortFindings(findings: Finding[]): void;
28
+ export declare function runDeclarationDependencies(repo: Repository): EngineResult;
29
+ export {};