Skip to content

Commit 47b4c04

Browse files
committed
chore: verify package runtime compatibility
1 parent 00b65c5 commit 47b4c04

File tree

2 files changed

+231
-1
lines changed

2 files changed

+231
-1
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
"scripts": {
1010
"clean": "rm -rf dist",
1111
"build": "npm run clean && tsc",
12-
"prepublishOnly": "npm run build",
12+
"verify:package": "node scripts/verify-package.mjs",
13+
"check:package": "npm run build && npm run verify:package",
14+
"prepublishOnly": "npm run check:package",
1315
"dev": "opencode plugin dev",
1416
"typecheck": "tsc --noEmit",
1517
"test": "node --import tsx --test tests/*.test.ts",

scripts/verify-package.mjs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { builtinModules, createRequire } from "node:module"
2+
import { existsSync, readFileSync, statSync } from "node:fs"
3+
import { execFileSync } from "node:child_process"
4+
import path from "node:path"
5+
import process from "node:process"
6+
import { fileURLToPath } from "node:url"
7+
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+
])
15+
16+
const requiredRepoFiles = [
17+
"dist/index.js",
18+
"dist/index.d.ts",
19+
"dist/lib/config.js",
20+
"README.md",
21+
"LICENSE",
22+
]
23+
24+
const requiredTarballFiles = [
25+
"package.json",
26+
"dist/index.js",
27+
"dist/index.d.ts",
28+
"dist/lib/config.js",
29+
"README.md",
30+
"LICENSE",
31+
]
32+
33+
const forbiddenTarballPatterns = [
34+
/^node_modules\//,
35+
/^lib\//,
36+
/^index\.ts$/,
37+
/^tests\//,
38+
/^scripts\//,
39+
/^docs\//,
40+
/^assets\//,
41+
/^notes\//,
42+
/^\.github\//,
43+
/^package-lock\.json$/,
44+
/^tsconfig\.json$/,
45+
]
46+
47+
const packageInfoCache = new Map()
48+
49+
function fail(message) {
50+
console.error(`package verification failed: ${message}`)
51+
process.exit(1)
52+
}
53+
54+
function assertRepoFilesExist() {
55+
for (const relativePath of requiredRepoFiles) {
56+
if (!existsSync(path.join(root, relativePath))) {
57+
fail(`missing required file: ${relativePath}`)
58+
}
59+
}
60+
}
61+
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+
}
75+
}
76+
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+
}))
83+
}
84+
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+
}
95+
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]
102+
}
103+
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+
]
117+
118+
for (const candidate of candidates) {
119+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
120+
}
121+
122+
fail(`unable to resolve local import ${specifier} from ${path.relative(root, importerPath)}`)
123+
}
124+
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
151+
}
152+
current = parent
153+
}
154+
}
155+
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()

0 commit comments

Comments
 (0)