diff --git a/package-lock.json b/package-lock.json index 79cb580..d0ce832 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@github/copilot-sdk": "^1.0.16", "@openai/codex-sdk": "^0.159.0", "@types/node": "^25.9.1", + "source-map-js": "file:packages/source-map-compat", "typescript": "^5.8.0", "vitest": "^4.1.11", "zx": "^8.8.5" @@ -1125,6 +1126,27 @@ } } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha1-Y0Khn0Q0dRjJPkOxrGnes8Rlah8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha1-eg7mAfYPmaIMfHxf8MgDiMEYm9Y=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -1132,6 +1154,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha1-2xXWeByTHzolGj2sOVAcmKYIL9A=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@koromix/koffi-android-arm64": { "version": "3.2.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.2.1.tgz", @@ -3263,14 +3296,8 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } + "resolved": "packages/source-map-compat", + "link": true }, "node_modules/stackback": { "version": "0.0.2", @@ -3638,6 +3665,16 @@ "engines": { "node": ">= 12.17.0" } + }, + "packages/source-map-compat": { + "name": "@rig/source-map-compat", + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "0.3.13", + "@jridgewell/trace-mapping": "0.3.31" + } } } } diff --git a/package.json b/package.json index 7638c07..ffbf64e 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,12 @@ "@github/copilot-sdk": "^1.0.16", "@openai/codex-sdk": "^0.159.0", "@types/node": "^25.9.1", + "source-map-js": "file:packages/source-map-compat", "typescript": "^5.8.0", "vitest": "^4.1.11", "zx": "^8.8.5" + }, + "overrides": { + "source-map-js": "$source-map-js" } } diff --git a/packages/source-map-compat/LICENSE b/packages/source-map-compat/LICENSE new file mode 100644 index 0000000..28a50fa --- /dev/null +++ b/packages/source-map-compat/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright GitHub, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/source-map-compat/README.md b/packages/source-map-compat/README.md new file mode 100644 index 0000000..6f70f19 --- /dev/null +++ b/packages/source-map-compat/README.md @@ -0,0 +1,17 @@ +# Build-time source maps + +The root npm override resolves every `source-map-js` dependency to this local +package instead of fetching the `source-map-js@1.2.2` registry tarball. +Run `npm ci` normally; no install script or post-install patch is needed. + +Adapted from [githubnext/gh-aw-cao's source-map adapter](https://github.com/githubnext/gh-aw-cao/tree/main/dashboard/site/packages/source-map-compat) +under the included MIT license. The synchronous generator and consumer APIs +used by PostCSS delegate encoding, decoding, indexed maps, and position lookup +to `@jridgewell/gen-mapping` and `@jridgewell/trace-mapping`. Chained maps, +original-source diagnostics, and embedded source content remain enabled. + +This is scoped to build-tool consumers, not the complete `source-map-js` API: +`SourceNode`, reverse lookups, and original-order iteration are not implemented. + +Run `npx vitest run src/source-map-compat.test.ts` to check dependency resolution +and source-map behavior, including PostCSS integration. diff --git a/packages/source-map-compat/index.cjs b/packages/source-map-compat/index.cjs new file mode 100644 index 0000000..f2c6cbe --- /dev/null +++ b/packages/source-map-compat/index.cjs @@ -0,0 +1,173 @@ +const { posix: path } = require("node:path"); +const { URL } = require("node:url"); +const { + GenMapping, addMapping, fromMap, setSourceContent, toDecodedMap, toEncodedMap, +} = require("@jridgewell/gen-mapping"); +const { + AnyMap, eachMapping, originalPositionFor, sourceContentFor, +} = require("@jridgewell/trace-mapping"); + +/** @typedef {{ line: number, column: number }} Position */ +/** @typedef {{ generated: Position, original?: Position | null, source?: string | null, name?: string | null }} Mapping */ + +/** @param {string | null | undefined} root @param {string} source */ +function joinSource(root, source) { + if (!root || /^\w+:/.test(source) || source.startsWith("//")) return source; + if (/^\w+:\/\//.test(root)) return new URL(source, root.replace(/\/?$/, "/")).href; + if (path.isAbsolute(source)) return source; + return path.join(root, source); +} + +/** @param {string | null | undefined} root @param {string} source */ +function relativeSource(root, source) { + if (!root) return source; + if (/^\w+:\/\//.test(root)) { + const base = new URL(root.replace(/\/?$/, "/")); + const target = new URL(source, base); + if (base.protocol !== target.protocol || base.host !== target.host) return source; + return path.relative(base.pathname, target.pathname) + target.search + target.hash; + } + if (/^\w+:/.test(source) || path.isAbsolute(root) !== path.isAbsolute(source)) return source; + if (!path.isAbsolute(root) && path.normalize(root).split("/")[0] !== path.normalize(source).split("/")[0]) { + return source; + } + return path.relative(root, source); +} + +class SourceMapConsumer { + /** @param {Parameters[0]} map */ + constructor(map) { + this._map = AnyMap(map); + } + + /** @param {SourceMapGenerator} generator */ + static fromSourceMap(generator) { + return new SourceMapConsumer(generator.toJSON()); + } + + get file() { return this._map.file; } + set file(value) { this._map.file = value; } + get sourceRoot() { return this._map.sourceRoot; } + get sources() { return this._map.resolvedSources; } + get sourcesContent() { return this._map.sourcesContent; } + /** @param {(string | null)[] | null | undefined} value */ + set sourcesContent(value) { this._map.sourcesContent = value ?? undefined; } + + /** @param {import("@jridgewell/trace-mapping").Needle} position */ + originalPositionFor(position) { + return originalPositionFor(this._map, position); + } + + /** @param {(mapping: import("@jridgewell/trace-mapping").EachMapping) => void} callback @param {unknown} [context] */ + eachMapping(callback, context) { + eachMapping(this._map, context === undefined ? callback : callback.bind(context)); + } + + /** @param {string} source @param {boolean} [nullOnMissing] */ + sourceContentFor(source, nullOnMissing = false) { + if (!this._map.sources.includes(source) && !this.sources.includes(source)) { + if (nullOnMissing) return null; + throw new Error(`Source not found in source map: ${source}`); + } + return sourceContentFor(this._map, source); + } + + hasContentsOfAllSources() { + const content = this.sourcesContent; + return content != null && this.sources.every((_, index) => content[index] != null); + } +} + +class SourceMapGenerator { + /** @param {import("@jridgewell/gen-mapping").Options & { ignoreInvalidMapping?: boolean }} [options] */ + constructor(options = {}) { + this._map = new GenMapping(options); + this._ignoreInvalidMapping = options.ignoreInvalidMapping === true; + } + + get _file() { return this._map.file; } + set _file(value) { this._map.file = value; } + + /** @param {SourceMapConsumer} consumer @param {{ ignoreInvalidMapping?: boolean }} [options] */ + static fromSourceMap(consumer, options = {}) { + const generator = new SourceMapGenerator(options); + generator._map = fromMap(consumer._map); + return generator; + } + + /** @param {Mapping} mapping */ + addMapping(mapping) { + /** @param {Position | null | undefined} position */ + const validPosition = (position) => position != null && + Number.isInteger(position.line) && position.line > 0 && + Number.isInteger(position.column) && position.column >= 0; + const valid = validPosition(mapping.generated) && + (mapping.original == null + ? mapping.source == null && mapping.name == null + : validPosition(mapping.original) && mapping.source != null); + if (!valid) { + if (this._ignoreInvalidMapping) return; + throw new Error("Invalid source map mapping"); + } + if (mapping.original != null && mapping.source != null) { + const entry = { generated: mapping.generated, original: mapping.original, source: mapping.source }; + if (mapping.name == null) addMapping(this._map, entry); + else addMapping(this._map, { ...entry, name: mapping.name }); + } else { + addMapping(this._map, { generated: mapping.generated }); + } + } + + /** @param {string} source @param {string | null} content */ + setSourceContent(source, content) { + setSourceContent(this._map, relativeSource(this._map.sourceRoot, source), content); + } + + /** @param {SourceMapConsumer} consumer @param {string | null} [sourceFile] @param {string} [sourceMapPath] */ + applySourceMap(consumer, sourceFile = consumer.file, sourceMapPath) { + if (sourceFile == null) throw new Error("A source file is required to apply a source map"); + const current = toDecodedMap(this._map); + const previous = new SourceMapConsumer({ ...current, sourceRoot: undefined }); + const target = relativeSource(current.sourceRoot, sourceFile); + const next = new SourceMapGenerator({ + file: current.file, + sourceRoot: current.sourceRoot, + }); + previous.eachMapping((mapping) => { + let source = mapping.source; + let line = mapping.originalLine; + let column = mapping.originalColumn; + let name = mapping.name; + let content = source == null ? null : previous.sourceContentFor(source); + if (source === target && line != null && column != null) { + const original = consumer.originalPositionFor({ line, column }); + if (original.source != null) { + source = relativeSource(current.sourceRoot, joinSource(sourceMapPath, original.source)); + line = original.line; + column = original.column; + name = original.name ?? name; + content = consumer.sourceContentFor(original.source); + } + } + const generated = { line: mapping.generatedLine, column: mapping.generatedColumn }; + if (source != null && line != null && column != null) { + next.addMapping({ generated, source, original: { line, column }, name }); + } else { + next.addMapping({ generated }); + } + if (source != null && content != null) setSourceContent(next._map, source, content); + }); + this._map = next._map; + } + + toJSON() { + return toEncodedMap(this._map); + } + + toString() { + return JSON.stringify(this.toJSON()); + } +} + +exports.SourceMapConsumer = SourceMapConsumer; +exports.SourceMapGenerator = SourceMapGenerator; diff --git a/packages/source-map-compat/package.json b/packages/source-map-compat/package.json new file mode 100644 index 0000000..fe42744 --- /dev/null +++ b/packages/source-map-compat/package.json @@ -0,0 +1,18 @@ +{ + "name": "@rig/source-map-compat", + "version": "1.0.0", + "private": true, + "description": "Synchronous source-map APIs used by PostCSS, without source-map-js.", + "license": "MIT", + "main": "index.cjs", + "exports": { + ".": "./index.cjs", + "./lib/source-map-generator.js": "./index.cjs", + "./lib/source-map-consumer.js": "./index.cjs", + "./package.json": "./package.json" + }, + "dependencies": { + "@jridgewell/gen-mapping": "0.3.13", + "@jridgewell/trace-mapping": "0.3.31" + } +} diff --git a/src/source-map-compat.test.ts b/src/source-map-compat.test.ts new file mode 100644 index 0000000..d58fe45 --- /dev/null +++ b/src/source-map-compat.test.ts @@ -0,0 +1,172 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { expect, test } from "vitest"; +import type { EachMapping } from "@jridgewell/trace-mapping"; + +const require = createRequire(import.meta.url); +const { SourceMapConsumer, SourceMapGenerator } = require("source-map-js"); +const postcss = require("postcss"); +const sourceContent = "$color: red;\n\na { color: $color; }"; + +function originalMap(options = {}) { + const map = new SourceMapGenerator({ file: "input.css", ...options }); + map.addMapping({ + generated: { line: 1, column: 0 }, + original: { line: 3, column: 2 }, + source: "original.scss", + name: "color", + }); + map.setSourceContent("original.scss", sourceContent); + return map; +} + +test("every source-map-js dependency resolves to the local package", () => { + const base = new URL("../", import.meta.url); + const manifest = JSON.parse(readFileSync(new URL("package.json", base), "utf8")); + const lock = JSON.parse(readFileSync(new URL("package-lock.json", base), "utf8")); + expect(manifest.overrides["source-map-js"]).toBe("$source-map-js"); + const entries = Object.keys(lock.packages).filter((key) => key.endsWith("/source-map-js")); + expect(entries).toEqual(["node_modules/source-map-js"]); + const entry = lock.packages[entries[0]!]; + expect(entry.link).toBe(true); + expect(entry.resolved).toBe("packages/source-map-compat"); + expect(lock.packages[entry.resolved].name).toBe("@rig/source-map-compat"); + expect(require.resolve("source-map-js")).toBe( + new URL("packages/source-map-compat/index.cjs", base).pathname, + ); + const local = JSON.parse(readFileSync(new URL(`${entry.resolved}/package.json`, base), "utf8")); + expect(local.scripts).toBeUndefined(); + expect(JSON.stringify(lock)).not.toMatch(/source-map-js-1\.2\.2\.tgz/); +}); + +test("CommonJS and ESM deep imports expose the same generator", async () => { + const deep = require.resolve("source-map-js/lib/source-map-generator.js"); + expect(require(deep).SourceMapGenerator).toBe(SourceMapGenerator); + expect((await import(deep)).SourceMapGenerator).toBe(SourceMapGenerator); +}); + +test("maps round-trip positions, names, unmapped segments, and content", () => { + const map = originalMap(); + map.addMapping({ generated: { line: 1, column: 5 } }); + const consumer = new SourceMapConsumer(map.toString()); + expect(consumer.originalPositionFor({ line: 1, column: 4 })).toEqual({ + source: "original.scss", line: 3, column: 2, name: "color", + }); + expect(consumer.originalPositionFor({ line: 1, column: 5 })).toEqual({ + source: null, line: null, column: null, name: null, + }); + expect(consumer.sourceContentFor("original.scss")).toBe(sourceContent); + expect(consumer.hasContentsOfAllSources()).toBe(true); + const mappings: EachMapping[] = []; + consumer.eachMapping(function (this: EachMapping[], mapping: EachMapping) { + this.push(mapping); + }, mappings); + expect(mappings).toHaveLength(2); + expect(mappings[0]?.originalLine).toBe(3); +}); + +test("missing content differs from unknown sources, and malformed maps throw", () => { + const consumer = new SourceMapConsumer({ version: 3, sources: ["input.css"], names: [], mappings: "AAAA" }); + expect(consumer.sourceContentFor("input.css")).toBeNull(); + expect(consumer.hasContentsOfAllSources()).toBe(false); + expect(consumer.sourceContentFor("missing.css", true)).toBeNull(); + expect(() => consumer.sourceContentFor("missing.css")).toThrow(/Source not found/); + expect(() => new SourceMapConsumer("{")).toThrow(SyntaxError); +}); + +test("indexed maps honor section offsets", () => { + const consumer = new SourceMapConsumer({ + version: 3, + sections: [{ offset: { line: 2, column: 5 }, map: originalMap().toJSON() }], + }); + expect(consumer.originalPositionFor({ line: 3, column: 5 }).line).toBe(3); + expect(consumer.originalPositionFor({ line: 3, column: 4 }).source).toBeNull(); +}); + +test("fromSourceMap preserves output filenames and disabled source content", () => { + const consumer = SourceMapConsumer.fromSourceMap(originalMap()); + consumer.file = "output.css"; + consumer.sourcesContent = null; + const copy = SourceMapGenerator.fromSourceMap(consumer); + expect(copy.toJSON().file).toBe("output.css"); + expect(new SourceMapConsumer(copy.toJSON()).sourceContentFor("original.scss")).toBeNull(); + copy._file = "minified.css"; + expect(copy.toJSON().file).toBe("minified.css"); +}); + +test("invalid mappings throw unless explicitly ignored", () => { + const invalid = { generated: { line: 1, column: -1 } }; + expect(() => new SourceMapGenerator().addMapping(invalid)).toThrow(/Invalid source map mapping/); + const map = new SourceMapGenerator({ ignoreInvalidMapping: true }); + map.addMapping(invalid); + expect(map.toJSON().mappings).toBe(""); + expect(() => map.applySourceMap(new SourceMapConsumer({ + version: 3, sources: [], names: [], mappings: "", + }))).toThrow(/source file is required/); +}); + +test("chained maps preserve unrelated and unmapped segments and rebase sources", () => { + const map = new SourceMapGenerator({ file: "output.css" }); + map.addMapping({ generated: { line: 1, column: 0 }, original: { line: 1, column: 0 }, source: "input.css" }); + map.addMapping({ generated: { line: 1, column: 5 } }); + map.addMapping({ generated: { line: 2, column: 0 }, original: { line: 4, column: 0 }, source: "other.css" }); + map.setSourceContent("other.css", "other CSS"); + map.applySourceMap(new SourceMapConsumer(originalMap().toJSON()), undefined, "../src"); + const consumer = new SourceMapConsumer(map.toJSON()); + expect(consumer.originalPositionFor({ line: 1, column: 0 })).toEqual({ + source: "../src/original.scss", line: 3, column: 2, name: "color", + }); + expect(consumer.originalPositionFor({ line: 1, column: 5 }).source).toBeNull(); + expect(consumer.originalPositionFor({ line: 2, column: 0 }).source).toBe("other.css"); + expect(consumer.sourceContentFor("other.css")).toBe("other CSS"); + expect(consumer.sources).not.toContain("input.css"); +}); + +test.each(["src", "https://example.com/src/"])("source root %s survives composition", (root) => { + const map = new SourceMapGenerator({ sourceRoot: root }); + map.addMapping({ generated: { line: 1, column: 0 }, original: { line: 1, column: 0 }, source: "input.css" }); + map.applySourceMap(new SourceMapConsumer(originalMap().toJSON())); + const consumer = new SourceMapConsumer(map.toJSON()); + const source = `${root.replace(/\/$/, "")}/original.scss`; + expect(consumer.originalPositionFor({ line: 1, column: 0 }).source).toBe(source); + expect(consumer.sourceContentFor(source)).toBe(sourceContent); +}); + +test("PostCSS generates and chains maps, including inline and content-free output", async () => { + const plugin = { + postcssPlugin: "change-color", + Declaration(declaration: { value: string }) { declaration.value = "blue"; }, + }; + const first = await postcss([plugin]).process("a { color: red; }", { + from: "/styles/input.css", to: "/styles/intermediate.css", + map: { inline: false, annotation: false, prev: originalMap() }, + }); + const second = await postcss([plugin]).process(first.css, { + from: "/styles/intermediate.css", to: "/styles/output.css", + map: { inline: false, annotation: false, prev: first.map }, + }); + const consumer = new SourceMapConsumer(second.map.toJSON()); + expect(consumer.originalPositionFor({ line: 1, column: 4 }).source).toBe("original.scss"); + expect(consumer.originalPositionFor({ line: 1, column: 4 }).line).toBe(3); + expect(second.css).toMatch(/blue/); + const noContent = await postcss([plugin]).process(first.css, { + from: "/styles/intermediate.css", to: "/styles/no-content.css", + map: { inline: false, prev: first.map, sourcesContent: false }, + }); + expect(noContent.map.toJSON().sourcesContent.every((content: string | null) => content == null)).toBe(true); + const inline = await postcss([plugin]).process("a { color: red; }", { + from: "/styles/input.css", map: { inline: true }, + }); + expect(inline.css).toMatch(/sourceMappingURL=data:application\/json/); +}); + +test("PostCSS diagnostics point to the original source", () => { + const input = new postcss.Input("a { color: red; }", { + from: "/styles/input.css", map: { prev: originalMap() }, + }); + const error = input.error("bad color", 1, 5); + expect(error.file).toBe("/styles/original.scss"); + expect(error.line).toBe(3); + expect(error.column).toBe(3); + expect(error.source).toBe(sourceContent); +});