Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"package-lock.json",
"node_modules",
"coverage",
"*.log"
"*.log",
"src/options.check.js"
]
}
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
/test/fixtures
CHANGELOG.md
docs/LOADER.md
/src/options.check.js
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defineConfig } from "eslint/config";
import configs from "eslint-config-webpack/configs.js";

export default defineConfig([
// Generated by `schema-utils precompile`
{ ignores: ["src/options.check.js"] },
{
extends: [configs["recommended-dirty"]],
},
Expand Down
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
testTimeout: 10000,
coveragePathIgnorePatterns: [
"/node_modules/",
"<rootDir>/src/options\\.check\\.js$",
],
transform: {
"\\.[mc]?tsx?$": ["ts-jest", { rootDir: ".", esModuleInterop: true }],
"\\.[mc]?jsx?$": "babel-jest",
Expand Down
28 changes: 16 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@
"lint:prettier": "prettier --cache --list-different .",
"lint:code": "eslint --cache .",
"lint:spelling": "cspell --cache --no-must-find-files --quiet \"**/*.*\"",
"lint:schema": "schema-utils precompile --check src/options.json",
"lint": "npm-run-all -l -p \"lint:**\"",
"fix:code": "npm run lint:code -- --fix",
"fmt": "npm run lint:prettier -- --write",
"fix": "npm run fix:code && npm run fmt",
"fix:schema": "schema-utils precompile src/options.json",
"fix": "npm run fix:schema && npm run fix:code && npm run fmt",
"test:only": "cross-env NODE_ENV=test jest",
"test:watch": "npm run test:only -- --watch",
"test:coverage": "npm run test:only -- --collectCoverageFrom=\"src/**/*.js\" --coverage",
Expand Down Expand Up @@ -81,6 +83,7 @@
"prettier": "^3.3.2",
"sass": "^1.77.6",
"sass-loader": "^14.2.1",
"schema-utils": "^4.6.0",
"standard-version": "^9.3.2",
"strip-ansi": "^7.1.0",
"sugarss": "^5.0.1",
Expand Down
3 changes: 2 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from "node:path";

import postcssPackage from "postcss/package.json";

import check from "./options.check.js";
import schema from "./options.json";
import {
exec,
Expand Down Expand Up @@ -31,7 +32,7 @@ let hasExplicitDependencyOnPostCSS = false;
* @return {callback} callback Result
*/
export default async function loader(content, sourceMap, meta) {
const options = this.getOptions(schema);
const options = this.getOptions(schema, check);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

python3 - <<'PY'
import io
import json
import re
import tarfile
import urllib.request

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "review-inspection"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read()

meta = json.loads(get("https://registry.npmjs.org/webpack"))
versions = [
    v for v in meta["versions"]
    if v.startswith("5.") and re.fullmatch(r"5\.\d+\.\d+", v)
]
versions.sort(key=lambda v: tuple(map(int, v.split("."))))
print(f"published webpack 5.x releases: {len(versions)}")
contradictions = []
missing = []
signatures = {}
for version in versions:
    info = meta["versions"][version]
    tarball = info["dist"]["tarball"]
    try:
        data = get(tarball)
        with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
            candidates = [
                n for n in tf.getnames()
                if n.endswith("/lib/NormalModule.js") or n.endswith("/lib/module/NormalModule.js")
            ]
            if not candidates:
                missing.append((version, "NormalModule.js not found"))
                continue
            name = candidates[0]
            text = tf.extractfile(name).read().decode("utf-8", "replace")
            pos = text.find("getOptions")
            excerpt = text[pos:pos+500] if pos >= 0 else "getOptions not found"
            signatures[version] = " ".join(excerpt.split())
            # Look for a parameter list that includes a second argument at the definition.
            definitions = re.findall(r"getOptions\s*:\s*\(([^)]*)\)", text)
            if not definitions:
                definitions = re.findall(r"getOptions\s*:\s*([A-Za-z_$][\\w$]*)", text)
            if any("," in d for d in definitions):
                contradictions.append((version, definitions, name))
    except Exception as e:
        missing.append((version, type(e).__name__ + ": " + str(e)))

print("releases with a getOptions definition containing a second parameter:", len(contradictions))
for row in contradictions:
    print("CONTRADICTS", row)
print("uninspected releases:", len(missing))
for row in missing[:20]:
    print("MISSING", row)
for version in (versions[:3] + versions[len(versions)//2:len(versions)//2+1] + versions[-3:]):
    print("SIGNATURE", version, signatures.get(version, "<unavailable>"))

print("\nREPOSITORY SOURCES")
for path, start, end in (("src/index.js", 1, 55), ("package.json", 88, 101)):
    print(f"--- {path}:{start}-{end}")
    lines = open(path, encoding="utf-8").read().splitlines()
    for n in range(start, min(end, len(lines)) + 1):
        print(f"{n}: {lines[n-1]}")
PY

Repository: webpack/postcss-loader

Length of output: 496


🏁 Script executed:

python3 - <<'PY'
import concurrent.futures
import io
import json
import re
import tarfile
import urllib.request

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "review-inspection"})
    with urllib.request.urlopen(req, timeout=15) as r:
        return r.read()

meta = json.loads(get("https://registry.npmjs.org/webpack"))
versions = sorted(
    [v for v in meta["versions"] if re.fullmatch(r"5\.\d+\.\d+", v)],
    key=lambda v: tuple(map(int, v.split("."))),
)

def inspect(version):
    try:
        data = get(meta["versions"][version]["dist"]["tarball"])
        with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
            names = [n for n in tf.getnames() if n.endswith("/lib/NormalModule.js") or n.endswith("/lib/module/NormalModule.js")]
            if not names:
                return version, "missing source", ""
            text = tf.extractfile(names[0]).read().decode("utf-8", "replace")
            matches = re.findall(r"getOptions\s*:\s*\(([^)]*)\)", text)
            if not matches:
                matches = re.findall(r"getOptions\s*:\s*([A-Za-z_$][A-Za-z0-9_$]*)", text)
            return version, "second parameter" if any("," in m for m in matches) else "schema only", " | ".join(matches)
    except Exception as exc:
        return version, "error", type(exc).__name__ + ": " + str(exc)

results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=24) as pool:
    for result in pool.map(inspect, versions):
        results.append(result)

print("release_count", len(versions))
for version, status, detail in results:
    if status != "schema only":
        print("EXCEPTION", version, status, detail)
print("schema_only_count", sum(status == "schema only" for _, status, _ in results))
print("missing_or_error_count", sum(status != "schema only" for _, status, _ in results))
for version in versions[:2] + versions[-2:]:
    row = next(r for r in results if r[0] == version)
    print("sample", row)

print("REPOSITORY")
for path, start, end in (("src/index.js", 30, 38), ("package.json", 92, 99)):
    print(f"--- {path}:{start}-{end}")
    lines = open(path, encoding="utf-8").read().splitlines()
    for n in range(start, end + 1):
        print(f"{n}: {lines[n-1]}")
PY

Repository: webpack/postcss-loader

Length of output: 1090


🏁 Script executed:

python3 - <<'PY'
import concurrent.futures
import io
import json
import re
import tarfile
import urllib.request

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "review-inspection"})
    with urllib.request.urlopen(req, timeout=15) as r:
        return r.read()

meta = json.loads(get("https://registry.npmjs.org/webpack"))
versions = sorted(
    [v for v in meta["versions"] if re.fullmatch(r"5\.\d+\.\d+", v)],
    key=lambda v: tuple(map(int, v.split("."))),
)

def body_after_get_options(text):
    pos = text.find("getOptions")
    if pos < 0:
        return None
    brace = text.find("{", pos)
    if brace < 0:
        return None
    depth = 0
    quote = None
    escaped = False
    for i in range(brace, len(text)):
        c = text[i]
        if quote:
            if escaped:
                escaped = False
            elif c == "\\":
                escaped = True
            elif c == quote:
                quote = None
        elif c in "'\"`":
            quote = c
        elif c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return text[brace:i+1]
    return None

def inspect(version):
    try:
        data = get(meta["versions"][version]["dist"]["tarball"])
        with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
            names = [n for n in tf.getnames() if n.endswith("/lib/NormalModule.js") or n.endswith("/lib/module/NormalModule.js")]
            text = tf.extractfile(names[0]).read().decode("utf-8", "replace")
            body = body_after_get_options(text)
            if body is None:
                return version, "no-body", ""
            suspicious = []
            if re.search(r"\barguments\s*\[\s*1\s*\]", body):
                suspicious.append("arguments[1]")
            if re.search(r"\b(check|checker|compiled)\b", body, re.I):
                suspicious.append("checker-term")
            if not re.search(r"\bvalidate\b|\bschema-utils\b", body):
                suspicious.append("no-validation-token")
            return version, "ok" if not suspicious else " ".join(suspicious), " ".join(body[:220].split())
    except Exception as exc:
        return version, "error", type(exc).__name__ + ": " + str(exc)

with concurrent.futures.ThreadPoolExecutor(max_workers=24) as pool:
    results = list(pool.map(inspect, versions))

print("release_count", len(results))
for row in results:
    if row[1] != "ok":
        print("EXCEPTION", row)
print("clean_getOptions_bodies", sum(status == "ok" for _, status, _ in results))
print("exception_count", sum(status != "ok" for _, status, _ in results))
PY

Repository: webpack/postcss-loader

Length of output: 33118


The declared webpack range does not provide the claimed optimization.

All 221 published stable webpack 5.x releases allowed by ^5.0.0 define getOptions with only the schema parameter. No release consumes the second argument as a precompiled checker. Webpack therefore continues its normal schema validation, so this change does not improve performance for webpack consumers.

Remove or defer the checker-specific changes until webpack releases support for this argument.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended: it lands ahead of webpack/webpack#22745. Released webpack ignores the second argument and validates as before, so nothing changes for current users. Once #22745 ships, the fast path applies without another postcss-loader release. A maintainer can also hold this until then.


Generated by Claude Code

const callback = this.async();
const configOption =
typeof options.postcssOptions === "undefined" ||
Expand Down
7 changes: 7 additions & 0 deletions src/options.check.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions test/validate-options.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import path from "node:path";

import { validate } from "schema-utils";

import check from "../src/options.check";
import schema from "../src/options.json";

import { compile, getCompiler } from "./helpers/index";

/**
* @param {object} options loader options
* @returns {boolean} whether the schema accepts them
*/
function isValid(options) {
try {
validate(schema, options);

return true;
} catch {
return false;
}
}

describe("validate options", () => {
const tests = {
execute: {
Expand Down Expand Up @@ -70,6 +89,10 @@ describe("validate options", () => {
it(`should ${
type === "success" ? "successfully validate" : "throw an error on"
} the "${key}" option with "${stringifyValue(value)}" value`, async () => {
// webpack trusts a check that passes and skips the schema, so the two
// must agree on every value
expect(check({ [key]: value })).toBe(isValid({ [key]: value }));

let compiler;

if (
Expand Down
Loading