|
1 | | -import { builtinModules, createRequire } from "node:module" |
2 | | -import { existsSync, readFileSync, statSync } from "node:fs" |
3 | 1 | import { execFileSync } from "node:child_process" |
4 | | -import path from "node:path" |
5 | | -import process from "node:process" |
6 | | -import { fileURLToPath } from "node:url" |
| 2 | +import { existsSync, readFileSync } from "node:fs" |
7 | 3 |
|
8 | | -const require = createRequire(import.meta.url) |
9 | | -const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) |
10 | | - |
11 | | -const builtinNames = new Set([ |
12 | | - ...builtinModules, |
13 | | - ...builtinModules.map((name) => name.replace(/^node:/, "")), |
14 | | -]) |
| 4 | +const repoRoot = new URL("../", import.meta.url) |
| 5 | +const packageJsonPath = new URL("./package.json", repoRoot) |
| 6 | +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) |
15 | 7 |
|
16 | 8 | const requiredRepoFiles = [ |
17 | 9 | "dist/index.js", |
18 | 10 | "dist/index.d.ts", |
19 | 11 | "dist/lib/config.js", |
| 12 | + "dist/tui/index.d.ts", |
| 13 | + "index.ts", |
| 14 | + "lib/config.ts", |
| 15 | + "tui/index.tsx", |
| 16 | + "tui/data/context.ts", |
| 17 | + "tui/routes/summary.tsx", |
| 18 | + "tui/shared/names.ts", |
| 19 | + "tui/shared/theme.ts", |
| 20 | + "tui/shared/types.ts", |
| 21 | + "tui/slots/sidebar-content.tsx", |
20 | 22 | "README.md", |
21 | 23 | "LICENSE", |
| 24 | + "dcp.schema.json", |
22 | 25 | ] |
23 | 26 |
|
24 | 27 | const requiredTarballFiles = [ |
25 | 28 | "package.json", |
26 | 29 | "dist/index.js", |
27 | 30 | "dist/index.d.ts", |
28 | 31 | "dist/lib/config.js", |
| 32 | + "dist/tui/index.js", |
| 33 | + "dist/tui/index.d.ts", |
| 34 | + "index.ts", |
| 35 | + "lib/config.ts", |
| 36 | + "tui/index.tsx", |
| 37 | + "tui/data/context.ts", |
| 38 | + "tui/routes/summary.tsx", |
| 39 | + "tui/shared/names.ts", |
| 40 | + "tui/shared/theme.ts", |
| 41 | + "tui/shared/types.ts", |
| 42 | + "tui/slots/sidebar-content.tsx", |
29 | 43 | "README.md", |
30 | 44 | "LICENSE", |
| 45 | + "dcp.schema.json", |
31 | 46 | ] |
32 | 47 |
|
33 | 48 | const forbiddenTarballPatterns = [ |
| 49 | + /^tui\/node_modules\//, |
34 | 50 | /^node_modules\//, |
35 | | - /^lib\//, |
36 | | - /^index\.ts$/, |
37 | 51 | /^tests\//, |
38 | 52 | /^scripts\//, |
39 | 53 | /^docs\//, |
40 | 54 | /^assets\//, |
41 | 55 | /^notes\//, |
42 | 56 | /^\.github\//, |
43 | 57 | /^package-lock\.json$/, |
44 | | - /^tsconfig\.json$/, |
45 | 58 | ] |
46 | 59 |
|
47 | | -const packageInfoCache = new Map() |
48 | | - |
49 | | -function fail(message) { |
| 60 | +const fail = (message) => { |
50 | 61 | console.error(`package verification failed: ${message}`) |
51 | 62 | process.exit(1) |
52 | 63 | } |
53 | 64 |
|
54 | | -function assertRepoFilesExist() { |
55 | | - for (const relativePath of requiredRepoFiles) { |
56 | | - if (!existsSync(path.join(root, relativePath))) { |
57 | | - fail(`missing required file: ${relativePath}`) |
58 | | - } |
| 65 | +for (const relativePath of requiredRepoFiles) { |
| 66 | + const absolutePath = new URL(`./${relativePath}`, repoRoot) |
| 67 | + if (!existsSync(absolutePath)) { |
| 68 | + fail(`missing required repo file '${relativePath}'`) |
59 | 69 | } |
60 | 70 | } |
61 | 71 |
|
62 | | -function assertPackageJsonShape() { |
63 | | - const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) |
64 | | - |
65 | | - if (pkg.main !== "./dist/index.js") { |
66 | | - fail(`package.json main must remain ./dist/index.js, found ${pkg.main ?? "<missing>"}`) |
67 | | - } |
68 | | - |
69 | | - const files = Array.isArray(pkg.files) ? pkg.files : [] |
70 | | - for (const entry of ["dist/", "README.md", "LICENSE"]) { |
71 | | - if (!files.includes(entry)) { |
72 | | - fail(`package.json files must include ${entry}`) |
73 | | - } |
74 | | - } |
| 72 | +if (packageJson.exports?.["./tui"]?.import !== "./tui/index.tsx") { |
| 73 | + fail("expected package.json exports['./tui'].import to be './tui/index.tsx'") |
75 | 74 | } |
76 | 75 |
|
77 | | -function getImportStatements(source) { |
78 | | - const pattern = /^\s*import\s+([^\n;]+?)\s+from\s+["']([^"']+)["']/gm |
79 | | - return Array.from(source.matchAll(pattern), (match) => ({ |
80 | | - clause: match[1].trim(), |
81 | | - specifier: match[2], |
82 | | - })) |
| 76 | +if (packageJson.exports?.["."]?.import !== "./dist/index.js") { |
| 77 | + fail("expected package.json exports['.'].import to be './dist/index.js'") |
83 | 78 | } |
84 | 79 |
|
85 | | -function getImportKind(clause) { |
86 | | - if (clause.startsWith("type ")) return "type" |
87 | | - if (clause.startsWith("* as ")) return "namespace" |
88 | | - if (clause.startsWith("{")) return "named" |
89 | | - if (clause.includes(",")) { |
90 | | - const [, trailing = ""] = clause.split(",", 2) |
91 | | - return trailing.trim().startsWith("* as ") ? "default+namespace" : "default+named" |
92 | | - } |
93 | | - return "default" |
94 | | -} |
| 80 | +const packOutput = execFileSync("npm", ["pack", "--dry-run", "--json"], { |
| 81 | + cwd: repoRoot, |
| 82 | + encoding: "utf8", |
| 83 | +}) |
95 | 84 |
|
96 | | -function getPackageName(specifier) { |
97 | | - if (specifier.startsWith("@")) { |
98 | | - const parts = specifier.split("/") |
99 | | - return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : specifier |
100 | | - } |
101 | | - return specifier.split("/")[0] |
| 85 | +const packResult = JSON.parse(packOutput) |
| 86 | +if (!Array.isArray(packResult) || packResult.length !== 1 || !Array.isArray(packResult[0]?.files)) { |
| 87 | + fail("unexpected npm pack JSON output") |
102 | 88 | } |
103 | 89 |
|
104 | | -function resolveLocalImport(importerPath, specifier) { |
105 | | - const basePath = path.resolve(path.dirname(importerPath), specifier) |
106 | | - const candidates = [ |
107 | | - basePath, |
108 | | - `${basePath}.ts`, |
109 | | - `${basePath}.tsx`, |
110 | | - `${basePath}.js`, |
111 | | - `${basePath}.mjs`, |
112 | | - path.join(basePath, "index.ts"), |
113 | | - path.join(basePath, "index.tsx"), |
114 | | - path.join(basePath, "index.js"), |
115 | | - path.join(basePath, "index.mjs"), |
116 | | - ] |
| 90 | +const tarballFiles = new Set(packResult[0].files.map((entry) => entry.path)) |
117 | 91 |
|
118 | | - for (const candidate of candidates) { |
119 | | - if (existsSync(candidate) && statSync(candidate).isFile()) return candidate |
| 92 | +for (const relativePath of requiredTarballFiles) { |
| 93 | + if (!tarballFiles.has(relativePath)) { |
| 94 | + fail(`tarball is missing required file '${relativePath}'`) |
120 | 95 | } |
121 | | - |
122 | | - fail(`unable to resolve local import ${specifier} from ${path.relative(root, importerPath)}`) |
123 | 96 | } |
124 | 97 |
|
125 | | -function findPackageInfo(packageName, importerPath) { |
126 | | - const cacheKey = `${packageName}::${path.dirname(importerPath)}` |
127 | | - if (packageInfoCache.has(cacheKey)) { |
128 | | - return packageInfoCache.get(cacheKey) |
129 | | - } |
130 | | - |
131 | | - let entry |
132 | | - try { |
133 | | - entry = require.resolve(packageName, { paths: [path.dirname(importerPath)] }) |
134 | | - } catch { |
135 | | - packageInfoCache.set(cacheKey, null) |
136 | | - return null |
137 | | - } |
138 | | - |
139 | | - let current = path.dirname(entry) |
140 | | - while (true) { |
141 | | - const manifest = path.join(current, "package.json") |
142 | | - if (existsSync(manifest)) { |
143 | | - const info = JSON.parse(readFileSync(manifest, "utf8")) |
144 | | - packageInfoCache.set(cacheKey, info) |
145 | | - return info |
146 | | - } |
147 | | - const parent = path.dirname(current) |
148 | | - if (parent === current) { |
149 | | - packageInfoCache.set(cacheKey, null) |
150 | | - return null |
| 98 | +for (const relativePath of tarballFiles) { |
| 99 | + for (const pattern of forbiddenTarballPatterns) { |
| 100 | + if (pattern.test(relativePath)) { |
| 101 | + fail(`tarball contains forbidden path '${relativePath}'`) |
151 | 102 | } |
152 | | - current = parent |
153 | 103 | } |
154 | 104 | } |
155 | 105 |
|
156 | | -function packageLooksCommonJs(pkg) { |
157 | | - if (!pkg) return false |
158 | | - if (pkg.type === "commonjs") return true |
159 | | - |
160 | | - const main = typeof pkg.main === "string" ? pkg.main : "" |
161 | | - return /(?:^|\/)(cjs|umd)(?:\/|$)/.test(main) || main.endsWith(".cjs") |
162 | | -} |
163 | | - |
164 | | -function validateRuntimeImportGraph() { |
165 | | - const pending = [path.join(root, "index.ts")] |
166 | | - const seen = new Set() |
167 | | - |
168 | | - while (pending.length > 0) { |
169 | | - const filePath = pending.pop() |
170 | | - if (!filePath || seen.has(filePath)) continue |
171 | | - seen.add(filePath) |
172 | | - |
173 | | - const source = readFileSync(filePath, "utf8") |
174 | | - for (const entry of getImportStatements(source)) { |
175 | | - if (entry.specifier.startsWith(".")) { |
176 | | - pending.push(resolveLocalImport(filePath, entry.specifier)) |
177 | | - continue |
178 | | - } |
179 | | - |
180 | | - const packageName = getPackageName(entry.specifier) |
181 | | - if (builtinNames.has(packageName)) continue |
182 | | - |
183 | | - const kind = getImportKind(entry.clause) |
184 | | - if (kind === "type" || kind === "namespace") continue |
185 | | - |
186 | | - const pkg = findPackageInfo(packageName, filePath) |
187 | | - if (packageLooksCommonJs(pkg)) { |
188 | | - fail( |
189 | | - `${path.relative(root, filePath)} uses ${kind} import from CommonJS-style package ${packageName}`, |
190 | | - ) |
191 | | - } |
192 | | - } |
193 | | - } |
194 | | -} |
195 | | - |
196 | | -function validatePackedFiles() { |
197 | | - const output = execFileSync("npm", ["pack", "--dry-run", "--json"], { |
198 | | - cwd: root, |
199 | | - encoding: "utf8", |
200 | | - }) |
201 | | - |
202 | | - const [result] = JSON.parse(output) |
203 | | - if (!result || !Array.isArray(result.files)) { |
204 | | - fail("npm pack --dry-run --json did not return file metadata") |
205 | | - } |
206 | | - |
207 | | - const packedPaths = result.files.map((file) => file.path) |
208 | | - for (const required of requiredTarballFiles) { |
209 | | - if (!packedPaths.includes(required)) { |
210 | | - fail(`packed tarball is missing ${required}`) |
211 | | - } |
212 | | - } |
213 | | - |
214 | | - const forbidden = packedPaths.find((file) => |
215 | | - forbiddenTarballPatterns.some((pattern) => pattern.test(file)), |
216 | | - ) |
217 | | - if (forbidden) { |
218 | | - fail(`packed tarball contains forbidden path ${forbidden}`) |
219 | | - } |
220 | | - |
221 | | - console.log(`package verification passed for ${result.name}@${result.version}`) |
222 | | - console.log(`tarball entries: ${result.entryCount}`) |
223 | | -} |
224 | | - |
225 | | -assertRepoFilesExist() |
226 | | -assertPackageJsonShape() |
227 | | -validateRuntimeImportGraph() |
228 | | -validatePackedFiles() |
| 106 | +console.log(`package verification passed for ${packageJson.name}@${packageJson.version}`) |
| 107 | +console.log(`tarball entries: ${packResult[0].entryCount}`) |
0 commit comments