diff --git a/CONTEXT.md b/CONTEXT.md index a9698d4..7943cb3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,6 +15,10 @@ _Avoid_: tool version, upstream version **Package version**: The npm semver of this package. Bumps independently of the ShellCheck version so wrapper fixes can ship without a new ShellCheck release. +**Build info**: +The metadata describing how a given Artifact was built: its ShellCheck version, toolchain versions, compiler flags, target features, digest and size. Produced together with the Artifact and exposed to Hosts as a typed value. +_Avoid_: build metadata, manifest + **Command module**: The WASI execution model the Artifact uses: exports `_start`, reads argv/stdin, writes stdout/stderr, exits. One run per `WebAssembly.Instance`. Chosen over a reactor module so output stays byte-identical to native ShellCheck. _Avoid_: reactor, JSFFI module diff --git a/README.md b/README.md index 8a32d14..a50cb6d 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ threading, cancellation or filesystem policy; those are host concerns. ## Entry points -| Subpath | Provides | -| ------------------- | ----------------------------------------------------------------------------------- | -| `.` | `run`, `wasmUrl`, `SHELLCHECK_VERSION`, `RunOptions`, `RunResult`; no Node imports | -| `./node` | The above plus `loadModule`, `createReadOnlyPreopen`, `wasmPath`, `ReadOnlyPreopen` | -| `./shellcheck.wasm` | The compiled ShellCheck module | -| `./build-info.json` | Toolchain and feature metadata for the bundled module | -| `./package.json` | The package manifest | +| Subpath | Provides | +| ------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `.` | `run`, `wasmUrl`, `SHELLCHECK_VERSION`, `RunOptions`, `RunResult`; no Node imports | +| `./node` | The above plus `loadModule`, `createReadOnlyPreopen`, `readBuildInfo`, `wasmPath`, `ReadOnlyPreopen`, `BuildInfo` | +| `./build-info` | `readBuildInfo`, `BuildInfo`: toolchain and feature metadata for the bundled module, without the runner | +| `./shellcheck.wasm` | The compiled ShellCheck module | +| `./package.json` | The package manifest | Bundlers that relocate modules can copy the artifact from `require.resolve("@vscode-shellcheck/shellcheck-wasm/shellcheck.wasm")` and pass their own @@ -52,7 +52,7 @@ Bundlers that relocate modules can copy the artifact from ## Versioning The package follows its own semver, independent of the bundled ShellCheck release exposed as -`SHELLCHECK_VERSION`; `build-info.json` describes the build that produced the artifact. +`SHELLCHECK_VERSION`; `readBuildInfo()` describes the build that produced the artifact. ## License diff --git a/package.json b/package.json index d121788..0dc9dd9 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,10 @@ "default": "./dist/node.js" }, "./shellcheck.wasm": "./dist/shellcheck.wasm", - "./build-info.json": "./dist/build-info.json", + "./build-info": { + "types": "./dist/build-info.d.ts", + "default": "./dist/build-info.js" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/src/build-info.ts b/src/build-info.ts new file mode 100644 index 0000000..dc4c73e --- /dev/null +++ b/src/build-info.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; + +/** How the bundled shellcheck.wasm was built; mirrors `dist/build-info.json`. */ +export interface BuildInfo { + /** ShellCheck release tag, e.g. `v0.11.0`. */ + readonly shellcheckVersion: string; + readonly ghcWasmMetaCommit: string; + readonly ghcVersion: string; + readonly cabalVersion: string; + readonly wasmOptVersion: string; + readonly cflags: string; + readonly targetFeatures: readonly string[]; + readonly sha256: string; + readonly size: number; +} + +let cached: BuildInfo | undefined; + +/** + * Reads the build info shipped next to shellcheck.wasm. Kept free of runner imports so + * Hosts can read it without pulling the runner into their bundle. + */ +export function readBuildInfo(): BuildInfo { + cached ??= JSON.parse( + readFileSync(new URL("./build-info.json", import.meta.url), "utf8"), + ) as BuildInfo; + return cached; +} diff --git a/src/node.ts b/src/node.ts index d615d64..df25877 100644 --- a/src/node.ts +++ b/src/node.ts @@ -6,6 +6,8 @@ export { run, SHELLCHECK_VERSION, wasmUrl } from "./index.js"; export type { RunOptions, RunResult } from "./index.js"; export { createReadOnlyPreopen } from "./host-preopen.js"; export type { ReadOnlyPreopen } from "./host-preopen.js"; +export { readBuildInfo } from "./build-info.js"; +export type { BuildInfo } from "./build-info.js"; /** Filesystem path of the bundled shellcheck.wasm. */ export const wasmPath: string = fileURLToPath(wasmUrl); diff --git a/test/package.test.ts b/test/package.test.ts index ee26c61..878301c 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -61,6 +61,8 @@ describe("package", () => { "dist/index.d.ts", "dist/node.js", "dist/node.d.ts", + "dist/build-info.js", + "dist/build-info.d.ts", "package.json", "README.md", ]), @@ -78,16 +80,9 @@ describe("package", () => { }, ); - it.skipIf(skipHint(!hasBuildInfo, buildInfoHint))( - "ships build-info.json for the same ShellCheck", - () => { - expect(npmPackDryRun().files.map((file) => file.path)).toContain("dist/build-info.json"); - const buildInfo = JSON.parse(readFileSync(buildInfoPath, "utf8")) as { - shellcheckVersion: string; - }; - expect(buildInfo.shellcheckVersion).toBe(SHELLCHECK_VERSION); - }, - ); + it.skipIf(skipHint(!hasBuildInfo, buildInfoHint))("ships build-info.json", () => { + expect(npmPackDryRun().files.map((file) => file.path)).toContain("dist/build-info.json"); + }); }); describe.skipIf(skipHint(!hasDist, distHint))("built output", () => { @@ -107,24 +102,56 @@ describe("package", () => { expect(typeof node.loadModule).toBe("function"); expect(typeof node.createReadOnlyPreopen).toBe("function"); expect(node.wasmPath).toBe(path.join(repoRoot, "dist", "shellcheck.wasm")); + expect(typeof node.readBuildInfo).toBe("function"); }); + it("keeps dist/build-info.js free of the runner", () => { + const source = readFileSync(path.join(repoRoot, "dist", "build-info.js"), "utf8"); + const imports = [...source.matchAll(/^import .* from "([^"]+)";$/gm)].map((m) => m[1]); + expect(imports).toEqual(["node:fs"]); + }); + + it.skipIf(skipHint(!hasBuildInfo, buildInfoHint))( + "reads build info for the same ShellCheck through the published entry point", + () => { + const output = nodeEval( + `import { readBuildInfo } from ${JSON.stringify(`${packageName}/build-info`)}; + const info = readBuildInfo(); + console.log(JSON.stringify([info === readBuildInfo(), info]));`, + ); + const [cached, info] = JSON.parse(output) as [boolean, unknown]; + expect(cached).toBe(true); + expect(info).toEqual(JSON.parse(readFileSync(buildInfoPath, "utf8"))); + expect((info as { shellcheckVersion: string }).shellcheckVersion).toBe(SHELLCHECK_VERSION); + }, + ); + it("resolves the subpath exports by package name", () => { const resolved = nodeEval( `console.log(JSON.stringify([ import.meta.resolve(${JSON.stringify(packageName)}), import.meta.resolve(${JSON.stringify(`${packageName}/node`)}), + import.meta.resolve(${JSON.stringify(`${packageName}/build-info`)}), import.meta.resolve(${JSON.stringify(`${packageName}/shellcheck.wasm`)}), import.meta.resolve(${JSON.stringify(`${packageName}/package.json`)}), ]))`, ); - const [index, node, wasm, pkg] = JSON.parse(resolved) as string[]; + const [index, node, buildInfo, wasm, pkg] = JSON.parse(resolved) as string[]; expect(index).toBe(`file://${path.join(repoRoot, "dist", "index.js")}`); expect(node).toBe(`file://${path.join(repoRoot, "dist", "node.js")}`); + expect(buildInfo).toBe(`file://${path.join(repoRoot, "dist", "build-info.js")}`); expect(wasm).toBe(`file://${path.join(repoRoot, "dist", "shellcheck.wasm")}`); expect(pkg).toBe(`file://${path.join(repoRoot, "package.json")}`); }); + it("no longer exports build-info.json", () => { + const code = nodeEval( + `try { import.meta.resolve(${JSON.stringify(`${packageName}/build-info.json`)}); console.log("resolved"); } + catch (error) { console.log(error.code); }`, + ); + expect(code).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED"); + }); + it.skipIf(skipHint(!hasWasm, wasmHint))( "runs end to end through the published entry point", () => {