@comity-dev/validate 0.3.0 → 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
@@ -25,6 +25,7 @@ The engines wrap shared Development-owned tooling:
25
25
  | `semgrep` | `semgrep` + `@comity-dev/semgrep-rules` | Structural source patterns. |
26
26
  | `adapter-peers` | in-process | Adapter peer-dependency contract. |
27
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). |
28
29
 
29
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.
30
31
 
@@ -57,6 +58,22 @@ not evaluated: `--only semgrep` does not check ESLint-owned policy, including
57
58
  the required `@internal` marker rule (`@comity-dev/require-internal-marker`).
58
59
  Use the default run (no `--only`) for full policy coverage.
59
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
+
60
77
  Exit codes:
61
78
 
62
79
  - `0` — validation passed
@@ -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 | declarations | declaration-dependencies)
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 | declarations | declaration-dependencies)
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 | declarations | declaration-dependencies)
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
@@ -1,3 +1,29 @@
1
- import type { Repository } from "../discover.js";
2
- import type { EngineResult } from "./types.js";
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;
3
28
  export declare function runDeclarationDependencies(repo: Repository): EngineResult;
29
+ export {};
@@ -13,7 +13,7 @@ function stringSet(value) {
13
13
  return new Set();
14
14
  return new Set(Object.keys(value));
15
15
  }
16
- function dependencySets(manifest) {
16
+ export function dependencySets(manifest) {
17
17
  const peers = stringSet(manifest["peerDependencies"]);
18
18
  const meta = isRecord(manifest["peerDependenciesMeta"])
19
19
  ? manifest["peerDependenciesMeta"]
@@ -30,7 +30,7 @@ function dependencySets(manifest) {
30
30
  exports: isRecord(manifest["exports"]) ? Object.keys(manifest["exports"]) : [],
31
31
  };
32
32
  }
33
- function isDeclarationFile(path) {
33
+ export function isDeclarationFile(path) {
34
34
  return /\.d\.(?:ts|mts|cts)$/.test(path);
35
35
  }
36
36
  function collectDeclarationTargets(pkg) {
@@ -86,8 +86,7 @@ function collectDeclarationTargets(pkg) {
86
86
  }
87
87
  return targets;
88
88
  }
89
- function parseDeclaration(file) {
90
- const text = readFileSync(file, "utf8");
89
+ export function parseDeclarationText(file, text) {
91
90
  const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
92
91
  const relativeSpecifiers = new Set();
93
92
  const externalSpecifiers = new Set();
@@ -136,6 +135,9 @@ function parseDeclaration(file) {
136
135
  external: [...externalSpecifiers],
137
136
  };
138
137
  }
138
+ function parseDeclaration(file) {
139
+ return parseDeclarationText(file, readFileSync(file, "utf8"));
140
+ }
139
141
  function resolveRelative(specifier, fromFile) {
140
142
  const base = resolve(dirname(fromFile), specifier);
141
143
  const candidates = [];
@@ -189,7 +191,7 @@ function isBuiltin(specifier) {
189
191
  }
190
192
  function finding(context, rule, specifier, file, message, remediation) {
191
193
  return {
192
- tool: "declarations",
194
+ tool: context.tool ?? "declarations",
193
195
  rule,
194
196
  package: context.packageRecord.name,
195
197
  file,
@@ -200,7 +202,7 @@ function finding(context, rule, specifier, file, message, remediation) {
200
202
  remediation,
201
203
  };
202
204
  }
203
- function classifyExternal(specifier, file, context) {
205
+ export function classifyExternal(specifier, file, context) {
204
206
  const owner = packageNameOf(specifier);
205
207
  if (isBuiltin(specifier))
206
208
  return null;
@@ -258,7 +260,7 @@ function countDeclarationFiles(directory) {
258
260
  }
259
261
  return count;
260
262
  }
261
- function sortFindings(findings) {
263
+ export function sortFindings(findings) {
262
264
  findings.sort((a, b) => {
263
265
  return ((a.package ?? "").localeCompare(b.package ?? "") ||
264
266
  (a.file ?? "").localeCompare(b.file ?? "") ||
@@ -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 };
@@ -0,0 +1,654 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, matchesGlob, posix } from "node:path";
5
+ import { classifyExternal, dependencySets, isDeclarationFile, parseDeclarationText, sortFindings, } from "./declaration-dependencies.js";
6
+ import { discoverPublishablePackages } from "../discover.js";
7
+ const SOURCE = "ADR-035";
8
+ const DEPENDENCY_FIELDS = [
9
+ "dependencies",
10
+ "devDependencies",
11
+ "peerDependencies",
12
+ "optionalDependencies",
13
+ ];
14
+ const EXACT_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
15
+ const IMPORT_CONDITIONS = new Set(["node", "import", "default"]);
16
+ const REQUIRE_CONDITIONS = new Set(["node", "require", "default"]);
17
+ class ToolUnavailableError extends Error {
18
+ }
19
+ class PackedConfigurationError extends Error {
20
+ }
21
+ function isRecord(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ function run(command, args, cwd, timeout) {
25
+ const result = spawnSync(command, args, {
26
+ cwd,
27
+ encoding: "utf8",
28
+ env: process.env,
29
+ maxBuffer: 64 * 1024 * 1024,
30
+ timeout,
31
+ });
32
+ if (result.error) {
33
+ const code = result.error.code;
34
+ if (code === "ENOENT") {
35
+ throw new ToolUnavailableError(`Required tool "${command}" was not found on PATH`);
36
+ }
37
+ throw new PackedConfigurationError(`Failed to execute "${command} ${args.join(" ")}": ${result.error.message}`);
38
+ }
39
+ return {
40
+ status: result.status,
41
+ stdout: result.stdout ?? "",
42
+ stderr: result.stderr ?? "",
43
+ };
44
+ }
45
+ function normalizeTarPath(raw) {
46
+ if (raw.startsWith("/") || raw.includes("\0")) {
47
+ throw new PackedConfigurationError(`Unsafe absolute path in tarball: ${raw}`);
48
+ }
49
+ const normalized = posix.normalize(raw).replace(/^\.\//, "");
50
+ if (normalized === ".." ||
51
+ normalized.startsWith("../") ||
52
+ normalized.includes("/../")) {
53
+ throw new PackedConfigurationError(`Unsafe path traversal entry in tarball: ${raw}`);
54
+ }
55
+ if (!normalized.startsWith("package/")) {
56
+ throw new PackedConfigurationError(`Tarball entry is not under the "package/" prefix: ${raw}`);
57
+ }
58
+ return normalized.slice("package/".length);
59
+ }
60
+ function readTarball(tarballPath) {
61
+ const listing = run("tar", ["-tzf", tarballPath], process.cwd(), 60_000);
62
+ if (listing.status !== 0) {
63
+ throw new PackedConfigurationError(`Cannot read tarball ${tarballPath}: ${listing.stderr.trim() || `tar exited ${listing.status}`}`);
64
+ }
65
+ const files = listing.stdout
66
+ .split(/\r?\n/)
67
+ .map((line) => line.trim())
68
+ .filter((line) => line.length > 0)
69
+ .map(normalizeTarPath)
70
+ .filter((file) => file.length > 0 && !file.endsWith("/"))
71
+ .sort();
72
+ const fileSet = new Set(files);
73
+ const cache = new Map();
74
+ return {
75
+ files,
76
+ fileSet,
77
+ read(file) {
78
+ const cached = cache.get(file);
79
+ if (cached !== undefined)
80
+ return cached;
81
+ if (!fileSet.has(file)) {
82
+ throw new PackedConfigurationError(`Tarball entry is missing: package/${file}`);
83
+ }
84
+ const extracted = run("tar", ["-xOf", tarballPath, `package/${file}`], process.cwd(), 60_000);
85
+ if (extracted.status !== 0) {
86
+ throw new PackedConfigurationError(`Cannot read package/${file} from ${tarballPath}: ${extracted.stderr.trim() || `tar exited ${extracted.status}`}`);
87
+ }
88
+ cache.set(file, extracted.stdout);
89
+ return extracted.stdout;
90
+ },
91
+ };
92
+ }
93
+ function readPackedManifest(tar) {
94
+ const parsed = JSON.parse(tar.read("package.json"));
95
+ if (!isRecord(parsed)) {
96
+ throw new PackedConfigurationError("Packed package.json is not a JSON object");
97
+ }
98
+ return parsed;
99
+ }
100
+ function packPackage(pkg, destination) {
101
+ const result = run("pnpm", ["pack", "--pack-destination", destination, "--silent"], pkg.path, 120_000);
102
+ if (result.status !== 0) {
103
+ throw new PackedConfigurationError(`pnpm pack failed for "${pkg.name}": ${result.stderr.trim() || `pnpm exited ${result.status}`}`);
104
+ }
105
+ const tarballs = readdirSync(destination)
106
+ .filter((file) => file.endsWith(".tgz"))
107
+ .sort();
108
+ if (tarballs.length !== 1) {
109
+ throw new PackedConfigurationError(`pnpm pack for "${pkg.name}" produced ${tarballs.length} tarball(s) in ${destination}`);
110
+ }
111
+ const tarball = tarballs[0];
112
+ if (!tarball) {
113
+ throw new PackedConfigurationError(`pnpm pack for "${pkg.name}" produced no tarball`);
114
+ }
115
+ return join(destination, tarball);
116
+ }
117
+ function normalizeTarget(value) {
118
+ const normalized = value.replace(/\\/g, "/").replace(/^\.\//, "");
119
+ return posix.normalize(normalized);
120
+ }
121
+ function isSafeRelativeTarget(target) {
122
+ return (target.length > 0 &&
123
+ !target.startsWith("/") &&
124
+ target !== ".." &&
125
+ !target.startsWith("../"));
126
+ }
127
+ function addTarget(targets, field, value, entry) {
128
+ const target = normalizeTarget(value);
129
+ if (!isSafeRelativeTarget(target))
130
+ return;
131
+ targets.push({ field, target, entry });
132
+ }
133
+ function walkExportValue(targets, field, entry, value) {
134
+ if (typeof value === "string") {
135
+ addTarget(targets, field, value, entry);
136
+ return;
137
+ }
138
+ if (Array.isArray(value)) {
139
+ for (const item of value)
140
+ walkExportValue(targets, field, entry, item);
141
+ return;
142
+ }
143
+ if (!isRecord(value))
144
+ return;
145
+ for (const [key, child] of Object.entries(value)) {
146
+ walkExportValue(targets, field, key.startsWith(".") ? key : entry, child);
147
+ }
148
+ }
149
+ function collectManifestTargets(manifest) {
150
+ const targets = [];
151
+ const exportsField = manifest["exports"];
152
+ if (typeof exportsField === "string") {
153
+ addTarget(targets, "exports", exportsField, ".");
154
+ }
155
+ else if (isRecord(exportsField)) {
156
+ const hasSubpaths = Object.keys(exportsField).some((key) => key.startsWith("."));
157
+ if (hasSubpaths) {
158
+ for (const [key, value] of Object.entries(exportsField)) {
159
+ if (key.startsWith(".")) {
160
+ walkExportValue(targets, `exports["${key}"]`, key, value);
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ walkExportValue(targets, "exports", ".", exportsField);
166
+ }
167
+ }
168
+ for (const field of ["main", "module", "types"]) {
169
+ const value = manifest[field];
170
+ if (typeof value === "string")
171
+ addTarget(targets, field, value, ".");
172
+ }
173
+ const bin = manifest["bin"];
174
+ if (typeof bin === "string") {
175
+ addTarget(targets, "bin", bin, null);
176
+ }
177
+ else if (isRecord(bin)) {
178
+ for (const value of Object.values(bin)) {
179
+ if (typeof value === "string")
180
+ addTarget(targets, "bin", value, null);
181
+ }
182
+ }
183
+ const typesVersions = manifest["typesVersions"];
184
+ if (isRecord(typesVersions)) {
185
+ for (const mapping of Object.values(typesVersions)) {
186
+ if (!isRecord(mapping))
187
+ continue;
188
+ for (const [subpath, value] of Object.entries(mapping)) {
189
+ const entry = subpath.startsWith(".")
190
+ ? subpath
191
+ : subpath === "*"
192
+ ? "*"
193
+ : `./${subpath}`;
194
+ if (typeof value === "string") {
195
+ addTarget(targets, "typesVersions", value, entry);
196
+ }
197
+ else if (Array.isArray(value)) {
198
+ for (const item of value) {
199
+ if (typeof item === "string") {
200
+ addTarget(targets, "typesVersions", item, entry);
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ }
207
+ return targets;
208
+ }
209
+ function targetFiles(tar, target) {
210
+ if (target.includes("*")) {
211
+ return tar.files.filter((file) => matchesGlob(file, target));
212
+ }
213
+ return tar.fileSet.has(target) ? [target] : [];
214
+ }
215
+ function relativeCandidates(specifier, fromFile) {
216
+ const base = posix.normalize(posix.join(posix.dirname(fromFile), specifier));
217
+ const candidates = [];
218
+ if (base.endsWith(".js"))
219
+ candidates.push(`${base.slice(0, -3)}.d.ts`);
220
+ else if (base.endsWith(".mjs"))
221
+ candidates.push(`${base.slice(0, -4)}.d.mts`);
222
+ else if (base.endsWith(".cjs"))
223
+ candidates.push(`${base.slice(0, -4)}.d.cts`);
224
+ else if (isDeclarationFile(base))
225
+ candidates.push(base);
226
+ candidates.push(`${base}.d.ts`, posix.join(base, "index.d.ts"));
227
+ return candidates;
228
+ }
229
+ /**
230
+ * A recorded ARCH-PACK-005 exception (ADR-033 §4): a line in the package's
231
+ * `docs/conventions.md` that names the sibling and records the exception or
232
+ * its justification. ADR-033 does not prescribe a machine format, so the check
233
+ * stays deliberately permissive: the sibling must be named on the same line as
234
+ * the exception or justification wording.
235
+ */
236
+ function exactPinException(packagePath, sibling) {
237
+ const convention = join(packagePath, "docs", "conventions.md");
238
+ if (!existsSync(convention))
239
+ return false;
240
+ try {
241
+ return readFileSync(convention, "utf8")
242
+ .split(/\r?\n/)
243
+ .some((line) => line.includes(sibling) &&
244
+ /exception|justification/i.test(line) &&
245
+ !/\bno\s+(?:recorded\s+)?exceptions?\b/i.test(line));
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ function finding(pkg, rule, message, remediation, options = {}) {
252
+ return {
253
+ tool: "packed-surface",
254
+ rule,
255
+ package: pkg,
256
+ file: options.file,
257
+ keyword: options.keyword,
258
+ severity: "error",
259
+ source: SOURCE,
260
+ message,
261
+ remediation,
262
+ };
263
+ }
264
+ function dedupeFindings(findings) {
265
+ const seen = new Set();
266
+ const unique = [];
267
+ for (const item of findings) {
268
+ const key = [
269
+ item.package ?? "",
270
+ item.rule,
271
+ item.file ?? "",
272
+ item.keyword ?? "",
273
+ item.message,
274
+ ].join("\0");
275
+ if (seen.has(key))
276
+ continue;
277
+ seen.add(key);
278
+ unique.push(item);
279
+ }
280
+ return unique;
281
+ }
282
+ function resolveRuntimeTarget(value, conditions) {
283
+ if (typeof value === "string")
284
+ return value;
285
+ if (Array.isArray(value)) {
286
+ for (const item of value) {
287
+ const resolved = resolveRuntimeTarget(item, conditions);
288
+ if (resolved)
289
+ return resolved;
290
+ }
291
+ return null;
292
+ }
293
+ if (!isRecord(value))
294
+ return null;
295
+ for (const [key, child] of Object.entries(value)) {
296
+ if (key === "types")
297
+ continue;
298
+ if (key !== "default" && !conditions.has(key))
299
+ continue;
300
+ const resolved = resolveRuntimeTarget(child, conditions);
301
+ if (resolved)
302
+ return resolved;
303
+ }
304
+ return null;
305
+ }
306
+ function runtimeExports(manifest) {
307
+ const exportsField = manifest["exports"];
308
+ if (typeof exportsField === "string") {
309
+ return [{ key: ".", value: exportsField }];
310
+ }
311
+ if (!isRecord(exportsField))
312
+ return [];
313
+ const hasSubpaths = Object.keys(exportsField).some((key) => key.startsWith("."));
314
+ if (!hasSubpaths)
315
+ return [{ key: ".", value: exportsField }];
316
+ return Object.entries(exportsField)
317
+ .filter(([key]) => key.startsWith("."))
318
+ .map(([key, value]) => ({ key, value }));
319
+ }
320
+ /**
321
+ * Rules `ARCH-PACK-004` (unrewritten `workspace:`/`file:`/`link:` specifiers)
322
+ * and `ARCH-PACK-005` (exact sibling pins without a recorded ADR-033 exception),
323
+ * evaluated against the **packed** manifest.
324
+ *
325
+ * Exported so the `workspace:` branch of `ARCH-PACK-004` has direct test
326
+ * coverage: a real `pnpm pack` always rewrites `workspace:` protocols (and fails
327
+ * outright when it cannot resolve them), so a published tarball carrying a
328
+ * `workspace:` specifier can only come from a broken publish path, never from
329
+ * the packer this engine invokes.
330
+ */
331
+ export function packedDependencySpecifierFindings(pkg, manifest, siblingNames) {
332
+ const findings = [];
333
+ for (const field of DEPENDENCY_FIELDS) {
334
+ const values = manifest[field];
335
+ if (!isRecord(values))
336
+ continue;
337
+ for (const [name, value] of Object.entries(values)) {
338
+ if (typeof value !== "string")
339
+ continue;
340
+ if (!/^(?:workspace:|file:|link:)/.test(value))
341
+ continue;
342
+ findings.push(finding(pkg.name, "ARCH-PACK-004", `Package "${pkg.name}" ships "${name}": "${value}" in ${field}; published manifests must not contain workspace:, file: or link: specifiers`, "Rewrite the dependency as a published semver range.", { file: "package.json", keyword: name }));
343
+ }
344
+ }
345
+ for (const field of ["dependencies", "peerDependencies"]) {
346
+ const values = manifest[field];
347
+ if (!isRecord(values))
348
+ continue;
349
+ for (const [name, value] of Object.entries(values)) {
350
+ if (typeof value !== "string" ||
351
+ !siblingNames.has(name) ||
352
+ !EXACT_VERSION.test(value) ||
353
+ exactPinException(pkg.path, name)) {
354
+ continue;
355
+ }
356
+ findings.push(finding(pkg.name, "ARCH-PACK-005", `Package "${pkg.name}" pins sibling "${name}" to exact version "${value}" in ${field} without a recorded exception in docs/conventions.md`, `Use a caret range (workspace:^ publishes as ^${value}) or record the exception in docs/conventions.md.`, { file: "package.json", keyword: name }));
357
+ }
358
+ }
359
+ return findings;
360
+ }
361
+ function checkPackedPackage(packed, packages, siblingNames) {
362
+ const findings = [];
363
+ const pkg = packed.packageRecord;
364
+ const manifest = packed.manifest;
365
+ const targets = collectManifestTargets(manifest);
366
+ const entries = new Map();
367
+ let externalReferences = 0;
368
+ for (const target of targets) {
369
+ const matches = targetFiles(packed.tar, target.target);
370
+ if (matches.length === 0) {
371
+ findings.push(finding(pkg.name, "ARCH-PACK-001", `Package "${pkg.name}" declares ${target.field} target "${target.target}" but the tarball does not contain it`, "Include the target in the package files field, or correct the manifest target.", { file: target.target, keyword: target.target }));
372
+ continue;
373
+ }
374
+ if (target.entry === null)
375
+ continue;
376
+ for (const file of matches) {
377
+ if (!isDeclarationFile(file))
378
+ continue;
379
+ const keys = entries.get(file) ?? new Set();
380
+ keys.add(target.entry);
381
+ entries.set(file, keys);
382
+ }
383
+ }
384
+ const parsed = new Map();
385
+ const entryKeysByFile = new Map();
386
+ const unresolved = new Map();
387
+ const queue = [];
388
+ for (const [file, keys] of [...entries.entries()].sort(([a], [b]) => a.localeCompare(b))) {
389
+ for (const entry of [...keys].sort())
390
+ queue.push({ file, entry });
391
+ }
392
+ for (let index = 0; index < queue.length; index += 1) {
393
+ const item = queue[index];
394
+ if (!item)
395
+ continue;
396
+ const keys = entryKeysByFile.get(item.file) ?? new Set();
397
+ if (keys.has(item.entry))
398
+ continue;
399
+ keys.add(item.entry);
400
+ entryKeysByFile.set(item.file, keys);
401
+ let declaration = parsed.get(item.file);
402
+ if (!declaration) {
403
+ declaration = parseDeclarationText(item.file, packed.tar.read(item.file));
404
+ parsed.set(item.file, declaration);
405
+ }
406
+ for (const specifier of declaration.relative) {
407
+ const resolved = relativeCandidates(specifier, item.file).find((file) => packed.tar.fileSet.has(file));
408
+ if (resolved) {
409
+ queue.push({ file: resolved, entry: item.entry });
410
+ }
411
+ else {
412
+ const key = `${item.file}\0${specifier}`;
413
+ const entryKeys = unresolved.get(key) ?? new Set();
414
+ entryKeys.add(item.entry);
415
+ unresolved.set(key, entryKeys);
416
+ }
417
+ }
418
+ }
419
+ for (const [key, entryKeys] of unresolved) {
420
+ const [file, specifier] = key.split("\0");
421
+ if (!file || !specifier)
422
+ continue;
423
+ const entry = [...entryKeys].sort()[0] ?? "?";
424
+ findings.push(finding(pkg.name, "ARCH-PACK-002", `Package "${pkg.name}" publishes entry "${entry}" whose declaration "${file}" references "${specifier}", but the resolved declaration is absent from the tarball`, "Include the emitted declaration in the package files field, or fix the relative reference.", { file, keyword: specifier }));
425
+ }
426
+ if (entryKeysByFile.size === 0 && targets.some((target) => target.entry !== null)) {
427
+ findings.push(finding(pkg.name, "ARCH-PACK-002", `Package "${pkg.name}" declares declaration entry points but the tarball contains no reachable declaration file`, "Include the emitted declarations in the package files field."));
428
+ }
429
+ const sets = dependencySets(manifest);
430
+ const packedPackages = new Map(packages);
431
+ for (const [file, entryKeys] of [...entryKeysByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
432
+ const declaration = parsed.get(file);
433
+ if (!declaration)
434
+ continue;
435
+ const entry = [...entryKeys].sort()[0] ?? "?";
436
+ const context = {
437
+ packageRecord: pkg,
438
+ sets,
439
+ packages: packedPackages,
440
+ rootReached: entryKeys.has("."),
441
+ entry,
442
+ tool: "packed-surface",
443
+ };
444
+ for (const specifier of [...declaration.external].sort()) {
445
+ externalReferences += 1;
446
+ const result = classifyExternal(specifier, file, context);
447
+ if (result)
448
+ findings.push(result);
449
+ }
450
+ }
451
+ findings.push(...packedDependencySpecifierFindings({ name: pkg.name, path: packed.workspacePath }, manifest, siblingNames));
452
+ const cjs = packed.tar.files.filter((file) => file === "dist/cjs" || file.startsWith("dist/cjs/"));
453
+ if (cjs.length > 0) {
454
+ findings.push(finding(pkg.name, "ARCH-PACK-006", `Package "${pkg.name}" ships ${cjs.length} file(s) under dist/cjs/; Comity publishes a single ESM distribution`, "Remove the CommonJS build from the package outputs.", { file: "dist/cjs", keyword: "dist/cjs" }));
455
+ }
456
+ for (const entry of runtimeExports(manifest)) {
457
+ const importTarget = resolveRuntimeTarget(entry.value, IMPORT_CONDITIONS);
458
+ const requireTarget = resolveRuntimeTarget(entry.value, REQUIRE_CONDITIONS);
459
+ if (!importTarget || !requireTarget) {
460
+ findings.push(finding(pkg.name, "ARCH-PACK-006", `Package "${pkg.name}" exports "${entry.key}" but does not resolve for both import and require consumers`, "Add a default (or matching import/require) condition that resolves to the ESM artifact.", { file: "package.json", keyword: entry.key }));
461
+ continue;
462
+ }
463
+ if (importTarget !== requireTarget) {
464
+ findings.push(finding(pkg.name, "ARCH-PACK-006", `Package "${pkg.name}" exports "${entry.key}" with import "${importTarget}" and require "${requireTarget}"; both must resolve to the same ESM artifact`, "Point import and require at the same ESM file.", { file: "package.json", keyword: entry.key }));
465
+ continue;
466
+ }
467
+ if (/\.(?:cjs|cts)$/.test(importTarget)) {
468
+ findings.push(finding(pkg.name, "ARCH-PACK-006", `Package "${pkg.name}" exports "${entry.key}" to a CommonJS artifact "${importTarget}"`, "Publish the ESM artifact for this export.", { file: importTarget, keyword: entry.key }));
469
+ }
470
+ }
471
+ return {
472
+ findings: dedupeFindings(findings),
473
+ declarationFiles: entryKeysByFile.size,
474
+ externalReferences,
475
+ };
476
+ }
477
+ export function runPackedSurface(repo) {
478
+ const start = Date.now();
479
+ const discovery = discoverPublishablePackages(repo.root);
480
+ if (discovery.publishable.length === 0) {
481
+ if (discovery.allPackages.length === 0) {
482
+ const defaultNote = discovery.usedDefaultRoots
483
+ ? " The default package root was used."
484
+ : "";
485
+ const excludedNote = discovery.excluded.length > 0
486
+ ? ` Excluded package directories: ${discovery.excluded.map((pkg) => pkg.path).join(", ")}.`
487
+ : "";
488
+ return {
489
+ executed: false,
490
+ exitCode: null,
491
+ passed: false,
492
+ findings: [],
493
+ duration: Date.now() - start,
494
+ status: "CONFIGURATION-ERROR",
495
+ tool: "packed-surface",
496
+ reason: `No package directory with a readable package.json was found under root(s): ${discovery.roots.join(", ")}.${defaultNote}${excludedNote}`,
497
+ };
498
+ }
499
+ return {
500
+ executed: false,
501
+ exitCode: null,
502
+ passed: false,
503
+ findings: [],
504
+ duration: Date.now() - start,
505
+ status: "NOT-APPLICABLE",
506
+ tool: "packed-surface",
507
+ reason: `All discovered packages are private: ${discovery.excluded
508
+ .filter((pkg) => pkg.reason === "private package")
509
+ .map((pkg) => pkg.name ?? pkg.path)
510
+ .join(", ")}.`,
511
+ };
512
+ }
513
+ const tempRoot = mkdtempSync(join(tmpdir(), "comity-packed-surface-"));
514
+ const packed = [];
515
+ const configurationErrors = [];
516
+ try {
517
+ for (const [index, pkg] of discovery.publishable.entries()) {
518
+ const destination = join(tempRoot, `pack-${index}`);
519
+ mkdirSync(destination, { recursive: true });
520
+ try {
521
+ const tarballPath = packPackage(pkg, destination);
522
+ const tar = readTarball(tarballPath);
523
+ const manifest = readPackedManifest(tar);
524
+ packed.push({
525
+ packageRecord: { ...pkg, manifest },
526
+ workspacePath: pkg.path,
527
+ tarballPath,
528
+ tar,
529
+ manifest,
530
+ });
531
+ }
532
+ catch (error) {
533
+ if (error instanceof ToolUnavailableError)
534
+ throw error;
535
+ configurationErrors.push(error instanceof Error ? error.message : String(error));
536
+ }
537
+ }
538
+ }
539
+ catch (error) {
540
+ rmSync(tempRoot, { recursive: true, force: true });
541
+ if (error instanceof ToolUnavailableError) {
542
+ return {
543
+ executed: false,
544
+ exitCode: null,
545
+ passed: false,
546
+ findings: [],
547
+ duration: Date.now() - start,
548
+ status: "TOOL-UNAVAILABLE",
549
+ tool: "packed-surface",
550
+ reason: error.message,
551
+ };
552
+ }
553
+ return {
554
+ executed: false,
555
+ exitCode: null,
556
+ passed: false,
557
+ findings: [],
558
+ duration: Date.now() - start,
559
+ status: "CONFIGURATION-ERROR",
560
+ tool: "packed-surface",
561
+ reason: error instanceof Error ? error.message : String(error),
562
+ };
563
+ }
564
+ try {
565
+ const siblingNames = new Set(discovery.allPackages.map((pkg) => pkg.name));
566
+ const packedByName = new Map(packed.map((item) => [item.packageRecord.name, item]));
567
+ const packages = new Map();
568
+ for (const pkg of discovery.allPackages) {
569
+ const item = packedByName.get(pkg.name);
570
+ packages.set(pkg.name, item
571
+ ? { ...pkg, manifest: item.manifest }
572
+ : pkg);
573
+ }
574
+ const findings = [];
575
+ let declarationFiles = 0;
576
+ let externalReferences = 0;
577
+ for (const item of packed) {
578
+ try {
579
+ const result = checkPackedPackage(item, packages, siblingNames);
580
+ findings.push(...result.findings);
581
+ declarationFiles += result.declarationFiles;
582
+ externalReferences += result.externalReferences;
583
+ }
584
+ catch (error) {
585
+ if (error instanceof ToolUnavailableError)
586
+ throw error;
587
+ configurationErrors.push(`${item.packageRecord.name}: ${error instanceof Error ? error.message : String(error)}`);
588
+ }
589
+ }
590
+ sortFindings(findings);
591
+ if (configurationErrors.length > 0) {
592
+ return {
593
+ executed: false,
594
+ exitCode: null,
595
+ passed: false,
596
+ findings,
597
+ duration: Date.now() - start,
598
+ status: "CONFIGURATION-ERROR",
599
+ tool: "packed-surface",
600
+ reason: configurationErrors.join("; "),
601
+ };
602
+ }
603
+ const excludedNotes = discovery.excluded.map((pkg) => `${pkg.name ?? pkg.path} (${pkg.reason})`);
604
+ const notes = [
605
+ `${packed.length} publishable package(s)`,
606
+ `${declarationFiles} reachable declaration file(s)`,
607
+ `${externalReferences} external reference(s)`,
608
+ "external references evaluated against the packed manifests (ARCH-PACK-003 umbrella; violations are reported as ARCH-DECL-DEP-001…005)",
609
+ ];
610
+ if (discovery.usedDefaultRoots)
611
+ notes.push("default package root was used");
612
+ if (excludedNotes.length > 0) {
613
+ notes.push(`excluded: ${excludedNotes.join(", ")}`);
614
+ }
615
+ return {
616
+ executed: true,
617
+ exitCode: 0,
618
+ passed: findings.length === 0,
619
+ findings,
620
+ duration: Date.now() - start,
621
+ status: findings.length === 0 ? "PASS" : "FAIL",
622
+ tool: "packed-surface",
623
+ reason: notes.join("; "),
624
+ };
625
+ }
626
+ catch (error) {
627
+ if (error instanceof ToolUnavailableError) {
628
+ return {
629
+ executed: false,
630
+ exitCode: null,
631
+ passed: false,
632
+ findings: [],
633
+ duration: Date.now() - start,
634
+ status: "TOOL-UNAVAILABLE",
635
+ tool: "packed-surface",
636
+ reason: error.message,
637
+ };
638
+ }
639
+ return {
640
+ executed: false,
641
+ exitCode: null,
642
+ passed: false,
643
+ findings: [],
644
+ duration: Date.now() - start,
645
+ status: "CONFIGURATION-ERROR",
646
+ tool: "packed-surface",
647
+ reason: error instanceof Error ? error.message : String(error),
648
+ };
649
+ }
650
+ finally {
651
+ rmSync(tempRoot, { recursive: true, force: true });
652
+ }
653
+ }
654
+ export { readTarball };
package/dist/format.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface FormatInput {
23
23
  adapterPeers: CategoryResult;
24
24
  /** Published declaration dependency validation results */
25
25
  declarations: CategoryResult;
26
+ /** Packed-surface tarball validation results */
27
+ packedSurface: CategoryResult;
26
28
  };
27
29
  }
28
30
  /**
package/dist/format.js CHANGED
@@ -7,6 +7,7 @@ const CATEGORY_LABELS = {
7
7
  semgrep: "Structural patterns (Semgrep)",
8
8
  adapterPeers: "Adapter peers",
9
9
  declarations: "Declarations",
10
+ packedSurface: "Packed surface",
10
11
  };
11
12
  function statusBadge(cat) {
12
13
  switch (cat.status) {
package/dist/run.d.ts CHANGED
@@ -47,6 +47,8 @@ export interface RunResult {
47
47
  adapterPeers: CategoryResult;
48
48
  /** Published declaration dependency validation results */
49
49
  declarations: CategoryResult;
50
+ /** Packed-surface tarball validation results */
51
+ packedSurface: CategoryResult;
50
52
  };
51
53
  }
52
54
  /**
package/dist/run.js CHANGED
@@ -5,6 +5,7 @@ import { runAdapterPeers } from "./engines/adapter-peers.js";
5
5
  import { runDeclarationDependencies } from "./engines/declaration-dependencies.js";
6
6
  import { runDepcruise } from "./engines/depcruise.js";
7
7
  import { runEslint } from "./engines/eslint.js";
8
+ import { runPackedSurface } from "./engines/packed-surface.js";
8
9
  import { runSchema } from "./engines/schema.js";
9
10
  import { runSemgrep } from "./engines/semgrep.js";
10
11
  import { ExitCode } from "./exit-codes.js";
@@ -125,6 +126,15 @@ export async function runValidation(options) {
125
126
  else {
126
127
  declarationResult = emptyEngineResult("declarations engine skipped");
127
128
  }
129
+ let packedSurfaceResult;
130
+ if (only === "packed-surface") {
131
+ packedSurfaceResult = runPackedSurface(repo);
132
+ }
133
+ else {
134
+ packedSurfaceResult = emptyEngineResult(only
135
+ ? "packed-surface engine skipped"
136
+ : "packed-surface engine requires explicit --only=packed-surface");
137
+ }
128
138
  const violations = [];
129
139
  for (const r of [
130
140
  schemaResult,
@@ -133,6 +143,7 @@ export async function runValidation(options) {
133
143
  semgrepResult,
134
144
  adapterResult,
135
145
  declarationResult,
146
+ packedSurfaceResult,
136
147
  ]) {
137
148
  if (Array.isArray(r.findings)) {
138
149
  violations.push(...r.findings);
@@ -144,6 +155,7 @@ export async function runValidation(options) {
144
155
  semgrepResult,
145
156
  adapterResult,
146
157
  declarationResult,
158
+ packedSurfaceResult,
147
159
  ];
148
160
  const allPassed = allExecuted.every(categoryIsAcceptable) &&
149
161
  categoryIsAcceptable(metaResult) &&
@@ -160,6 +172,7 @@ export async function runValidation(options) {
160
172
  semgrep: categoryOf(semgrepResult),
161
173
  adapterPeers: categoryOf(adapterResult),
162
174
  declarations: categoryOf(declarationResult),
175
+ packedSurface: categoryOf(packedSurfaceResult),
163
176
  },
164
177
  };
165
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comity-dev/validate",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Thin orchestrator over Comity shared validation tooling. Development tooling.",
5
5
  "type": "module",
6
6
  "private": false,