From 8b91950acafdc7f2e985f3cff7a2ca6e081e613f Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 14:47:39 -0600 Subject: [PATCH 1/7] chore(scripts): enforce locked lint and type quality gates Add strict Python and checked JavaScript tooling, explicit source-scope checks and real rejection probes to CI. Preserve existing runtime behavior and document the limited bundle and test portability changes. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Codex GPT-6 Signed-off-by: James Maes --- .github/pyrightconfig.json | 15 + .github/scripts/check-codeql-sarif.py | 138 ++- .github/scripts/check-script-quality.py | 104 ++ .github/scripts/check-source-headers.py | 36 +- .github/scripts/test-scan-gates.py | 137 ++- .github/scripts/test-script-quality.py | 228 ++++ .github/scripts/test-source-headers.py | 38 +- .github/workflows/ci.yml | 13 + .gitignore | 5 + .python-version | 1 + CONTRIBUTING.md | 13 + docs/TODO.md | 5 + eslint.config.mjs | 84 ++ package-lock.json | 1402 ++++++++++++++++++++++- package.json | 15 +- pyproject.toml | 13 + ruff.toml | 56 + tools/bundle.mjs | 38 +- tools/create-content-fixtures.mjs | 40 +- tools/fixture-types.mjs | 156 +-- tools/verify-fixtures.mjs | 248 ++-- tools/verify-realtime.mjs | 120 +- tools/verify-schema.mjs | 148 +-- tsconfig.json | 45 + uv.lock | 78 ++ 25 files changed, 2712 insertions(+), 464 deletions(-) create mode 100644 .github/pyrightconfig.json create mode 100644 .github/scripts/check-script-quality.py create mode 100644 .github/scripts/test-script-quality.py create mode 100644 .python-version create mode 100644 eslint.config.mjs create mode 100644 pyproject.toml create mode 100644 ruff.toml create mode 100644 tsconfig.json create mode 100644 uv.lock diff --git a/.github/pyrightconfig.json b/.github/pyrightconfig.json new file mode 100644 index 0000000..6ceb2c0 --- /dev/null +++ b/.github/pyrightconfig.json @@ -0,0 +1,15 @@ +{ + "typeCheckingMode": "strict", + "pythonVersion": "3.13", + "include": [ + "scripts/*.py" + ], + "reportMissingTypeStubs": "error", + "reportUnnecessaryTypeIgnoreComment": "error", + "reportImplicitOverride": "error", + "exclude": [ + "**/__pycache__" + ], + "venvPath": "..", + "venv": ".venv" +} diff --git a/.github/scripts/check-codeql-sarif.py b/.github/scripts/check-codeql-sarif.py index 8705490..7177466 100644 --- a/.github/scripts/check-codeql-sarif.py +++ b/.github/scripts/check-codeql-sarif.py @@ -2,55 +2,107 @@ # SPDX-License-Identifier: Apache-2.0 """Reject missing/malformed CodeQL evidence before enforcing the severity gate.""" + import json import math import os from pathlib import Path +from typing import TypedDict + +RuleProperties = TypedDict("RuleProperties", {"security-severity": str | float | None}, total=False) -def check(directory, language, threshold): - if not math.isfinite(threshold) or not 0 <= threshold <= 10: - raise ValueError("Invalid security severity threshold") - canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language) - files = sorted(Path(directory).glob("*.sarif")) - if not canonical or not any(path.name == canonical + ".sarif" for path in files): - raise ValueError("Missing expected CodeQL SARIF report for " + language) - blocking = [] - for path in files: - report = json.loads(path.read_text(encoding="utf-8")) - runs = report.get("runs") - if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs: - raise ValueError("Invalid or empty SARIF runs: " + path.name) - for run in runs: - driver = run.get("tool", {}).get("driver", {}) - results = run.get("results") - if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list): - raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name) - rules = {} - for component in [driver, *run.get("tool", {}).get("extensions", [])]: - for rule in component.get("rules", []): - rules[rule["id"]] = rule - if not rules: - raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name) - for invocation in run.get("invocations", []): - if invocation.get("executionSuccessful") is False: - raise ValueError("CodeQL invocation failed: " + path.name) - for result in results: - rule_id = result.get("ruleId") or result.get("rule", {}).get("id") - if rule_id not in rules: - raise ValueError("Result references missing rule metadata: " + path.name) - if result.get("suppressions"): - continue - severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0) - if not math.isfinite(severity) or not 0 <= severity <= 10: - raise ValueError("Invalid rule severity: " + rule_id) - if severity >= threshold: - blocking.append((severity, rule_id)) - for severity, rule_id in sorted(blocking, reverse=True): - print(f"::error title=CodeQL security finding::{rule_id} ({severity})") - print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)") - return bool(blocking) +class Rule(TypedDict): + """Keep the rule identity and optional severity metadata used by the gate.""" + + id: str + properties: RuleProperties + + +class Component(TypedDict, total=False): + """Describe CodeQL driver or extension rule metadata.""" + + name: str + rules: list[Rule] + + +class Tool(TypedDict, total=False): + """Group the driver and optional extension rule catalogs.""" + + driver: Component + extensions: list[Component] + + +class Result(TypedDict, total=False): + """Describe only the result fields consumed by the severity gate.""" + + ruleId: str + rule: dict[str, str] + suppressions: list[object] + + +class Run(TypedDict, total=False): + """Retain invocation success and findings for one analyzed language.""" + + tool: Tool + results: list[Result] + invocations: list[dict[str, bool]] + + +class Report(TypedDict, total=False): + """Represent SARIF fields while retaining the explicit runtime validation.""" + + version: str + runs: list[Run] + + +MAX_SEVERITY = 10 + + +def check(directory: str | Path, language: str, threshold: float) -> bool: # noqa: PLR0912 -- Keep the ordered evidence checks together; none may be skipped. + """Validate complete CodeQL evidence and report findings at the threshold.""" + if not math.isfinite(threshold) or not 0 <= threshold <= MAX_SEVERITY: + raise ValueError("Invalid security severity threshold") + canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language) + files = sorted(Path(directory).glob("*.sarif")) + if not canonical or not any(path.name == canonical + ".sarif" for path in files): + raise ValueError("Missing expected CodeQL SARIF report for " + language) + blocking: list[tuple[float, str]] = [] + for path in files: + report: Report = json.loads(path.read_text(encoding="utf-8")) + runs = report.get("runs") + if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs: + raise ValueError("Invalid or empty SARIF runs: " + path.name) + for run in runs: + driver = run.get("tool", {}).get("driver", {}) + results = run.get("results") + if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list): + raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name) + rules: dict[str, Rule] = {} + for component in [driver, *run.get("tool", {}).get("extensions", [])]: + for rule in component.get("rules", []): + rules[rule["id"]] = rule + if not rules: + raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name) + for invocation in run.get("invocations", []): + if invocation.get("executionSuccessful") is False: + raise ValueError("CodeQL invocation failed: " + path.name) + for result in results: + rule_id = result.get("ruleId") or result.get("rule", {}).get("id") + if rule_id not in rules: + raise ValueError("Result references missing rule metadata: " + path.name) + if result.get("suppressions"): + continue + severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0) + if not math.isfinite(severity) or not 0 <= severity <= MAX_SEVERITY: + raise ValueError("Invalid rule severity: " + rule_id) + if severity >= threshold: + blocking.append((severity, rule_id)) + for severity, rule_id in sorted(blocking, reverse=True): + print(f"::error title=CodeQL security finding::{rule_id} ({severity})") + print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)") + return bool(blocking) if __name__ == "__main__": - raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0) + raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0) diff --git a/.github/scripts/check-script-quality.py b/.github/scripts/check-script-quality.py new file mode 100644 index 0000000..0c9549b --- /dev/null +++ b/.github/scripts/check-script-quality.py @@ -0,0 +1,104 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools.""" + +import json +import subprocess +import sys +from pathlib import Path, PurePosixPath +from typing import Any + +PYTHON_SUFFIXES = (".py", ".pyi", ".pyw") +SCRIPT_PATTERN = ".github/scripts/*.py" +PYRIGHT_CONFIG = ".github" +JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx") + + +def tracked_files(root: Path) -> list[str]: + """List tracked and not-yet-ignored new paths so no source file can silently bypass the gates.""" + # Fixed Git inventory operation; the root is an argument, never shell input. + output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command. + return [name for name in output.split("\u0000") if name] + + +def python_scope(tracked: list[str]) -> list[str]: + """Return the tracked Python sources, rejecting any outside the one checked script root.""" + candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES)) + outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)] + if outside: + raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside)) + if not candidates: + raise ValueError("No owned Python scripts selected") + return candidates + + +def javascript_scope(root: Path, tracked: list[str]) -> list[str]: + """Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list.""" + candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES)) + tsconfig = root / "tsconfig.json" + if not tsconfig.is_file(): + if candidates: + raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates)) + return [] + includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"] + outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)] + if outside: + raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside)) + if not candidates: + raise ValueError("No owned JavaScript sources selected") + return candidates + + +def run_pyright(root: Path, config: str) -> dict[str, Any]: + """Run strict Pyright with JSON output and return its full report.""" + # Fixed interpreter module invocation; the config is an argument, never shell input. + completed = subprocess.run([sys.executable, "-m", "pyright", "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed interpreter and module; no shell. + try: + report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :]) + int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing. + report["exitCode"] = completed.returncode + except (ValueError, KeyError) as error: + raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error + return report + + +def check_pyright_report(report: dict[str, Any], expected: int) -> None: + """Require the analyzed-file count to match the selected sources, with no errors or warnings.""" + summary = report["summary"] + analyzed = summary["filesAnalyzed"] + if analyzed == 0 or analyzed != expected: + raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}") + if summary["errorCount"] or summary["warningCount"]: + details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]] + raise ValueError("Pyright reported findings:\n" + "\n".join(details)) + if report["exitCode"] != 0: + raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary") + + +def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None: + """Run Ruff on the explicit source list so no selected file can be skipped.""" + # Fixed interpreter module invocation; paths are arguments, never shell input. + if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell. + raise ValueError("Ruff " + " ".join(arguments) + " failed") + + +def main() -> None: + """Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count.""" + root = Path(__file__).resolve().parents[2] + tracked = tracked_files(root) + sources = python_scope(tracked) + javascript = javascript_scope(root, tracked) + run_ruff(root, ["format", "--check"], sources) + run_ruff(root, ["check"], sources) + report = run_pyright(root, PYRIGHT_CONFIG) + check_pyright_report(report, len(sources)) + print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print(str(error), file=sys.stderr) + sys.exit(1) diff --git a/.github/scripts/check-source-headers.py b/.github/scripts/check-source-headers.py index 2392c7e..e98fd8f 100644 --- a/.github/scripts/check-source-headers.py +++ b/.github/scripts/check-source-headers.py @@ -19,14 +19,14 @@ def selected(name: str, policy: dict[str, Any]) -> bool: return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"]) -def leading_comments(text: str, style: str) -> str: +def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch. """Read comments only, preserving required interpreter/XML directive ordering.""" text = text.removeprefix("\ufeff") if text.startswith("", text) if style != "xml" or declaration is None: raise ValueError("Invalid XML declaration") - text = text[declaration.end():] + text = text[declaration.end() :] elif text.startswith("#!"): if style not in ("hash", "powershell", "slash"): raise ValueError("Unexpected interpreter directive") @@ -35,7 +35,7 @@ def leading_comments(text: str, style: str) -> str: if style != "powershell": raise ValueError("Unexpected PowerShell directive") text = text.partition("\n")[2] - comments = [] + comments: list[str] = [] while text.strip(): text = text.lstrip() if text.startswith(" str: elif style == "slash" and text.startswith("//"): line, _, text = text.partition("\n") comments.append(line[2:]) - elif ((style == "slash" and text.startswith("/*")) - or (style == "xml" and text.startswith(""), "powershell": ("<#", "#>")}[style] - body, separator, rest = text[len(opening):].partition(closing) + body, separator, rest = text[len(opening) :].partition(closing) if not separator: raise ValueError("Unterminated leading comment") comments.append(body) @@ -72,19 +70,22 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None: approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])] for notice in approved: holder = re.escape(notice["holder"]) - copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$" + copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$" if re.search(copyright_pattern, comments) is None: continue spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments) if spdx and any(value != notice["license"] for value in spdx): continue - apache = all(phrase in comments for phrase in ( - "Licensed under the Apache License, Version 2.0", - "you may not use this file except in compliance with the License.", - "https://www.apache.org/licenses/LICENSE-2.0", - "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND", - "limitations under the License.", - )) + apache = all( + phrase in comments + for phrase in ( + "Licensed under the Apache License, Version 2.0", + "you may not use this file except in compliance with the License.", + "https://www.apache.org/licenses/LICENSE-2.0", + "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND", + "limitations under the License.", + ) + ) if suffix == ".java" and not apache: continue if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache): @@ -94,8 +95,9 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None: def check_repository(root: Path, policy: dict[str, Any]) -> int: """Check tracked sources without scanning build outputs or executing source.""" - tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") - failures = [] + # Fixed Git inventory operation; paths are arguments, never shell input. + tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command. + failures: list[str] = [] count = 0 for name in tracked: if not name or not selected(name, policy): diff --git a/.github/scripts/test-scan-gates.py b/.github/scripts/test-scan-gates.py index c8494eb..2b173dd 100644 --- a/.github/scripts/test-scan-gates.py +++ b/.github/scripts/test-scan-gates.py @@ -2,91 +2,106 @@ # SPDX-License-Identifier: Apache-2.0 """Exercise the exact CI guards using synthetic evidence only.""" + import contextlib import copy -import io import importlib.util +import io import json -from pathlib import Path import sys import tempfile import unittest - +from pathlib import Path +from types import ModuleType +from typing import Any, override sys.dont_write_bytecode = True -def load(name): - spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module +def load(name: str) -> ModuleType: + """Load the real sibling guard for synthetic evidence tests.""" + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module codeql = load("check-codeql-sarif") class SarifTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.directory = Path(self.temporary.name) - self.report = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]} + """Reject incomplete SARIF evidence and preserve severity enforcement.""" - def save(self, report): - (self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8") + @override + def setUp(self) -> None: + """Create isolated synthetic evidence with cleanup after each test.""" + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.report: dict[str, Any] = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]} - def test_clean_report_with_zero_findings_passes(self): - self.save(self.report) - self.assertFalse(codeql.check(self.directory, "java-kotlin", 7)) + def save(self, report: object) -> None: + """Write the test body as SARIF without validating it first.""" + (self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8") - def test_missing_report_or_wrong_language_fails(self): - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", 7) - self.save(self.report) - with self.assertRaises(ValueError): - codeql.check(self.directory, "csharp", 7) + def test_clean_report_with_zero_findings_passes(self) -> None: + """Verify clean report with zero findings passes.""" + self.save(self.report) + self.assertFalse(codeql.check(self.directory, "java-kotlin", 7)) - def test_empty_or_malformed_report_fails(self): - for text in ["", "{", "null", "[]"]: - with self.subTest(text=text): - (self.directory / "java.sarif").write_text(text) - with self.assertRaises((ValueError, AttributeError)): - codeql.check(self.directory, "java-kotlin", 7) + def test_missing_report_or_wrong_language_fails(self) -> None: + """Verify missing report or wrong language fails.""" + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "csharp", 7) - def test_missing_runs_tool_rules_or_results_fails(self): - variants = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}] - for field in ["tool", "results"]: - bad = copy.deepcopy(self.report) - del bad["runs"][0][field] - variants.append(bad) - bad = copy.deepcopy(self.report) - bad["runs"][0]["tool"]["driver"]["rules"] = [] - variants.append(bad) - for bad in variants: - with self.subTest(report=bad): - self.save(bad) - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", 7) + def test_empty_or_malformed_report_fails(self) -> None: + """Verify empty or malformed report fails.""" + for text in ["", "{", "null", "[]"]: + with self.subTest(text=text): + (self.directory / "java.sarif").write_text(text) + with self.assertRaises((ValueError, AttributeError)): + codeql.check(self.directory, "java-kotlin", 7) - def test_high_finding_blocks_and_unknown_rule_fails(self): - self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}] - self.save(self.report) - with contextlib.redirect_stdout(io.StringIO()): - self.assertTrue(codeql.check(self.directory, "java-kotlin", 7)) - self.report["runs"][0]["results"] = [{"ruleId": "missing"}] - self.save(self.report) - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", 7) + def test_missing_runs_tool_rules_or_results_fails(self) -> None: + """Verify missing runs tool rules or results fails.""" + variants: list[dict[str, Any]] = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}] + for field in ["tool", "results"]: + bad = copy.deepcopy(self.report) + del bad["runs"][0][field] + variants.append(bad) + bad = copy.deepcopy(self.report) + bad["runs"][0]["tool"]["driver"]["rules"] = [] + variants.append(bad) + for bad in variants: + with self.subTest(report=bad): + self.save(bad) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) - def test_failed_invocation_and_invalid_threshold_fail(self): - self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}] - self.save(self.report) - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", 7) - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", float("nan")) + def test_high_finding_blocks_and_unknown_rule_fails(self) -> None: + """Verify high finding blocks and unknown rule fails.""" + self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}] + self.save(self.report) + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(codeql.check(self.directory, "java-kotlin", 7)) + self.report["runs"][0]["results"] = [{"ruleId": "missing"}] + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + + def test_failed_invocation_and_invalid_threshold_fail(self) -> None: + """Verify failed invocation and invalid threshold fail.""" + self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}] + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", float("nan")) if __name__ == "__main__": - unittest.main() + unittest.main() diff --git a/.github/scripts/test-script-quality.py b/.github/scripts/test-script-quality.py new file mode 100644 index 0000000..b488e71 --- /dev/null +++ b/.github/scripts/test-script-quality.py @@ -0,0 +1,228 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Prove the quality gates reject real violations, using private temporary fixtures and the locked tools.""" + +import importlib.util +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import ModuleType +from typing import Any, override + +sys.dont_write_bytecode = True + +ROOT = Path(__file__).resolve().parents[2] +GOOD_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +UNUSED_IMPORT_PYTHON = '"""Probe module."""\n\nimport os\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +TWO_SPACE_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +INCOMPATIBLE_PYTHON = '"""Probe module."""\n\nVALUE: int = "text"\n' +UNANNOTATED_PYTHON = '"""Probe module."""\n\n\ndef echo(value):\n """Return the value."""\n return value\n' +GOOD_JAVASCRIPT = "export function addNumbers(left, right)\n{\n return left + right;\n}\n" +BAD_LAYOUT_JAVASCRIPT = "export function addNumbers(left, right) {\n return left + right\n}\n" +GOOD_TYPED_JAVASCRIPT = "/** @type {number} */\nexport const value = 1;\n" +INCOMPATIBLE_JAVASCRIPT = '/** @type {number} */\nexport const value = "text";\n' + + +def load(name: str) -> ModuleType: + """Load the real sibling guard for tests.""" + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +guard = load("check-script-quality") + + +def run(command: list[str], cwd: Path, stdin: str | None = None) -> subprocess.CompletedProcess[str]: + """Run one real tool invocation without a shell and capture its diagnostics.""" + return subprocess.run(command, cwd=cwd, input=stdin, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed tool paths and private temporary arguments; no shell. + + +def ruff(arguments: list[str], source: Path) -> subprocess.CompletedProcess[str]: + """Run the locked Ruff with the repository configuration on one private fixture.""" + return run([sys.executable, "-m", "ruff", *arguments, "--no-cache", "--config", str(ROOT / "ruff.toml"), str(source)], source.parent) + + +class ScopeTests(unittest.TestCase): + """Reject tracked sources that fall outside the explicit tool roots.""" + + def test_python_outside_script_root_is_rejected(self) -> None: + """Reject a tracked Python file outside the script root instead of letting it disappear.""" + for stray in ("tools/helper.py", ".github/scripts/nested/helper.py", "helper.pyi"): + with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the checked script root"): + guard.python_scope([".github/scripts/check-a.py", stray]) + + def test_python_scope_returns_sorted_script_root_and_rejects_empty(self) -> None: + """Select only the direct script root, sorted, and reject selecting nothing.""" + self.assertEqual([".github/scripts/a.py", ".github/scripts/b.py"], guard.python_scope([".github/scripts/b.py", "README.md", ".github/scripts/a.py"])) + with self.assertRaisesRegex(ValueError, "No owned Python"): + guard.python_scope(["README.md"]) + + def test_javascript_requires_tsconfig_include_scope(self) -> None: + """Reject tracked JavaScript outside tsconfig include patterns, including .js/.cjs/.ts.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + (root / "tsconfig.json").write_text(json.dumps({"include": ["tools/**/*.mjs", "eslint.config.mjs"]}), encoding="utf-8") + self.assertEqual(["eslint.config.mjs", "tools/a/b.mjs", "tools/c.mjs"], guard.javascript_scope(root, ["tools/c.mjs", "tools/a/b.mjs", "eslint.config.mjs", "README.md"])) + for stray in ("src/z.js", "tools/w.cjs", "tools/v.ts", "tools/u.js"): + with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the tsconfig include list"): + guard.javascript_scope(root, ["tools/c.mjs", stray]) + with self.assertRaisesRegex(ValueError, "No owned JavaScript"): + guard.javascript_scope(root, ["README.md"]) + + def test_javascript_without_tsconfig_is_rejected(self) -> None: + """Reject JavaScript in a repository that has no tsconfig.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + self.assertEqual([], guard.javascript_scope(root, ["README.md"])) + with self.assertRaisesRegex(ValueError, "without a tsconfig.json"): + guard.javascript_scope(root, ["tools/a.mjs"]) + + +class PyrightCountTests(unittest.TestCase): + """Reject Pyright reports that analyzed too few files or found anything.""" + + @staticmethod + def report(analyzed: int, errors: int = 0, warnings: int = 0) -> dict[str, Any]: + """Build the exact Pyright JSON shape consumed by the guard.""" + return {"summary": {"filesAnalyzed": analyzed, "errorCount": errors, "warningCount": warnings}, "generalDiagnostics": [{"file": "x.py", "message": "finding"}] * (errors + warnings), "exitCode": 1 if errors or warnings else 0} + + def test_nonzero_exit_fails_even_with_clean_summary(self) -> None: + """Fail a nonzero Pyright exit even when every count is clean.""" + clean = self.report(4) + clean["exitCode"] = 1 + with self.assertRaisesRegex(ValueError, "exited 1"): + guard.check_pyright_report(clean, 4) + + def test_count_must_match_selected_sources(self) -> None: + """Fail zero or mismatched analyzed counts even with no diagnostics.""" + guard.check_pyright_report(self.report(4), 4) + for analyzed in (0, 3, 5): + with self.subTest(analyzed=analyzed), self.assertRaisesRegex(ValueError, "analyzed"): + guard.check_pyright_report(self.report(analyzed), 4) + + def test_errors_and_warnings_fail(self) -> None: + """Fail on errors and on warnings at the exact count.""" + for report in (self.report(4, errors=1), self.report(4, warnings=1)): + with self.subTest(report=report), self.assertRaisesRegex(ValueError, "reported findings"): + guard.check_pyright_report(report, 4) + + +class RuffProbeTests(unittest.TestCase): + """Run the real locked Ruff on bounded private fixtures.""" + + def test_compliant_fixture_passes_and_violations_fail(self) -> None: + """Pass a compliant control and reject unused imports and wrong indentation.""" + with tempfile.TemporaryDirectory() as name: + probe = Path(name) / "probe.py" + probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + self.assertEqual(0, ruff(["check"], probe).returncode) + self.assertEqual(0, ruff(["format", "--check"], probe).returncode) + probe.write_text(UNUSED_IMPORT_PYTHON, encoding="utf-8", newline="\n") + unused = ruff(["check", "--output-format", "concise"], probe) + self.assertNotEqual(0, unused.returncode) + self.assertIn("F401", unused.stdout) + probe.write_text(TWO_SPACE_PYTHON, encoding="utf-8", newline="\n") + indented = ruff(["format", "--check"], probe) + self.assertNotEqual(0, indented.returncode) + self.assertIn("would be reformatted", (indented.stdout + indented.stderr).lower()) + + +class PyrightProbeTests(unittest.TestCase): + """Run the real locked Pyright with the repository strict settings on private projects.""" + + @staticmethod + def project(root: Path, include: list[str] | None = None) -> None: + """Write the repository Pyright config, without its venv location, into a private project.""" + config: dict[str, Any] = json.loads((ROOT / ".github/pyrightconfig.json").read_text(encoding="utf-8")) + del config["venvPath"], config["venv"] + if include is not None: + config["include"] = include + (root / "pyrightconfig.json").write_text(json.dumps(config), encoding="utf-8") + + def test_compliant_control_passes_and_strict_violations_fail(self) -> None: + """Analyze the control once with no findings and reject incompatible and unannotated code.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + self.project(root) + scripts = root / "scripts" + scripts.mkdir() + probe = scripts / "probe.py" + probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + clean = guard.run_pyright(root, ".") + self.assertEqual(0, clean["exitCode"]) + guard.check_pyright_report(clean, 1) + clean["exitCode"] = 1 + with self.assertRaisesRegex(ValueError, "exited 1"): + guard.check_pyright_report(clean, 1) + probe.write_text(INCOMPATIBLE_PYTHON, encoding="utf-8", newline="\n") + report = guard.run_pyright(root, ".") + self.assertIn("reportAssignmentType", {item.get("rule") for item in report["generalDiagnostics"]}) + with self.assertRaisesRegex(ValueError, "reported findings"): + guard.check_pyright_report(report, 1) + probe.write_text(UNANNOTATED_PYTHON, encoding="utf-8", newline="\n") + strict = guard.run_pyright(root, ".") + self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]}) + + def test_zero_analyzed_files_cannot_pass(self) -> None: + """Reject an empty include and a hidden-directory root config in the wrapper.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + scripts = root / ".github" / "scripts" + scripts.mkdir(parents=True) + (scripts / "probe.py").write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + for include in (["nothing/*.py"], [".github/scripts/*.py"]): + self.project(root, include) + with self.subTest(include=include), self.assertRaises(ValueError): + guard.check_pyright_report(guard.run_pyright(root, "."), 1) + + +class JavaScriptProbeTests(unittest.TestCase): + """Run the real locked ESLint and TypeScript on bounded private input.""" + + @override + def setUp(self) -> None: + """Skip only where tsconfig is absent, because the scope guard forbids JavaScript there.""" + if not (ROOT / "tsconfig.json").is_file(): + self.skipTest("No JavaScript tooling in this repository") + node = shutil.which("node") + self.assertIsNotNone(node) + self.node = str(node) + + def eslint(self, text: str) -> subprocess.CompletedProcess[str]: + """Lint text as an in-scope tool file using the repository configuration.""" + return run([self.node, str(ROOT / "node_modules/eslint/bin/eslint.js"), "--stdin", "--stdin-filename", "tools/probe.mjs", "--max-warnings", "0", "--format", "json"], ROOT, text) + + def test_eslint_rejects_wrong_layout(self) -> None: + """Pass a compliant control and reject K&R braces, two-space indent and a missing semicolon.""" + good = self.eslint(GOOD_JAVASCRIPT) + self.assertEqual(0, good.returncode, good.stdout + good.stderr) + bad = self.eslint(BAD_LAYOUT_JAVASCRIPT) + self.assertNotEqual(0, bad.returncode) + rules = {message["ruleId"] for message in json.loads(bad.stdout)[0]["messages"]} + self.assertLessEqual({"@stylistic/brace-style", "@stylistic/semi", "@stylistic/indent"}, rules) + + def test_checked_javascript_rejects_incompatible_assignment(self) -> None: + """Reject a real incompatible assignment under the repository strict checkJs settings.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + (root / "tsconfig.json").write_text(json.dumps({"extends": (ROOT / "tsconfig.json").as_posix(), "compilerOptions": {"typeRoots": [(ROOT / "node_modules/@types").as_posix()]}, "include": ["probe.mjs"]}), encoding="utf-8") + tsc = [self.node, str(ROOT / "node_modules/typescript/bin/tsc"), "-p", str(root / "tsconfig.json")] + (root / "probe.mjs").write_text(GOOD_TYPED_JAVASCRIPT, encoding="utf-8", newline="\n") + good = run(tsc, root) + self.assertEqual(0, good.returncode, good.stdout + good.stderr) + (root / "probe.mjs").write_text(INCOMPATIBLE_JAVASCRIPT, encoding="utf-8", newline="\n") + bad = run(tsc, root) + self.assertNotEqual(0, bad.returncode) + self.assertIn("TS2322", bad.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-source-headers.py b/.github/scripts/test-source-headers.py index 8a04b44..20102c8 100644 --- a/.github/scripts/test-source-headers.py +++ b/.github/scripts/test-source-headers.py @@ -11,7 +11,6 @@ import unittest from pathlib import Path from typing import Any - SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py")) assert SPEC is not None and SPEC.loader is not None HEADERS = importlib.util.module_from_spec(SPEC) @@ -32,8 +31,7 @@ limitations under the License.""" def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]: """Use the production styles with a small explicit owned fixture scope.""" - return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], - "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]} + return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]} class HeaderTests(unittest.TestCase): @@ -51,12 +49,23 @@ class HeaderTests(unittest.TestCase): with self.subTest(name=name): HEADERS.check_notice(name, text, fixture_policy()) + def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None: + """Accept a hyphen or en dash between years and reject any other separator.""" + for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)): + text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT" + with self.subTest(separator=separator): + if accepted: + HEADERS.check_notice("src/a.py", text, fixture_policy()) + else: + with self.assertRaises(ValueError): + HEADERS.check_notice("src/a.py", text, fixture_policy()) + def test_required_first_lines_and_bom(self) -> None: """Accept shebangs, PowerShell requirements and XML declarations first.""" for name, prefix, body in ( ("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")), ("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")), - ("src/a.xml", "\n", ""), + ("src/a.xml", '\n', ""), ("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"), ): with self.subTest(name=name): @@ -67,21 +76,20 @@ class HeaderTests(unittest.TestCase): for name, text in ( ("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"), ("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"), - ("src/a.xml", ""), + ("src/a.xml", "'), ): with self.subTest(name=name), self.assertRaises(ValueError): HEADERS.check_notice(name, text, fixture_policy()) def test_code_strings_and_late_comments_do_not_count(self) -> None: """Reject docstrings and comments after executable declarations.""" - for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"", - "\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")): + for text in ('notice = "' + MIT.replace("\n", "\\n") + '"', '"""' + MIT + '"""', "import os\n# " + MIT.replace("\n", "\n# ")): with self.subTest(text=text), self.assertRaises(ValueError): HEADERS.check_notice("src/a.py", text, fixture_policy()) def test_wrong_syntax_and_unterminated_comments_fail(self) -> None: """Notice text alone is insufficient without a complete language comment.""" - for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"): + for text in ("# " + MIT, "/*" + MIT, 'string text = "' + MIT + '";'): with self.subTest(text=text), self.assertRaises(ValueError): HEADERS.check_notice("src/a.cs", text, fixture_policy()) with self.assertRaises(ValueError): @@ -89,8 +97,7 @@ class HeaderTests(unittest.TestCase): def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None: """Do not conflate repository licenses or accept conflicting identifiers.""" - for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), - MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]): + for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]): with self.subTest(text=text), self.assertRaises(ValueError): HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy()) @@ -103,8 +110,7 @@ class HeaderTests(unittest.TestCase): def test_java_requires_full_apache_block(self) -> None: """An abbreviated SPDX-only Java notice cannot replace scoped convention.""" policy = fixture_policy("Kingsrook, LLC", "Apache-2.0") - for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", - APACHE.replace("limitations under the License.", "")): + for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", APACHE.replace("limitations under the License.", "")): with self.subTest(notice=notice), self.assertRaises(ValueError): HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy) @@ -121,9 +127,7 @@ class HeaderTests(unittest.TestCase): """New scripts enter the guard; data, generated and upstream files do not.""" self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY)) self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY)) - for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", - "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", - "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"): + for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"): with self.subTest(name=name): self.assertFalse(HEADERS.selected(name, POLICY)) self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"])) @@ -140,11 +144,11 @@ class HeaderTests(unittest.TestCase): """A tracked new source fails while untracked generated output is ignored.""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) - subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory. root.joinpath("src").mkdir() source = root / "src/a.py" source.write_text("print(1)", encoding="utf-8") - subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) + subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command. with self.assertRaisesRegex(ValueError, "src/a.py"): HEADERS.check_repository(root, fixture_policy()) source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d3bb4d..3c3e578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,19 @@ jobs: node-version: '24' package-manager-cache: false - run: npm ci --ignore-scripts + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: '0.13.6' + enable-cache: false + - name: Restore locked Python tools + run: uv sync --locked + - name: Verify script quality gates and rejection paths + run: | + uv run --locked --no-sync python .github/scripts/test-script-quality.py + uv run --locked --no-sync python .github/scripts/check-script-quality.py + npm run typecheck + npm run lint - name: Verify owned source notices and rejection paths run: | python3 .github/scripts/test-source-headers.py diff --git a/.gitignore b/.gitignore index b0099ef..0a81630 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ dist/ .env .DS_Store Thumbs.db + +# Local script-quality environments and tool caches. +.venv/ +.ruff_cache/ +__pycache__/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b66c37d..28ae411 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,3 +9,16 @@ Run both the Java and .NET consumers' protocol tests for a contract change. Chec Keep wire names stable within `/printing/v1`. Optional response fields may be added; clients ignore unknown response fields. Removing/renaming a field, changing its type or changing an existing field's meaning requires a new protocol version. A prerelease package version is not permission to silently break an already deployed connector. `npm run bundle` produces the release archive and checksum in `dist/`. Do not hand-edit generated checksums. Release tags must match the version in package.json, prefixed with `v`. Commits and releases are made by maintainers after review. + +## Script quality + +Owned scripts follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (configs adapted from its `python` and `typescript` profiles): three-space indentation, Ruff 0.17.3 format/lint and strict Pyright 1.1.414 for `.github/scripts/*.py`, and ESLint 10 with Stylistic (Allman) plus strict `tsc` over JSDoc for `tools/**/*.mjs` and `eslint.config.mjs`. Restore with `uv sync --locked` and `npm ci --ignore-scripts`, then run: + +```text +uv run --locked --no-sync python .github/scripts/test-script-quality.py +uv run --locked --no-sync python .github/scripts/check-script-quality.py +npm run typecheck +npm run lint +``` + +`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or JavaScript outside the `tsconfig.json` include list, and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print`/`console.log` only in `check-*.py` and `tools/`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. diff --git a/docs/TODO.md b/docs/TODO.md index b8fe97e..9103022 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,10 @@ # TODO +## Script quality gates + +- [x] Pin Ruff, strict Pyright, ESLint/Stylistic and strict checkJs with locked restores; reject untracked-scope Python/JavaScript and zero-file Pyright runs; negative tool probes pass locally. +- [ ] Observe the new `verify` job steps pass on hosted CI before relying on them. + ## Echo media integration - [x] Define bounded queue-default profile and exact revision selection schemas. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..3d2609d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,84 @@ +// Copyright 2026 Kingsrook, LLC +// SPDX-License-Identifier: Apache-2.0 +// Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152. +// KofTwentyTwo TypeScript / JavaScript lint configuration (K22-CODE-TS). +// The Kingsrook layout (3-space indent, Allman braces, if(...) with no space, no more than +// one blank line inside code) is enforced by ESLint Stylistic rather than Prettier, which +// cannot express Allman braces. Type-aware strict rules come from typescript-eslint. +// Install: npm i -D eslint @eslint/js typescript-eslint @stylistic/eslint-plugin typescript +import js from "@eslint/js"; +import stylistic from "@stylistic/eslint-plugin"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["**", "!tools/", "!tools/**", "!eslint.config.mjs"], + }, + js.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + globals: { process: "readonly", console: "readonly", Buffer: "readonly", URL: "readonly", fetch: "readonly", WebSocket: "readonly", setTimeout: "readonly", clearTimeout: "readonly", structuredClone: "readonly", performance: "readonly", AbortController: "readonly", Headers: "readonly" }, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { "@stylistic": stylistic }, + rules: { + // Kingsrook layout + "@stylistic/indent": ["error", 3, { SwitchCase: 1 }], + "@stylistic/brace-style": ["error", "allman", { allowSingleLine: false }], + "@stylistic/keyword-spacing": ["error", { + before: true, + after: true, + overrides: { + if: { after: false }, + for: { after: false }, + while: { after: false }, + switch: { after: false }, + catch: { after: false }, + }, + }], + // 1 blank line inside code; up to 3 so methods can be separated by 3. + "@stylistic/no-multiple-empty-lines": ["error", { max: 3, maxBOF: 0, maxEOF: 0 }], + "@stylistic/lines-between-class-members": ["error", "always", { exceptAfterSingleLine: true }], + "@stylistic/semi": ["error", "always"], + "@stylistic/quotes": ["error", "double", { avoidEscape: true }], + "@stylistic/comma-dangle": ["error", "always-multiline"], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/no-tabs": "error", + "@stylistic/no-trailing-spaces": "error", + "@stylistic/operator-linebreak": ["error", "before"], + "@stylistic/dot-location": ["error", "property"], + "@stylistic/max-len": "off", + + // Kingsrook non-layout rules + "curly": ["error", "all"], + "eqeqeq": ["error", "always"], + "no-console": "error", + "no-warning-comments": ["error", { terms: ["todo", "fixme"], location: "start" }], + "@typescript-eslint/explicit-function-return-type": "error", + "@typescript-eslint/naming-convention": ["error", + { selector: "default", format: ["camelCase"] }, + { selector: "variable", modifiers: ["const", "global"], format: ["camelCase", "UPPER_CASE"] }, + { selector: "typeLike", format: ["PascalCase"] }, + { selector: "enumMember", format: ["PascalCase", "UPPER_CASE"] }, + { selector: "import", format: null }, + { selector: "objectLiteralProperty", format: null }, + ], + }, + }, + { + files: ["**/*.js", "**/*.mjs", "**/*.cjs"], + extends: [tseslint.configs.disableTypeChecked], + // Checked JavaScript uses JSDoc plus strict tsc; TS-only return syntax is unavailable. + rules: { "@typescript-eslint/explicit-function-return-type": "off" }, + }, + { + files: ["tools/**/*.mjs"], + // These command-line tools intentionally print their results and diagnostics. + rules: { "no-console": ["error", { allow: ["log", "error"] }] }, + }, +); diff --git a/package-lock.json b/package-lock.json index a773023..2f7997f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,579 @@ "version": "0.4.0-rc.1", "license": "Apache-2.0", "devDependencies": { + "@eslint/js": "10.0.1", + "@stylistic/eslint-plugin": "5.10.0", + "@types/node": "24.10.1", "ajv": "8.20.0", - "ajv-formats": "3.0.1" + "ajv-formats": "3.0.1", + "eslint": "10.14.0", + "typescript": "6.0.3", + "typescript-eslint": "8.72.1" }, "engines": { "node": ">=24" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", + "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/type-utils": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.72.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", + "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", + "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.72.1", + "@typescript-eslint/types": "^8.72.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", + "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", + "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", + "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", + "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", + "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.72.1", + "@typescript-eslint/tsconfig-utils": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", + "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", + "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -51,6 +617,306 @@ } } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz", + "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.8", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.6", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -58,6 +924,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", @@ -75,6 +955,153 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -82,6 +1109,206 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -91,6 +1318,179 @@ "engines": { "node": ">=0.10.0" } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz", + "integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.72.1", + "@typescript-eslint/parser": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index d5cc3a2..6191377 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,19 @@ }, "scripts": { "check": "node tools/verify-schema.mjs && node tools/verify-fixtures.mjs", - "bundle": "node tools/bundle.mjs" + "bundle": "node tools/bundle.mjs", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit" }, "devDependencies": { "ajv": "8.20.0", - "ajv-formats": "3.0.1" - } + "ajv-formats": "3.0.1", + "@eslint/js": "10.0.1", + "typescript": "6.0.3", + "@stylistic/eslint-plugin": "5.10.0", + "@types/node": "24.10.1", + "typescript-eslint": "8.72.1", + "eslint": "10.14.0" + }, + "packageManager": "npm@11.19.1" } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b63885a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +[project] +name = "qqq-print-protocol-script-quality" +version = "0.0.0" +requires-python = ">=3.13" + +[dependency-groups] +dev = ["ruff==0.17.3", "pyright==1.1.414"] + +[tool.uv] +package = false diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..286fa55 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,56 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 +# Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152. +# KofTwentyTwo Python lint + format configuration (K22-CODE-PY). Copy to the repo root +# (or merge into [tool.ruff] in pyproject.toml). `ruff format --check` and `ruff check` +# run in CI and pre-commit with zero findings allowed. +target-version = "py313" +include = [".github/scripts/*.py"] +extend-exclude = [".venv", "node_modules", "dist", "target", "bin", "obj"] +# Kingsrook: no practical line-length limit (ruff's maximum is 320). +line-length = 320 +# Kingsrook: 3-space indentation. Ruff's formatter supports it fully; pycodestyle's +# "multiple of four" rules (E111/E114) are therefore not selected. +indent-width = 3 + +[format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" +docstring-code-format = true + +[lint] +select = [ + "F", # pyflakes + "E4", "E7", "E9", "W", # pycodestyle errors that are layout-independent + "I", # isort: no wildcard imports, ordered + "N", # pep8-naming + "UP", # pyupgrade + "B", # bugbear + "S", # bandit security + "BLE", # blind except + "T20", # print statements: use logging (Kingsrook logging rule) + "ERA", # commented-out code (Kingsrook: no zombie code) + "FIX", # FIXME/TODO must be resolved or tracked + "TD", # TODO format: author and issue link + "PTH", # pathlib over os.path + "RET", "SIM", "C4", "PIE", "PL", "RUF", + "ANN", # type annotations on every function + "D", # docstrings on modules, classes, functions + "LOG", "G", # logging hygiene +] +ignore = [ + "D203", # conflicts with D211 + "D213", # conflicts with D212 + "TD003", # issue link is enforced by review, not regex +] + +[lint.per-file-ignores] +".github/scripts/check-*.py" = ["T201"] # CLI diagnostics are the public stdout contract, not application logging. +".github/scripts/test-*.py" = ["S101", "PLR2004"] # Synthetic assertions and explicit boundary values. + +[lint.pydocstyle] +convention = "pep257" + +[lint.flake8-tidy-imports] +ban-relative-imports = "all" diff --git a/tools/bundle.mjs b/tools/bundle.mjs index bfedbc6..2bad3aa 100644 --- a/tools/bundle.mjs +++ b/tools/bundle.mjs @@ -1,25 +1,29 @@ // Copyright 2026 Kingsrook, LLC // SPDX-License-Identifier: Apache-2.0 -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -const root = fileURLToPath(new URL('../', import.meta.url)); -const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); -assert(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(manifest.version), 'Invalid release version.'); -if(process.env.GITHUB_REF_TYPE === 'tag') +const root = fileURLToPath(new URL("../", import.meta.url)); +/** @type {{version:string}} */ +const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +assert(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(manifest.version), "Invalid release version."); +if(process.env["GITHUB_REF_TYPE"] === "tag") { - assert.equal(process.env.GITHUB_REF_NAME, `v${manifest.version}`, 'Release tag must match package.json.'); + assert.equal(process.env["GITHUB_REF_NAME"], `v${manifest.version}`, "Release tag must match package.json."); } -mkdirSync(new URL('../dist/', import.meta.url), { recursive: true }); +mkdirSync(new URL("../dist/", import.meta.url), { recursive: true }); const name = `qqq-print-protocol-${manifest.version}.tar.gz`; -const files = ['README.md', 'LICENSE', 'NOTICE', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'package.json', 'package-lock.json', 'schemas', 'fixtures', 'tools', 'docs/INTEGRATION.md', 'docs/DEVICE-CONTROLS.md', 'docs/QQQ-AUTHENTICATION.md', 'docs/REALTIME.md', 'docs/PRINTER-MEDIA.md']; -const result = spawnSync('tar', ['-czf', `dist/${name}`, ...files], { cwd: root, stdio: 'inherit', shell: false }); -if(result.error) throw result.error; -assert.equal(result.status, 0, 'Archive creation failed.'); -const digest = createHash('sha256').update(readFileSync(new URL(`../dist/${name}`, import.meta.url))).digest('hex'); -writeFileSync(new URL('../dist/SHA256SUMS', import.meta.url), `${digest} ${name}\n`); +const files = ["README.md", "LICENSE", "NOTICE", "CHANGELOG.md", "CONTRIBUTING.md", "SECURITY.md", "package.json", "package-lock.json", "tsconfig.json", "eslint.config.mjs", "schemas", "fixtures", "tools", "docs/INTEGRATION.md", "docs/DEVICE-CONTROLS.md", "docs/QQQ-AUTHENTICATION.md", "docs/REALTIME.md", "docs/PRINTER-MEDIA.md"]; +const result = spawnSync("tar", ["-czf", `dist/${name}`, ...files], { cwd: root, stdio: "inherit", shell: false }); +if(result.error) +{ + throw result.error; +} +assert.equal(result.status, 0, "Archive creation failed."); +const digest = createHash("sha256").update(readFileSync(new URL(`../dist/${name}`, import.meta.url))).digest("hex"); +writeFileSync(new URL("../dist/SHA256SUMS", import.meta.url), `${digest} ${name}\n`); console.log(`Created dist/${name} and dist/SHA256SUMS.`); diff --git a/tools/create-content-fixtures.mjs b/tools/create-content-fixtures.mjs index a96558c..9f4f6fe 100644 --- a/tools/create-content-fixtures.mjs +++ b/tools/create-content-fixtures.mjs @@ -1,35 +1,35 @@ // Copyright 2026 Kingsrook, LLC // SPDX-License-Identifier: Apache-2.0 -import { createHash } from 'node:crypto'; -import { writeFileSync } from 'node:fs'; +import { createHash } from "node:crypto"; +import { writeFileSync } from "node:fs"; // Keep printable fixtures synthetic and independent of a printer's settings. -const content = Buffer.from('^XA\n^FO40,40^A0N,36,36^FDQQQ cloud print test^FS\n^FO40,90^A0N,24,24^FDProtocol v1 - one test label^FS\n^XZ\n', 'utf8'); -const root = new URL('../fixtures/', import.meta.url); -writeFileSync(new URL('test-label.zpl', root), content); +const content = Buffer.from("^XA\n^FO40,40^A0N,36,36^FDQQQ cloud print test^FS\n^FO40,90^A0N,24,24^FDProtocol v1 - one test label^FS\n^XZ\n", "utf8"); +const root = new URL("../fixtures/", import.meta.url); +writeFileSync(new URL("test-label.zpl", root), content); const job = { - jobId: 'job-example', - deviceId: 'device-example', - printerId: 'zebra-example', - title: 'QQQ cloud print test', - format: 'ZPL', - contentSha256: createHash('sha256').update(content).digest('hex'), + jobId: "job-example", + deviceId: "device-example", + printerId: "zebra-example", + title: "QQQ cloud print test", + format: "ZPL", + contentSha256: createHash("sha256").update(content).digest("hex"), contentLength: content.length, - options: { copies: 1, scale: 'ACTUAL_SIZE' }, - createdAt: '2026-10-29T18:00:00Z', - status: 'CLAIMED' + options: { copies: 1, scale: "ACTUAL_SIZE" }, + createdAt: "2026-10-29T18:00:00Z", + status: "CLAIMED", }; const request = { deviceId: job.deviceId, printerId: job.printerId, title: job.title, format: job.format, - contentBase64: content.toString('base64'), - idempotencyKey: 'protocol-fixture-label-v1', - options: job.options + contentBase64: content.toString("base64"), + idempotencyKey: "protocol-fixture-label-v1", + options: job.options, }; -writeFileSync(new URL('submit.request.json', root), JSON.stringify(request, null, 2) + '\n'); -writeFileSync(new URL('claim.response.json', root), JSON.stringify({ job }, null, 2) + '\n'); -writeFileSync(new URL('job.response.json', root), JSON.stringify(job, null, 2) + '\n'); +writeFileSync(new URL("submit.request.json", root), JSON.stringify(request, null, 2) + "\n"); +writeFileSync(new URL("claim.response.json", root), JSON.stringify({ job }, null, 2) + "\n"); +writeFileSync(new URL("job.response.json", root), JSON.stringify(job, null, 2) + "\n"); diff --git a/tools/fixture-types.mjs b/tools/fixture-types.mjs index 89703ca..c72de89 100644 --- a/tools/fixture-types.mjs +++ b/tools/fixture-types.mjs @@ -3,82 +3,82 @@ // Shared correspondence between wire examples and schema definitions. export const fixtureTypes = { - 'printing-realtime-extension.discovery.json': 'PrintingRealtimeExtensionDiscovery', - 'version-legacy.response.json': 'ProtocolVersionResponse', - 'version-controls.response.json': 'ProtocolVersionResponse', - 'control-poll.request.json': 'DeviceControlPollRequest', - 'control-poll.response.json': 'DeviceControlPollResponse', - 'control-disconnected.response.json': 'DeviceControlPollResponse', - 'device-control.response.json': 'Device', - 'action-pause-dispatch.request.json': 'AdminDeviceActionRequest', - 'action-resume-dispatch.request.json': 'AdminDeviceActionRequest', - 'action-disconnect.request.json': 'AdminDeviceActionRequest', - 'action-reconnect.request.json': 'AdminDeviceActionRequest', - 'action-revoke.request.json': 'AdminDeviceActionRequest', - 'action-refresh-inventory.request.json': 'AdminDeviceActionRequest', - 'action-clear-queue.request.json': 'AdminDeviceActionRequest', - 'action-cancel-spool.request.json': 'AdminDeviceActionRequest', - 'action-refresh.response.json': 'DeviceActionRecord', - 'action-cancel.response.json': 'DeviceActionRecord', - 'action-clear-queue.response.json': 'DeviceActionRecord', - 'action-pause-dispatch.response.json': 'DeviceActionRecord', - 'action-revoke.response.json': 'DeviceActionRecord', - 'control-inventory-refreshed.receipt.json': 'DeviceCommandReceipt', - 'control-cancel-requested.receipt.json': 'DeviceCommandReceipt', - 'control-already-absent.receipt.json': 'DeviceCommandReceipt', - 'control-ownership-mismatch.receipt.json': 'DeviceCommandReceipt', - 'control-unsupported.receipt.json': 'DeviceCommandReceipt', - 'control-failed.receipt.json': 'DeviceCommandReceipt', - 'control-expired.receipt.json': 'DeviceCommandReceipt', - 'control-outcome-unknown.receipt.json': 'DeviceCommandReceipt', - 'control-commands-page.response.json': 'DeviceActionList', - 'control-cancel-completed.response.json': 'DeviceActionRecord', - 'heartbeat-profile.request.json': 'HeartbeatRequest', - 'printers-profile.request.json': 'PrinterInventory', - 'device-test.request.json': 'DeviceTestPrintRequest', - 'device-test-profile.request.json': 'DeviceTestPrintRequest', - 'device-retry.request.json': 'DeviceRetryRequest', - 'create-enrollment.request.json': 'CreateEnrollmentRequest', - 'enrollment.response.json': 'EnrollmentCode', - 'enroll.request.json': 'EnrollRequest', - 'enroll.response.json': 'EnrollResponse', - 'heartbeat.request.json': 'HeartbeatRequest', - 'heartbeat.response.json': 'HeartbeatResponse', - 'printers.request.json': 'PrinterInventory', - 'printers-page.response.json': 'PrinterList', - 'approval.request.json': 'PrinterApproval', - 'claim-empty.response.json': 'ClaimResponse', - 'claim.response.json': 'ClaimResponse', - 'job.response.json': 'PrintJob', - 'submit.request.json': 'SubmitJobRequest', - 'received.event.json': 'JobEvent', - 'submitting.event.json': 'JobEvent', - 'spooled.event.json': 'JobEvent', - 'ack.response.json': 'Acknowledgment', - 'error.response.json': 'Error', - 'printing-session.response.json': 'PrintingSessionIdentity', - 'printing-session-bound.response.json': 'PrintingSessionIdentity', - 'realtime-settings.payload.json': 'RealtimeSettings', - 'realtime-hello.message.json': 'RealtimeHello', - 'realtime-welcome.message.json': 'RealtimeWelcome', - 'realtime-state-applied.message.json': 'RealtimeStateApplied', - 'realtime-desired-state.message.json': 'RealtimeDesiredState', - 'realtime-job-available.message.json': 'RealtimeJobAvailable', - 'realtime-command.message.json': 'RealtimeCommand', - 'realtime-command-receipt.message.json': 'RealtimeCommandReceipt', - 'realtime-job-event.message.json': 'RealtimeJobEvent', - 'realtime-job-event-replay.message.json': 'RealtimeJobEvent', - 'realtime-settings.message.json': 'RealtimeSettingsMessage', - 'realtime-ack.message.json': 'RealtimeAck', - 'realtime-error.message.json': 'RealtimeError', - 'realtime-heartbeat.message.json': 'RealtimeHeartbeat', - 'realtime-inventory.message.json': 'RealtimeInventory', - 'realtime-ping.message.json': 'RealtimePing', - 'realtime-pong.message.json': 'RealtimePong', - 'realtime-large-pdf.response.json': 'PrintJob', - 'printer-media-profile.response.json': 'PrinterMediaProfile', - 'media-profile-options.request.json': 'PrintOptions', - 'operator-printers.response.json': 'OperatorPrinterList', - 'operator-submit.request.json': 'OperatorSubmitJobRequest', - 'realtime-media-hello.message.json': 'RealtimeHello' + "printing-realtime-extension.discovery.json": "PrintingRealtimeExtensionDiscovery", + "version-legacy.response.json": "ProtocolVersionResponse", + "version-controls.response.json": "ProtocolVersionResponse", + "control-poll.request.json": "DeviceControlPollRequest", + "control-poll.response.json": "DeviceControlPollResponse", + "control-disconnected.response.json": "DeviceControlPollResponse", + "device-control.response.json": "Device", + "action-pause-dispatch.request.json": "AdminDeviceActionRequest", + "action-resume-dispatch.request.json": "AdminDeviceActionRequest", + "action-disconnect.request.json": "AdminDeviceActionRequest", + "action-reconnect.request.json": "AdminDeviceActionRequest", + "action-revoke.request.json": "AdminDeviceActionRequest", + "action-refresh-inventory.request.json": "AdminDeviceActionRequest", + "action-clear-queue.request.json": "AdminDeviceActionRequest", + "action-cancel-spool.request.json": "AdminDeviceActionRequest", + "action-refresh.response.json": "DeviceActionRecord", + "action-cancel.response.json": "DeviceActionRecord", + "action-clear-queue.response.json": "DeviceActionRecord", + "action-pause-dispatch.response.json": "DeviceActionRecord", + "action-revoke.response.json": "DeviceActionRecord", + "control-inventory-refreshed.receipt.json": "DeviceCommandReceipt", + "control-cancel-requested.receipt.json": "DeviceCommandReceipt", + "control-already-absent.receipt.json": "DeviceCommandReceipt", + "control-ownership-mismatch.receipt.json": "DeviceCommandReceipt", + "control-unsupported.receipt.json": "DeviceCommandReceipt", + "control-failed.receipt.json": "DeviceCommandReceipt", + "control-expired.receipt.json": "DeviceCommandReceipt", + "control-outcome-unknown.receipt.json": "DeviceCommandReceipt", + "control-commands-page.response.json": "DeviceActionList", + "control-cancel-completed.response.json": "DeviceActionRecord", + "heartbeat-profile.request.json": "HeartbeatRequest", + "printers-profile.request.json": "PrinterInventory", + "device-test.request.json": "DeviceTestPrintRequest", + "device-test-profile.request.json": "DeviceTestPrintRequest", + "device-retry.request.json": "DeviceRetryRequest", + "create-enrollment.request.json": "CreateEnrollmentRequest", + "enrollment.response.json": "EnrollmentCode", + "enroll.request.json": "EnrollRequest", + "enroll.response.json": "EnrollResponse", + "heartbeat.request.json": "HeartbeatRequest", + "heartbeat.response.json": "HeartbeatResponse", + "printers.request.json": "PrinterInventory", + "printers-page.response.json": "PrinterList", + "approval.request.json": "PrinterApproval", + "claim-empty.response.json": "ClaimResponse", + "claim.response.json": "ClaimResponse", + "job.response.json": "PrintJob", + "submit.request.json": "SubmitJobRequest", + "received.event.json": "JobEvent", + "submitting.event.json": "JobEvent", + "spooled.event.json": "JobEvent", + "ack.response.json": "Acknowledgment", + "error.response.json": "Error", + "printing-session.response.json": "PrintingSessionIdentity", + "printing-session-bound.response.json": "PrintingSessionIdentity", + "realtime-settings.payload.json": "RealtimeSettings", + "realtime-hello.message.json": "RealtimeHello", + "realtime-welcome.message.json": "RealtimeWelcome", + "realtime-state-applied.message.json": "RealtimeStateApplied", + "realtime-desired-state.message.json": "RealtimeDesiredState", + "realtime-job-available.message.json": "RealtimeJobAvailable", + "realtime-command.message.json": "RealtimeCommand", + "realtime-command-receipt.message.json": "RealtimeCommandReceipt", + "realtime-job-event.message.json": "RealtimeJobEvent", + "realtime-job-event-replay.message.json": "RealtimeJobEvent", + "realtime-settings.message.json": "RealtimeSettingsMessage", + "realtime-ack.message.json": "RealtimeAck", + "realtime-error.message.json": "RealtimeError", + "realtime-heartbeat.message.json": "RealtimeHeartbeat", + "realtime-inventory.message.json": "RealtimeInventory", + "realtime-ping.message.json": "RealtimePing", + "realtime-pong.message.json": "RealtimePong", + "realtime-large-pdf.response.json": "PrintJob", + "printer-media-profile.response.json": "PrinterMediaProfile", + "media-profile-options.request.json": "PrintOptions", + "operator-printers.response.json": "OperatorPrinterList", + "operator-submit.request.json": "OperatorSubmitJobRequest", + "realtime-media-hello.message.json": "RealtimeHello", }; diff --git a/tools/verify-fixtures.mjs b/tools/verify-fixtures.mjs index 1b5ec09..ef249d9 100644 --- a/tools/verify-fixtures.mjs +++ b/tools/verify-fixtures.mjs @@ -1,148 +1,236 @@ // Copyright 2026 Kingsrook, LLC // SPDX-License-Identifier: Apache-2.0 -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { fixtureTypes } from './fixture-types.mjs'; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fixtureTypes } from "./fixture-types.mjs"; -const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8')); -const root = new URL('../fixtures/', import.meta.url); +/** @typedef {{ $ref?:string,not?:Rule|boolean,required?:string[],type?:string,anyOf?:(Rule|boolean)[],const?:unknown,enum?:unknown[],additionalProperties?:boolean,properties?:Record,dependentRequired?:Record,minItems?:number,maxItems?:number,uniqueItems?:boolean,contains?:Rule|boolean,items?:Rule|boolean,minLength?:number,maxLength?:number,pattern?:string,format?:string,contentEncoding?:string,minimum?:number,maximum?:number,exclusiveMinimum?:number }} Rule */ +/** @type {{$defs:Record}} */ +const schema = JSON.parse(readFileSync(new URL("../schemas/protocol.schema.json", import.meta.url), "utf8")); +const root = new URL("../fixtures/", import.meta.url); // Validate the schema keywords used by this contract without installing dependencies. +/** @param {unknown} value @param {Rule|boolean} rule @param {string} path @returns {void} */ function validate(value, rule, path) { - if(typeof rule === 'boolean') + if(typeof rule === "boolean") { assert(rule, `${path}: forbidden property`); return; } if(rule.$ref) { - return validate(value, schema.$defs[rule.$ref.split('/').at(-1)], path); + return validate(value, /** @type {Rule} */ (schema.$defs[/** @type {string} */ (rule.$ref.split("/").at(-1))]), path); } if(rule.not) { let matches = true; - try { validate(value, rule.not, path); } - catch { matches = false; } + try + { + validate(value, rule.not, path); + } + catch + { + matches = false; + } assert(!matches, `${path}: forbidden combination`); } if(rule.required && !rule.type) { - for(const name of rule.required) assert(name in value, `${path}.${name}: required`); + for(const name of rule.required) + { + assert(name in /** @type {object} */ (value), `${path}.${name}: required`); + } } if(rule.anyOf) { assert(rule.anyOf.some(candidate => { - try { validate(value, candidate, path); return true; } - catch { return false; } + try + { + validate(value, candidate, path); return true; + } + catch + { + return false; + } }), `${path}: no anyOf variant matched`); } - if(rule.const !== undefined) assert.deepEqual(value, rule.const, path); - if(rule.enum) assert(rule.enum.includes(value), `${path}: invalid enum`); - if(rule.type === 'null') assert.equal(value, null, path); - if(rule.type === 'object') + if(rule.const !== undefined) { - assert(value !== null && typeof value === 'object' && !Array.isArray(value), `${path}: expected object`); - for(const name of rule.required ?? []) assert(name in value, `${path}.${name}: required`); + assert.deepEqual(value, rule.const, path); + } + if(rule.enum) + { + assert(rule.enum.includes(value), `${path}: invalid enum`); + } + if(rule.type === "null") + { + assert.equal(value, null, path); + } + if(rule.type === "object") + { + assert(value !== null && typeof value === "object" && !Array.isArray(value), `${path}: expected object`); + for(const name of rule.required ?? []) + { + assert(name in /** @type {object} */ (value), `${path}.${name}: required`); + } if(rule.additionalProperties === false) { - for(const name of Object.keys(value)) assert(name in (rule.properties ?? {}), `${path}.${name}: unknown property`); + for(const name of Object.keys(value)) + { + assert(name in (rule.properties ?? {}), `${path}.${name}: unknown property`); + } } for(const [name, child] of Object.entries(rule.properties ?? {})) { - if(name in value) validate(value[name], child, `${path}.${name}`); + if(name in /** @type {object} */ (value)) + { + validate(/** @type {Record} */ (value)[name], child, `${path}.${name}`); + } } for(const [name, dependencies] of Object.entries(rule.dependentRequired ?? {})) { - if(name in value) for(const dependency of dependencies) assert(dependency in value, `${path}.${dependency}: required with ${name}`); + if(name in /** @type {object} */ (value)) + { + for(const dependency of dependencies) + { + assert(dependency in value, `${path}.${dependency}: required with ${name}`); + } + } } } - if(rule.type === 'array') + if(rule.type === "array") { assert(Array.isArray(value), `${path}: expected array`); - if(rule.minItems !== undefined) assert(value.length >= rule.minItems, path); - if(rule.maxItems !== undefined) assert(value.length <= rule.maxItems, path); - if(rule.uniqueItems) assert.equal(new Set(value.map(item => JSON.stringify(item))).size, value.length, path); + if(rule.minItems !== undefined) + { + assert(value.length >= rule.minItems, path); + } + if(rule.maxItems !== undefined) + { + assert(value.length <= rule.maxItems, path); + } + if(rule.uniqueItems) + { + assert.equal(new Set(value.map(item => JSON.stringify(item))).size, value.length, path); + } if(rule.contains) { assert(value.some(item => { - try { validate(item, rule.contains, path); return true; } - catch { return false; } + try + { + validate(item, /** @type {Rule|boolean} */ (rule.contains), path); return true; + } + catch + { + return false; + } }), `${path}: required array member missing`); } - value.forEach((item, index) => validate(item, rule.items, `${path}[${index}]`)); + value.forEach((item, index) => validate(item, /** @type {Rule|boolean} */ (rule.items), `${path}[${index}]`)); } - if(rule.type === 'string') + if(rule.type === "string") { - assert.equal(typeof value, 'string', path); - if(rule.minLength !== undefined) assert(value.length >= rule.minLength, path); - if(rule.maxLength !== undefined) assert(value.length <= rule.maxLength, path); - if(rule.pattern) assert(new RegExp(rule.pattern).test(value), path); - if(rule.format === 'date-time') assert(!Number.isNaN(Date.parse(value)) && value.endsWith('Z'), `${path}: UTC date-time required`); - if(rule.contentEncoding === 'base64') assert.equal(Buffer.from(value, 'base64').toString('base64'), value, path); + assert.equal(typeof value, "string", path); + if(rule.minLength !== undefined) + { + assert((/** @type {string} */ (value)).length >= rule.minLength, path); + } + if(rule.maxLength !== undefined) + { + assert((/** @type {string} */ (value)).length <= rule.maxLength, path); + } + if(rule.pattern) + { + assert(new RegExp(rule.pattern).test(/** @type {string} */ (value)), path); + } + if(rule.format === "date-time") + { + assert(!Number.isNaN(Date.parse(/** @type {string} */ (value))) && (/** @type {string} */ (value)).endsWith("Z"), `${path}: UTC date-time required`); + } + if(rule.contentEncoding === "base64") + { + assert.equal(Buffer.from(/** @type {string} */ (value), "base64").toString("base64"), value, path); + } } - if(rule.type === 'boolean') assert.equal(typeof value, 'boolean', path); - if(rule.type === 'integer' || rule.type === 'number') + if(rule.type === "boolean") { - assert.equal(typeof value, 'number', path); + assert.equal(typeof value, "boolean", path); + } + if(rule.type === "integer" || rule.type === "number") + { + assert.equal(typeof value, "number", path); assert(Number.isFinite(value), path); - if(rule.type === 'integer') assert(Number.isInteger(value), path); - if(rule.minimum !== undefined) assert(value >= rule.minimum, path); - if(rule.maximum !== undefined) assert(value <= rule.maximum, path); - if(rule.exclusiveMinimum !== undefined) assert(value > rule.exclusiveMinimum, path); + if(rule.type === "integer") + { + assert(Number.isInteger(value), path); + } + if(rule.minimum !== undefined) + { + assert(/** @type {number} */ (value) >= rule.minimum, path); + } + if(rule.maximum !== undefined) + { + assert(/** @type {number} */ (value) <= rule.maximum, path); + } + if(rule.exclusiveMinimum !== undefined) + { + assert(/** @type {number} */ (value) > rule.exclusiveMinimum, path); + } } } +/** @type {Record} */ const fixtures = {}; for(const [name, type] of Object.entries(fixtureTypes)) { - fixtures[name] = JSON.parse(readFileSync(new URL(name, root), 'utf8')); - validate(fixtures[name], schema.$defs[type], name); + fixtures[name] = JSON.parse(readFileSync(new URL(name, root), "utf8")); + validate(fixtures[name], /** @type {Rule} */ (schema.$defs[type]), name); } -const content = readFileSync(new URL('test-label.zpl', root)); -const job = fixtures['claim.response.json'].job; +const content = readFileSync(new URL("test-label.zpl", root)); +const job = (/** @type {typeof import("../fixtures/claim.response.json")} */ (fixtures["claim.response.json"])).job; assert.equal(content.length, job.contentLength); -assert.equal(createHash('sha256').update(content).digest('hex'), job.contentSha256); -assert.deepEqual(Buffer.from(fixtures['submit.request.json'].contentBase64, 'base64'), content); -assert.deepEqual(fixtures['job.response.json'], job); -assert.throws(() => validate({ copies: 0 }, schema.$defs.PrintOptions, 'invalidCopies')); -assert.throws(() => validate({ paperWidthMm: 100 }, schema.$defs.PrintOptions, 'missingHeight')); -assert.throws(() => validate({ eventId: 'test', status: 'PRINTED' }, schema.$defs.JobEvent, 'invalidStatus')); -assert.throws(() => validate({ name: 'x'.repeat(513) }, schema.$defs.DeviceProfile, 'oversizedName')); -assert.throws(() => validate({ description: 'x'.repeat(4097) }, schema.$defs.DeviceProfile, 'oversizedDescription')); -assert.throws(() => validate({ location: 'x'.repeat(513) }, schema.$defs.DeviceProfile, 'oversizedLocation')); -validate({ name: '', description: null }, schema.$defs.DeviceProfile, 'clearAndPreserve'); -const brandedTest = fixtures['device-test-profile.request.json']; -assert.throws(() => validate({ ...brandedTest, testProfile: 'LETTER' }, schema.$defs.DeviceTestPrintRequest, 'incompatibleLabelMedia')); -assert.throws(() => validate({ ...brandedTest, testProfile: 'UNKNOWN' }, schema.$defs.DeviceTestPrintRequest, 'unknownTestProfile')); -assert.throws(() => validate({ ...brandedTest, testProfile: null }, schema.$defs.DeviceTestPrintRequest, 'nullTestProfile')); -assert.throws(() => validate({ format: 'PDF' }, schema.$defs.DeviceTestPrintRequest, 'missingTestIdentity')); -validate({ ...brandedTest, format: 'PDF', testProfile: 'A4' }, schema.$defs.DeviceTestPrintRequest, 'a4TestPage'); -const clear = fixtures['action-clear-queue.request.json']; -assert.throws(() => validate({ ...clear, issuedAt: undefined }, schema.$defs.AdminDeviceActionRequest, 'missingIssuedAt')); -assert.throws(() => validate({ ...clear, jobId: 'job-example-1' }, schema.$defs.AdminDeviceActionRequest, 'clearCannotTargetSpool')); -assert.throws(() => validate({ ...clear, action: 'RESTART_SERVICE' }, schema.$defs.AdminDeviceActionRequest, 'arbitraryServiceControl')); -assert.throws(() => validate({ ...clear, command: 'shell' }, schema.$defs.AdminDeviceActionRequest, 'unknownCommandProperty')); -assert.throws(() => validate({ ...clear, action: 'CANCEL_SPOOL' }, schema.$defs.AdminDeviceActionRequest, 'cancelRequiresJobIdentity')); -assert.throws(() => validate({ ...fixtures['control-poll.request.json'], capabilities: ['CANCEL_SPOOL', 'CANCEL_SPOOL'] }, schema.$defs.DeviceControlPollRequest, 'duplicateCapability')); -assert.throws(() => validate({ ...fixtures['control-cancel-requested.receipt.json'], outcome: 'PRINT_RECALLED' }, schema.$defs.DeviceCommandReceipt, 'unsupportedPaperClaim')); +assert.equal(createHash("sha256").update(content).digest("hex"), job.contentSha256); +assert.deepEqual(Buffer.from((/** @type {typeof import("../fixtures/submit.request.json")} */ (fixtures["submit.request.json"])).contentBase64, "base64"), content); +assert.deepEqual(fixtures["job.response.json"], job); +assert.throws(() => validate({ copies: 0 }, /** @type {Rule} */ (schema.$defs["PrintOptions"]), "invalidCopies")); +assert.throws(() => validate({ paperWidthMm: 100 }, /** @type {Rule} */ (schema.$defs["PrintOptions"]), "missingHeight")); +assert.throws(() => validate({ eventId: "test", status: "PRINTED" }, /** @type {Rule} */ (schema.$defs["JobEvent"]), "invalidStatus")); +assert.throws(() => validate({ name: "x".repeat(513) }, /** @type {Rule} */ (schema.$defs["DeviceProfile"]), "oversizedName")); +assert.throws(() => validate({ description: "x".repeat(4097) }, /** @type {Rule} */ (schema.$defs["DeviceProfile"]), "oversizedDescription")); +assert.throws(() => validate({ location: "x".repeat(513) }, /** @type {Rule} */ (schema.$defs["DeviceProfile"]), "oversizedLocation")); +validate({ name: "", description: null }, /** @type {Rule} */ (schema.$defs["DeviceProfile"]), "clearAndPreserve"); +const brandedTest = fixtures["device-test-profile.request.json"]; +assert.throws(() => validate({ ...brandedTest, testProfile: "LETTER" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "incompatibleLabelMedia")); +assert.throws(() => validate({ ...brandedTest, testProfile: "UNKNOWN" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "unknownTestProfile")); +assert.throws(() => validate({ ...brandedTest, testProfile: null }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "nullTestProfile")); +assert.throws(() => validate({ format: "PDF" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "missingTestIdentity")); +validate({ ...brandedTest, format: "PDF", testProfile: "A4" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "a4TestPage"); +const clear = fixtures["action-clear-queue.request.json"]; +assert.throws(() => validate({ ...clear, issuedAt: undefined }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "missingIssuedAt")); +assert.throws(() => validate({ ...clear, jobId: "job-example-1" }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "clearCannotTargetSpool")); +assert.throws(() => validate({ ...clear, action: "RESTART_SERVICE" }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "arbitraryServiceControl")); +assert.throws(() => validate({ ...clear, command: "shell" }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "unknownCommandProperty")); +assert.throws(() => validate({ ...clear, action: "CANCEL_SPOOL" }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "cancelRequiresJobIdentity")); +assert.throws(() => validate({ ...fixtures["control-poll.request.json"], capabilities: ["CANCEL_SPOOL", "CANCEL_SPOOL"] }, /** @type {Rule} */ (schema.$defs["DeviceControlPollRequest"]), "duplicateCapability")); +assert.throws(() => validate({ ...fixtures["control-cancel-requested.receipt.json"], outcome: "PRINT_RECALLED" }, /** @type {Rule} */ (schema.$defs["DeviceCommandReceipt"]), "unsupportedPaperClaim")); console.log(`Validated ${Object.keys(fixtures).length} protocol fixtures, content integrity, and invalid-input checks.`); -const media = fixtures['printer-media-profile.response.json']; -for(const invalid of [{ dpiX: 0 }, { widthMicrons: 0 }, { revision: 'not-a-revision' }, { applyMode: 'CHANGE_STOCK' }]) +const media = fixtures["printer-media-profile.response.json"]; +for(const invalid of [{ dpiX: 0 }, { widthMicrons: 0 }, { revision: "not-a-revision" }, { applyMode: "CHANGE_STOCK" }]) { - assert.throws(() => validate({ ...media, ...invalid }, schema.$defs.PrinterMediaProfile, 'invalidMedia')); + assert.throws(() => validate({ ...media, ...invalid }, /** @type {Rule} */ (schema.$defs["PrinterMediaProfile"]), "invalidMedia")); } -const selection = fixtures['media-profile-options.request.json']; -assert.throws(() => validate({ ...selection, paperWidthMm: 100, paperHeightMm: 150 }, schema.$defs.PrintOptions, 'ambiguousMedia')); +const selection = fixtures["media-profile-options.request.json"]; +assert.throws(() => validate({ ...selection, paperWidthMm: 100, paperHeightMm: 150 }, /** @type {Rule} */ (schema.$defs["PrintOptions"]), "ambiguousMedia")); -const operatorRequest = fixtures['operator-submit.request.json']; -for(const invalid of [{ requestId: 'x'.repeat(129) }, { requestId: 'echo/scan' }, { actorId: 'forged' }, { tenantId: 'forged' }]) +const operatorRequest = fixtures["operator-submit.request.json"]; +for(const invalid of [{ requestId: "x".repeat(129) }, { requestId: "echo/scan" }, { actorId: "forged" }, { tenantId: "forged" }]) { - assert.throws(() => validate({ ...operatorRequest, ...invalid }, schema.$defs.OperatorSubmitJobRequest, 'invalidOperatorRequest')); + assert.throws(() => validate({ ...operatorRequest, ...invalid }, /** @type {Rule} */ (schema.$defs["OperatorSubmitJobRequest"]), "invalidOperatorRequest")); } -validate({ ...operatorRequest, requestId: 'x'.repeat(128) }, schema.$defs.OperatorSubmitJobRequest, 'maximumRequestKey'); +validate({ ...operatorRequest, requestId: "x".repeat(128) }, /** @type {Rule} */ (schema.$defs["OperatorSubmitJobRequest"]), "maximumRequestKey"); diff --git a/tools/verify-realtime.mjs b/tools/verify-realtime.mjs index 52dfb12..4699a09 100644 --- a/tools/verify-realtime.mjs +++ b/tools/verify-realtime.mjs @@ -1,62 +1,72 @@ // Copyright 2026 Kingsrook, LLC // SPDX-License-Identifier: Apache-2.0 -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import Ajv2020 from 'ajv/dist/2020.js'; -import addFormats from 'ajv-formats'; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; -const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8')); +const schema = JSON.parse(readFileSync(new URL("../schemas/protocol.schema.json", import.meta.url), "utf8")); const ajv = new Ajv2020({ strict: true, allErrors: true }); -addFormats(ajv); +// ajv-formats is callable CommonJS; its NodeNext declaration exposes a namespace. +/** @type {import("ajv-formats").FormatsPlugin} */ (/** @type {unknown} */ (addFormats))(ajv); ajv.addSchema(schema); -const fixture = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8')); +/** @param {string} name @returns {Record} */ +const fixture = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), "utf8")); +/** @param {string} type @param {unknown} value @returns {boolean|Promise} */ const valid = (type, value) => ajv.validate(`${schema.$id}#/$defs/${type}`, value); -const settings = fixture('realtime-settings.payload.json'); -const hello = fixture('realtime-hello.message.json'); -const extension = fixture('printing-realtime-extension.discovery.json'); -assert(valid('PrintingRealtimeExtensionDiscovery', extension), ajv.errorsText()); -assert.equal(extension.version, '2.0.0'); -assert(valid('PrintingRealtimeExtensionDiscovery', { ...extension, version: '2.7.3' })); -for(const version of ['1.0.0', '1.1.0', '3.0.0', '2.0', '2.01.0', '2.0.0-rc.1', null]) +const settings = /** @type {typeof import("../fixtures/realtime-settings.payload.json")} */ (/** @type {unknown} */ (fixture("realtime-settings.payload.json"))); +const hello = /** @type {typeof import("../fixtures/realtime-hello.message.json")} */ (/** @type {unknown} */ (fixture("realtime-hello.message.json"))); +const extension = fixture("printing-realtime-extension.discovery.json"); +assert(valid("PrintingRealtimeExtensionDiscovery", extension), ajv.errorsText()); +assert.equal(extension["version"], "2.0.0"); +assert(valid("PrintingRealtimeExtensionDiscovery", { ...extension, version: "2.7.3" })); +for(const version of ["1.0.0", "1.1.0", "3.0.0", "2.0", "2.01.0", "2.0.0-rc.1", null]) { - assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, version }), 'Only the compatible stable native major is accepted.'); + assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, version }), "Only the compatible stable native major is accepted."); } -assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, name: 'another-extension' })); -assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, pathPrefix: '/printing/v1' })); +assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, name: "another-extension" })); +assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, pathPrefix: "/printing/v1" })); /******************************************************************************* ** Cross-field policy relationships require semantic checks beyond JSON Schema. *******************************************************************************/ +/** @param {typeof settings} policy @param {typeof hello.payload.limits} limits @returns {void} */ function assertPolicy(policy, limits) { - assert(valid('RealtimeSettings', policy), ajv.errorsText()); - assert(valid('RealtimeClientLimits', limits), ajv.errorsText()); - assert(policy.maxQueuedFrameBytes >= policy.maxFrameBytes, 'Frame queue must hold one permitted message.'); - assert(policy.maxStagedBytes >= Math.max(policy.maxPdfBytes, policy.maxZplBytes), 'Staging must admit one permitted document.'); + assert(valid("RealtimeSettings", policy), ajv.errorsText()); + assert(valid("RealtimeClientLimits", limits), ajv.errorsText()); + assert(policy.maxQueuedFrameBytes >= policy.maxFrameBytes, "Frame queue must hold one permitted message."); + assert(policy.maxStagedBytes >= Math.max(policy.maxPdfBytes, policy.maxZplBytes), "Staging must admit one permitted document."); assert(policy.downloadTimeoutMillis >= policy.downloadConnectTimeoutMillis); assert(policy.downloadTimeoutMillis >= policy.downloadIdleTimeoutMillis); assert(policy.heartbeatTimeoutSeconds >= 2 * policy.heartbeatIntervalSeconds); assert(policy.reconnectMaxDelayMillis >= policy.reconnectMinDelayMillis); - for(const name of schema.$defs.RealtimeClientLimits.required) + for(const name of /** @type {(keyof typeof hello.payload.limits)[]} */ (schema.$defs.RealtimeClientLimits.required)) { assert(policy[name] <= limits[name], `${name}: policy exceeds declared client capability`); } } assertPolicy(settings, hello.payload.limits); -assert.equal(settings.maxConcurrentDownloads, 1, 'Initial deployment defaults to one configurable download lane.'); +assert.equal(settings.maxConcurrentDownloads, 1, "Initial deployment defaults to one configurable download lane."); assert.equal(settings.maxCopies, 100); assert.equal(settings.nativeSubmissionTimeoutMillis, 120000); -for(const [name, rule] of Object.entries(schema.$defs.RealtimeSettings.properties)) +for(const [name, rule] of Object.entries(/** @type {Record} */ (schema.$defs.RealtimeSettings.properties))) { - if(rule.default !== undefined) assert.equal(settings[name], rule.default, `${name}: fixture/schema default mismatch`); + if(rule.default !== undefined) + { + assert.equal(settings[/** @type {keyof typeof settings} */ (name)], rule.default, `${name}: fixture/schema default mismatch`); + } + /** @type {Partial} */ const missing = { ...settings }; - delete missing[name]; - assert(!valid('RealtimeSettings', missing), `${name}: missing value cannot inherit a default`); + // Negative evidence deliberately removes each required schema field. + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete missing[/** @type {keyof typeof settings} */ (name)]; + assert(!valid("RealtimeSettings", missing), `${name}: missing value cannot inherit a default`); for(const value of [rule.minimum - 1, rule.maximum + 1, 1.5, null]) { - assert(!valid('RealtimeSettings', { ...settings, [name]: value }), `${name}: invalid bound/type accepted`); + assert(!valid("RealtimeSettings", { ...settings, [name]: value }), `${name}: invalid bound/type accepted`); } } for(const change of [ @@ -64,50 +74,50 @@ for(const change of [ { maxStagedBytes: 1 }, { downloadTimeoutMillis: 1000 }, { heartbeatTimeoutSeconds: 10 }, - { reconnectMinDelayMillis: 60000, reconnectMaxDelayMillis: 1000 } + { reconnectMinDelayMillis: 60000, reconnectMaxDelayMillis: 1000 }, ]) { assert.throws(() => assertPolicy({ ...settings, ...change }, hello.payload.limits)); } -for(const name of schema.$defs.RealtimeClientLimits.required) +for(const name of /** @type {(keyof typeof hello.payload.limits)[]} */ (schema.$defs.RealtimeClientLimits.required)) { - assert.throws(() => assertPolicy(settings, { ...hello.payload.limits, [name]: settings[name] - 1 }), name); + assert.throws(() => assertPolicy(settings, { ...hello.payload.limits, [name]: settings[/** @type {keyof typeof settings} */ (name)] - 1 }), name); } assertPolicy({ ...settings, maxConcurrentDownloads: 16, maxConcurrentPrints: 16, maxCopies: 32767 }, hello.payload.limits); -assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, capabilities: [] } })); -assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, capabilities: ['CANCEL_SPOOL'] } })); -assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, limits: {} } })); -assert(!valid('RealtimeHello', { ...hello, messageId: 'hello:unsafe-prefix' })); -assert(!valid('RealtimeHello', { ...hello, token: 'not-a-real-token' })); -assert(!valid('RealtimeClientMessage', { type: 'RUN_SHELL', messageId: 'unsupported-1', payload: {} })); +assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, capabilities: [] } })); +assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, capabilities: ["CANCEL_SPOOL"] } })); +assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, limits: {} } })); +assert(!valid("RealtimeHello", { ...hello, messageId: "hello:unsafe-prefix" })); +assert(!valid("RealtimeHello", { ...hello, token: "not-a-real-token" })); +assert(!valid("RealtimeClientMessage", { type: "RUN_SHELL", messageId: "unsupported-1", payload: {} })); -const clientFixtures = ['hello', 'state-applied', 'job-event', 'command-receipt', 'heartbeat', 'inventory', 'ping']; -const serverFixtures = ['welcome', 'desired-state', 'job-available', 'command', 'settings', 'ack', 'error', 'pong']; -for(const [files, accepted, rejected] of [ - [clientFixtures, 'RealtimeClientMessage', 'RealtimeServerMessage'], - [serverFixtures, 'RealtimeServerMessage', 'RealtimeClientMessage'] -]) +const clientFixtures = ["hello", "state-applied", "job-event", "command-receipt", "heartbeat", "inventory", "ping"]; +const serverFixtures = ["welcome", "desired-state", "job-available", "command", "settings", "ack", "error", "pong"]; +for(const [files, accepted, rejected] of /** @type {[string[],string,string][]} */ ([ + [clientFixtures, "RealtimeClientMessage", "RealtimeServerMessage"], + [serverFixtures, "RealtimeServerMessage", "RealtimeClientMessage"], +])) { for(const name of files) { const message = fixture(`realtime-${name}.message.json`); assert(valid(accepted, message), `${name}: ${ajv.errorsText()}`); assert(!valid(rejected, message), `${name}: wrong-direction frame accepted`); - assert(Buffer.byteLength(JSON.stringify(message), 'utf8') <= settings.maxFrameBytes); + assert(Buffer.byteLength(JSON.stringify(message), "utf8") <= settings.maxFrameBytes); const missing = { ...message }; - delete missing.payload; + delete missing["payload"]; assert(!valid(accepted, missing), `${name}: missing payload accepted`); - assert(!valid(accepted, { ...message, contentUrl: 'https://unrelated.invalid/document' })); + assert(!valid(accepted, { ...message, contentUrl: "https://unrelated.invalid/document" })); } } -const event = fixture('realtime-job-event.message.json'); -assert.deepEqual(fixture('realtime-job-event-replay.message.json'), event, 'Lost ACK replay preserves the exact envelope.'); -assert.equal(fixture('realtime-ack.message.json').payload.acknowledgedMessageId, event.messageId); -assert.equal(fixture('realtime-error.message.json').messageId, event.messageId, 'Frame errors retain request correlation.'); -const largeJob = fixture('realtime-large-pdf.response.json'); -assert(valid('PrintJob', largeJob), ajv.errorsText()); +const event = fixture("realtime-job-event.message.json"); +assert.deepEqual(fixture("realtime-job-event-replay.message.json"), event, "Lost ACK replay preserves the exact envelope."); +assert.equal((/** @type {typeof import("../fixtures/realtime-ack.message.json")} */ (/** @type {unknown} */ (fixture("realtime-ack.message.json")))).payload.acknowledgedMessageId, event["messageId"]); +assert.equal(fixture("realtime-error.message.json")["messageId"], event["messageId"], "Frame errors retain request correlation."); +const largeJob = /** @type {typeof import("../fixtures/realtime-large-pdf.response.json")} */ (/** @type {unknown} */ (fixture("realtime-large-pdf.response.json"))); +assert(valid("PrintJob", largeJob), ajv.errorsText()); assert.equal(largeJob.contentLength, settings.maxPdfBytes); assert(largeJob.contentLength > 20 * 1024 * 1024); -assert(valid('PrintOptions', { copies: 32767 })); -assert(!valid('PrintOptions', { copies: 32768 })); -console.log('Realtime directional frames, defaults/ranges, policy/ceiling refusal and exact replay fixtures passed; runtime concurrency acceptance remains consumer-owned.'); +assert(valid("PrintOptions", { copies: 32767 })); +assert(!valid("PrintOptions", { copies: 32768 })); +console.log("Realtime directional frames, defaults/ranges, policy/ceiling refusal and exact replay fixtures passed; runtime concurrency acceptance remains consumer-owned."); diff --git a/tools/verify-schema.mjs b/tools/verify-schema.mjs index 64b0e78..40a454a 100644 --- a/tools/verify-schema.mjs +++ b/tools/verify-schema.mjs @@ -1,16 +1,17 @@ // Copyright 2026 Kingsrook, LLC // SPDX-License-Identifier: Apache-2.0 -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import Ajv2020 from 'ajv/dist/2020.js'; -import addFormats from 'ajv-formats'; -import { fixtureTypes } from './fixture-types.mjs'; -import './verify-realtime.mjs'; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { fixtureTypes } from "./fixture-types.mjs"; +import "./verify-realtime.mjs"; -const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8')); +const schema = JSON.parse(readFileSync(new URL("../schemas/protocol.schema.json", import.meta.url), "utf8")); const ajv = new Ajv2020({ strict: true, allErrors: true }); -addFormats(ajv); +// ajv-formats is callable CommonJS; its NodeNext declaration exposes a namespace. +/** @type {import("ajv-formats").FormatsPlugin} */ (/** @type {unknown} */ (addFormats))(ajv); assert(ajv.validateSchema(schema), ajv.errorsText()); ajv.addSchema(schema); @@ -21,91 +22,94 @@ for(const name of Object.keys(schema.$defs)) } for(const [file, type] of Object.entries(fixtureTypes)) { - const value = JSON.parse(readFileSync(new URL(`../fixtures/${file}`, import.meta.url), 'utf8')); + const value = JSON.parse(readFileSync(new URL(`../fixtures/${file}`, import.meta.url), "utf8")); const validate = ajv.compile({ $ref: `${schema.$id}#/$defs/${type}` }); assert(validate(value), `${file}: ${ajv.errorsText(validate.errors)}`); } const validateTest = ajv.compile({ $ref: `${schema.$id}#/$defs/DeviceTestPrintRequest` }); -const testRequest = { printerId: 'printer-zebra', format: 'ZPL', requestId: 'test-1' }; -assert(validateTest(testRequest), 'Existing requests without a profile remain valid.'); -assert(validateTest({ ...testRequest, testProfile: 'LABEL_4X6_300' })); -assert(!validateTest({ ...testRequest, testProfile: 'LETTER' })); +const testRequest = { printerId: "printer-zebra", format: "ZPL", requestId: "test-1" }; +assert(validateTest(testRequest), "Existing requests without a profile remain valid."); +assert(validateTest({ ...testRequest, testProfile: "LABEL_4X6_300" })); +assert(!validateTest({ ...testRequest, testProfile: "LETTER" })); assert(!validateTest({ ...testRequest, testProfile: null })); -assert(!validateTest({ format: 'PDF' })); +assert(!validateTest({ format: "PDF" })); const validatePrinters = ajv.compile({ $ref: `${schema.$id}#/$defs/PrinterList` }); -const printerPage = JSON.parse(readFileSync(new URL('../fixtures/printers-page.response.json', import.meta.url), 'utf8')); +/** @type {typeof import("../fixtures/printers-page.response.json")} */ +const printerPage = JSON.parse(readFileSync(new URL("../fixtures/printers-page.response.json", import.meta.url), "utf8")); assert(validatePrinters(printerPage)); -assert(validatePrinters({ printers: printerPage.printers }), 'Older responses without a cursor remain valid.'); -assert(validatePrinters({ ...printerPage, nextCursor: 'opaque-server-cursor' })); +assert(validatePrinters({ printers: printerPage.printers }), "Older responses without a cursor remain valid."); +assert(validatePrinters({ ...printerPage, nextCursor: "opaque-server-cursor" })); assert(!validatePrinters({ ...printerPage, nextCursor: 17 })); -assert(!validatePrinters({ ...printerPage, nextCursor: 'x'.repeat(12001) })); +assert(!validatePrinters({ ...printerPage, nextCursor: "x".repeat(12001) })); assert(!validatePrinters({ printers: Array(201).fill(printerPage.printers[0]) })); -const control = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8')); +/** @param {string} name @returns {Record} */ +const control = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), "utf8")); +/** @param {string} type @param {unknown} value @returns {boolean|Promise} */ const validControl = (type, value) => ajv.validate(`${schema.$id}#/$defs/${type}`, value); -assert(!validControl('PrintingSessionIdentity', { tenantId: 'test-tenant' }), 'Display or device identity cannot replace the authoritative principal.'); -assert(!validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: '' })); -assert(!validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: 'test-tenant', deviceId: null })); -assert(validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: 'test-tenant', futureResponseField: true })); -const action = control('action-clear-queue.request.json'); -assert(validControl('AdminDeviceActionRequest', action)); +assert(!validControl("PrintingSessionIdentity", { tenantId: "test-tenant" }), "Display or device identity cannot replace the authoritative principal."); +assert(!validControl("PrintingSessionIdentity", { principalId: "test-operator", tenantId: "" })); +assert(!validControl("PrintingSessionIdentity", { principalId: "test-operator", tenantId: "test-tenant", deviceId: null })); +assert(validControl("PrintingSessionIdentity", { principalId: "test-operator", tenantId: "test-tenant", futureResponseField: true })); +const action = control("action-clear-queue.request.json"); +assert(validControl("AdminDeviceActionRequest", action)); const withoutIssuedAt = { ...action }; -delete withoutIssuedAt.issuedAt; -assert(!validControl('AdminDeviceActionRequest', withoutIssuedAt), 'New controls require their durable freshness timestamp.'); +delete withoutIssuedAt["issuedAt"]; +assert(!validControl("AdminDeviceActionRequest", withoutIssuedAt), "New controls require their durable freshness timestamp."); for(const invalid of [ - { issuedAt: '2026-10-31T16:00:00-05:00' }, + { issuedAt: "2026-10-31T16:00:00-05:00" }, { issuedAt: null }, - { action: 'RESTART_SERVICE' }, - { action: 'CANCEL_SPOOL' }, - { jobId: 'job-example-1' }, - { spoolerJobId: '17' }, - { queueName: 'Some other queue' }, - { command: 'shell' }, - { requestId: '../device' } + { action: "RESTART_SERVICE" }, + { action: "CANCEL_SPOOL" }, + { jobId: "job-example-1" }, + { spoolerJobId: "17" }, + { queueName: "Some other queue" }, + { command: "shell" }, + { requestId: "../device" }, ]) { - assert(!validControl('AdminDeviceActionRequest', { ...action, ...invalid }), JSON.stringify(invalid)); + assert(!validControl("AdminDeviceActionRequest", { ...action, ...invalid }), JSON.stringify(invalid)); } -const poll = control('control-poll.request.json'); -assert(!validControl('DeviceControlPollRequest', { ...poll, controlVersion: 2 })); -assert(!validControl('DeviceControlPollRequest', { ...poll, capabilities: ['CANCEL_SPOOL', 'CANCEL_SPOOL'] })); -assert(!validControl('DeviceControlPollRequest', { ...poll, capabilities: ['RESTART_SERVICE'] })); -assert(!validControl('DeviceControlPollRequest', { ...poll, lastAppliedRevision: -1 })); -assert(!validControl('DeviceControlPollRequest', { ...poll, lastAppliedRevision: 9007199254740992 })); -const pollResult = control('control-poll.response.json'); -assert(validControl('DeviceControlPollResponse', { ...pollResult, futureResponseField: true }), 'Additive response fields remain compatible.'); -assert(!validControl('DeviceControlPollResponse', { ...pollResult, commands: Array(21).fill(pollResult.commands[0]) })); -assert(!validControl('DeviceControlPollResponse', { ...pollResult, desiredState: { ...pollResult.desiredState, connectionState: 'REVOKED' } }), 'Revocation is credential invalidation, not reversible desired state.'); -assert(!validControl('DeviceCommand', { ...pollResult.commands[1], jobId: null })); -assert(!validControl('DeviceCommand', { ...pollResult.commands[0], jobId: 'job-example-1' })); -assert(!validControl('DeviceCommand', { ...pollResult.commands[0], action: 'CLEAR_QUEUE' }), 'Server queue clear is never an arbitrary client command.'); -const receipt = control('control-cancel-requested.receipt.json'); -assert(!validControl('DeviceCommandReceipt', { ...receipt, outcome: 'PRINT_RECALLED' })); -assert(!validControl('DeviceCommandReceipt', { ...receipt, message: 'x'.repeat(1025) })); -assert(!validControl('DeviceCommandReceipt', { ...receipt, completedAt: 'not-a-date' })); -assert(!validControl('DeviceCommandReceipt', { ...receipt, jobId: 'other-job' }), 'Receipt cannot override its route-bound command target.'); -const history = control('control-commands-page.response.json'); -assert(!validControl('DeviceActionList', { ...history, commands: Array(201).fill(history.commands[0]) })); -assert(!validControl('DeviceActionList', { ...history, nextCursor: 'x'.repeat(12001) })); +const poll = control("control-poll.request.json"); +assert(!validControl("DeviceControlPollRequest", { ...poll, controlVersion: 2 })); +assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["CANCEL_SPOOL", "CANCEL_SPOOL"] })); +assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["RESTART_SERVICE"] })); +assert(!validControl("DeviceControlPollRequest", { ...poll, lastAppliedRevision: -1 })); +assert(!validControl("DeviceControlPollRequest", { ...poll, lastAppliedRevision: 9007199254740992 })); +const pollResult = /** @type {typeof import("../fixtures/control-poll.response.json")} */ (/** @type {unknown} */ (control("control-poll.response.json"))); +assert(validControl("DeviceControlPollResponse", { ...pollResult, futureResponseField: true }), "Additive response fields remain compatible."); +assert(!validControl("DeviceControlPollResponse", { ...pollResult, commands: Array(21).fill(pollResult.commands[0]) })); +assert(!validControl("DeviceControlPollResponse", { ...pollResult, desiredState: { ...pollResult.desiredState, connectionState: "REVOKED" } }), "Revocation is credential invalidation, not reversible desired state."); +assert(!validControl("DeviceCommand", { ...pollResult.commands[1], jobId: null })); +assert(!validControl("DeviceCommand", { ...pollResult.commands[0], jobId: "job-example-1" })); +assert(!validControl("DeviceCommand", { ...pollResult.commands[0], action: "CLEAR_QUEUE" }), "Server queue clear is never an arbitrary client command."); +const receipt = control("control-cancel-requested.receipt.json"); +assert(!validControl("DeviceCommandReceipt", { ...receipt, outcome: "PRINT_RECALLED" })); +assert(!validControl("DeviceCommandReceipt", { ...receipt, message: "x".repeat(1025) })); +assert(!validControl("DeviceCommandReceipt", { ...receipt, completedAt: "not-a-date" })); +assert(!validControl("DeviceCommandReceipt", { ...receipt, jobId: "other-job" }), "Receipt cannot override its route-bound command target."); +const history = /** @type {typeof import("../fixtures/control-commands-page.response.json")} */ (/** @type {unknown} */ (control("control-commands-page.response.json"))); +assert(!validControl("DeviceActionList", { ...history, commands: Array(201).fill(history.commands[0]) })); +assert(!validControl("DeviceActionList", { ...history, nextCursor: "x".repeat(12001) })); console.log(`Compiled ${Object.keys(schema.$defs).length} JSON Schema 2020-12 definitions; ${Object.keys(fixtureTypes).length} fixtures passed full schema validation.`); -const media = control('printer-media-profile.response.json'); -for(const invalid of [{ dpiX: 0 }, { widthMicrons: 0 }, { revision: 'not-a-revision' }, { applyMode: 'CHANGE_STOCK' }]) +const media = control("printer-media-profile.response.json"); +for(const invalid of [{ dpiX: 0 }, { widthMicrons: 0 }, { revision: "not-a-revision" }, { applyMode: "CHANGE_STOCK" }]) { - assert(!validControl('PrinterMediaProfile', { ...media, ...invalid })); + assert(!validControl("PrinterMediaProfile", { ...media, ...invalid })); } -const selection = control('media-profile-options.request.json'); -assert(!validControl('PrintOptions', { ...selection, paperWidthMm: 100, paperHeightMm: 150 })); -assert(!validControl('PrinterMediaSelection', { ...selection.mediaProfile, futureOption: true })); +const selection = /** @type {typeof import("../fixtures/media-profile-options.request.json")} */ (/** @type {unknown} */ (control("media-profile-options.request.json"))); +assert(!validControl("PrintOptions", { ...selection, paperWidthMm: 100, paperHeightMm: 150 })); +assert(!validControl("PrinterMediaSelection", { ...selection.mediaProfile, futureOption: true })); -const operatorRequest = control('operator-submit.request.json'); -for(const invalid of [{ requestId: 'x'.repeat(129) }, { requestId: 'echo/scan' }, { actorId: 'forged' }, { tenantId: 'forged' }]) +const operatorRequest = control("operator-submit.request.json"); +for(const invalid of [{ requestId: "x".repeat(129) }, { requestId: "echo/scan" }, { actorId: "forged" }, { tenantId: "forged" }]) { - assert(!validControl('OperatorSubmitJobRequest', { ...operatorRequest, ...invalid })); + assert(!validControl("OperatorSubmitJobRequest", { ...operatorRequest, ...invalid })); } -assert(validControl('OperatorSubmitJobRequest', { ...operatorRequest, requestId: 'x'.repeat(128) })); +assert(validControl("OperatorSubmitJobRequest", { ...operatorRequest, requestId: "x".repeat(128) })); -const catalogEntry = control('operator-printers.response.json').printers[0]; -assert(validControl('OperatorPrinter', { ...catalogEntry, deviceName: 'x'.repeat(1024) })); -assert(!validControl('OperatorPrinter', { ...catalogEntry, deviceName: 'x'.repeat(1025) })); -assert(validControl('PrinterMediaProfile', { ...media, id: 'x'.repeat(128), futureMetadata: { supported: true } })); -assert(!validControl('PrinterMediaProfile', { ...media, id: 'x'.repeat(129) })); +const catalogEntry = (/** @type {typeof import("../fixtures/operator-printers.response.json")} */ (/** @type {unknown} */ (control("operator-printers.response.json")))).printers[0]; +assert(validControl("OperatorPrinter", { ...catalogEntry, deviceName: "x".repeat(1024) })); +assert(!validControl("OperatorPrinter", { ...catalogEntry, deviceName: "x".repeat(1025) })); +assert(validControl("PrinterMediaProfile", { ...media, id: "x".repeat(128), futureMetadata: { supported: true } })); +assert(!validControl("PrinterMediaProfile", { ...media, id: "x".repeat(129) })); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3a84786 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2024" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": true, + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "newLine": "lf", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": true, + "noEmit": true, + "types": [ + "node" + ] + }, + "include": [ + "tools/**/*.mjs", + "eslint.config.mjs" + ], + "exclude": [ + "node_modules", + "dist", + "build", + "coverage" + ] +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..38ea416 --- /dev/null +++ b/uv.lock @@ -0,0 +1,78 @@ +version = 1 +revision = 5 +requires-python = ">=3.13" + +[[package]] +name = "nodeenv" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/99/d27cfc9518f49e4cbc25c683ec7cf8704a9d31311341c291c0e78ddf791b/nodeenv-1.12.0.tar.gz", hash = "sha256:4ea7f88339310afebc3253f2c7a91b94bacf97d73a36a5c108962abdfaca501e", size = 131292, upload-time = "2026-10-31T19:03:18.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/6a/8a9ac7d5e2f7cf53c459bfef90ebc17a2f8cd54b665af96799d4c534840e/nodeenv-1.12.0-py3-none-any.whl", hash = "sha256:80644d65c10cc2be12e3e4b54b3367ff288c7ad24ae75c2dcc65454fc7449d4d", size = 39442, upload-time = "2026-10-31T19:03:17.427Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + +[[package]] +name = "qqq-print-protocol-script-quality" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = "==1.1.414" }, + { name = "ruff", specifier = "==0.17.3" }, +] + +[[package]] +name = "ruff" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] -- 2.54.0 From 7ed498911452944c6f8a655cb47bc55cb3c389b5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 15:08:23 -0600 Subject: [PATCH 2/7] fix(tooling): use official Pyright and patched minimatch Keep strict Python checking at 1.1.414 using Microsoft's npm package, removing the unnecessary Python wrapper and its bootstrap dependencies. Pin minimatch 11.0.1 for the checked JavaScript tools and preserve actual negative quality probes and all license/security checks. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: James Maes --- .github/scripts/check-script-quality.py | 14 +++++-- .github/scripts/test-script-quality.py | 10 +++++ CONTRIBUTING.md | 4 +- package-lock.json | 49 +++++++++++++++++++++++-- package.json | 6 ++- pyproject.toml | 2 +- uv.lock | 37 +------------------ 7 files changed, 75 insertions(+), 47 deletions(-) diff --git a/.github/scripts/check-script-quality.py b/.github/scripts/check-script-quality.py index 0c9549b..18385f0 100644 --- a/.github/scripts/check-script-quality.py +++ b/.github/scripts/check-script-quality.py @@ -4,6 +4,7 @@ """Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools.""" import json +import shutil import subprocess import sys from pathlib import Path, PurePosixPath @@ -13,6 +14,8 @@ PYTHON_SUFFIXES = (".py", ".pyi", ".pyw") SCRIPT_PATTERN = ".github/scripts/*.py" PYRIGHT_CONFIG = ".github" JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx") +# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`. +PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js" def tracked_files(root: Path) -> list[str]: @@ -51,9 +54,14 @@ def javascript_scope(root: Path, tracked: list[str]) -> list[str]: def run_pyright(root: Path, config: str) -> dict[str, Any]: - """Run strict Pyright with JSON output and return its full report.""" - # Fixed interpreter module invocation; the config is an argument, never shell input. - completed = subprocess.run([sys.executable, "-m", "pyright", "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed interpreter and module; no shell. + """Run the locked npm Pyright through Node with JSON output and return its full report.""" + node = shutil.which("node") + if node is None: + raise ValueError("Node.js is required to run the locked Pyright") + if not PYRIGHT_ENTRY.is_file(): + raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})") + # Resolved Node and locked entry script; the config is an argument, never shell input. + completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell. try: report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :]) int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing. diff --git a/.github/scripts/test-script-quality.py b/.github/scripts/test-script-quality.py index b488e71..71a12d9 100644 --- a/.github/scripts/test-script-quality.py +++ b/.github/scripts/test-script-quality.py @@ -13,6 +13,7 @@ import unittest from pathlib import Path from types import ModuleType from typing import Any, override +from unittest import mock sys.dont_write_bytecode = True @@ -171,6 +172,15 @@ class PyrightProbeTests(unittest.TestCase): strict = guard.run_pyright(root, ".") self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]}) + def test_missing_locked_pyright_or_node_is_rejected(self) -> None: + """Reject a missing locked Pyright entry or Node instead of silently skipping the type gate.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + with mock.patch.object(guard, "PYRIGHT_ENTRY", root / "missing.js"), self.assertRaisesRegex(ValueError, "Locked Pyright is missing"): + guard.run_pyright(root, ".") + with mock.patch.object(guard.shutil, "which", return_value=None), self.assertRaisesRegex(ValueError, "Node.js is required"): + guard.run_pyright(root, ".") + def test_zero_analyzed_files_cannot_pass(self) -> None: """Reject an empty include and a hidden-directory root config in the wrapper.""" with tempfile.TemporaryDirectory() as name: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28ae411..5f20f03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Keep wire names stable within `/printing/v1`. Optional response fields may be ad ## Script quality -Owned scripts follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (configs adapted from its `python` and `typescript` profiles): three-space indentation, Ruff 0.17.3 format/lint and strict Pyright 1.1.414 for `.github/scripts/*.py`, and ESLint 10 with Stylistic (Allman) plus strict `tsc` over JSDoc for `tools/**/*.mjs` and `eslint.config.mjs`. Restore with `uv sync --locked` and `npm ci --ignore-scripts`, then run: +Owned scripts follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (configs adapted from its `python` and `typescript` profiles): three-space indentation, Ruff 0.17.3 format/lint (locked in `uv.lock`) and strict Pyright 1.1.414 (Microsoft's npm package, locked in `package-lock.json` and run through Node) for `.github/scripts/*.py`, and ESLint 10 with Stylistic (Allman) plus strict `tsc` over JSDoc for `tools/**/*.mjs` and `eslint.config.mjs`. Restore with `uv sync --locked` and `npm ci --ignore-scripts`, then run: ```text uv run --locked --no-sync python .github/scripts/test-script-quality.py @@ -21,4 +21,4 @@ npm run typecheck npm run lint ``` -`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or JavaScript outside the `tsconfig.json` include list, and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print`/`console.log` only in `check-*.py` and `tools/`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. +`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or JavaScript outside the `tsconfig.json` include list, and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print`/`console.log` only in `check-*.py` and `tools/`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. `package.json` overrides `minimatch` to 11.0.1, the patched release for [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (affected before 11.0.1); keep it until ESLint's own dependency range includes a patched release. diff --git a/package-lock.json b/package-lock.json index 2f7997f..221952b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "ajv": "8.20.0", "ajv-formats": "3.0.1", "eslint": "10.14.0", + "pyright": "1.1.414", "typescript": "6.0.3", "typescript-eslint": "8.72.1" }, @@ -240,6 +241,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", + "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@keyv/bigmap": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", @@ -1019,6 +1027,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1157,13 +1180,14 @@ } }, "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.1.tgz", + "integrity": "sha512-t+Vn6DX4wPCxS23y03C0uhF7S1jpi1GfqPrnlEipCJaA2ykiNbMkVlDutZtmvLfRcUN6R8AYVlzTsQrT4RZoLQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "@isaacs/nonrecursive-glob-match": "^1.0.1", + "brace-expansion": "^5.0.12" }, "engines": { "node": "18 || 20 || >=22" @@ -1289,6 +1313,23 @@ "node": ">=6" } }, + "node_modules/pyright": { + "version": "1.1.414", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.414.tgz", + "integrity": "sha512-FPZZb51jepDX4eP7TEYDeNFtmE3WgwkkEcJpvH3/QmUSsj0EAy3LXu+xB4T/FWDejtsXlCfFr/rbWFjwuwuXww==", + "dev": true, + "license": "MIT", + "bin": { + "pyright": "index.js", + "pyright-langserver": "langserver.index.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", diff --git a/package.json b/package.json index 6191377..5eae796 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,11 @@ "@stylistic/eslint-plugin": "5.10.0", "@types/node": "24.10.1", "typescript-eslint": "8.72.1", - "eslint": "10.14.0" + "eslint": "10.14.0", + "pyright": "1.1.414" + }, + "overrides": { + "minimatch": "11.0.1" }, "packageManager": "npm@11.19.1" } diff --git a/pyproject.toml b/pyproject.toml index b63885a..60be067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ version = "0.0.0" requires-python = ">=3.13" [dependency-groups] -dev = ["ruff==0.17.3", "pyright==1.1.414"] +dev = ["ruff==0.17.3"] [tool.uv] package = false diff --git a/uv.lock b/uv.lock index 38ea416..0e343f6 100644 --- a/uv.lock +++ b/uv.lock @@ -2,28 +2,6 @@ version = 1 revision = 5 requires-python = ">=3.13" -[[package]] -name = "nodeenv" -version = "1.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/99/d27cfc9518f49e4cbc25c683ec7cf8704a9d31311341c291c0e78ddf791b/nodeenv-1.12.0.tar.gz", hash = "sha256:4ea7f88339310afebc3253f2c7a91b94bacf97d73a36a5c108962abdfaca501e", size = 131292, upload-time = "2026-10-31T19:03:18.847Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/6a/8a9ac7d5e2f7cf53c459bfef90ebc17a2f8cd54b665af96799d4c534840e/nodeenv-1.12.0-py3-none-any.whl", hash = "sha256:80644d65c10cc2be12e3e4b54b3367ff288c7ad24ae75c2dcc65454fc7449d4d", size = 39442, upload-time = "2026-10-31T19:03:17.427Z" }, -] - -[[package]] -name = "pyright" -version = "1.1.414" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, -] - [[package]] name = "qqq-print-protocol-script-quality" version = "0.0.0" @@ -31,17 +9,13 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ - { name = "pyright" }, { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = "==1.1.414" }, - { name = "ruff", specifier = "==0.17.3" }, -] +dev = [{ name = "ruff", specifier = "==0.17.3" }] [[package]] name = "ruff" @@ -67,12 +41,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, ] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] -- 2.54.0 From fa13e80e5bd783bdc59634eb0687214701d050b4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 16:32:36 -0600 Subject: [PATCH 3/7] feat: add four by one and four by two label test profiles Extend the existing coordinated printing contract and document PDF job paper without changing printer defaults or the wire version. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: James Maes --- README.md | 4 ++-- fixtures/device-test-profile-4x1.request.json | 1 + schemas/protocol.schema.json | 16 ++++++++++++++-- tools/fixture-types.mjs | 1 + tools/verify-fixtures.mjs | 9 +++++++++ tools/verify-schema.mjs | 4 ++++ 6 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 fixtures/device-test-profile-4x1.request.json diff --git a/README.md b/README.md index a7f80de..f811313 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ polling, and explicit receipts. These additions are included in the * `EnrollResponse`: `protocolVersion` (1), `deviceId`, `deviceToken`, `pollIntervalSeconds` (default 5). * `HeartbeatRequest`: `agentVersion`. Response: `serverTime`. * `PrinterInventory`: full snapshot of the queues selected for cloud exposure in the local Windows companion, in `printers`. Each printer contains stable `printerId`, exact Windows queue `name`, `driverName`, `portName`, `status`, `formats`. An empty list withdraws every queue. There is no new required exposure field: presence in the snapshot expresses local exposure. -* `PrintOptions`: `copies` defaults to 1; `scale` defaults to `ACTUAL_SIZE`. The schema reserves optional `paperWidthMm`, `paperHeightMm` (which must appear together), but the initial implementation rejects them until per-job paper configuration is implemented. Configure paper on the Windows queue for now. First implementation supports `ACTUAL_SIZE` and `FIT`. ZPL dimensions are encoded in its payload; copies must be applied exactly once. +* `PrintOptions`: `copies` defaults to 1; `scale` defaults to `ACTUAL_SIZE`. Optional `paperWidthMm`, `paperHeightMm` (which must appear together) request actual-size job paper for PDF jobs; ZPL encodes its dimensions in the payload, and a `mediaProfile` selection cannot be combined with them. Without them, the Windows queue default applies. First implementation supports `ACTUAL_SIZE` and `FIT`. ZPL dimensions are encoded in its payload; copies must be applied exactly once. * `SubmitJobRequest`: `deviceId`, `printerId`, `title`, `format`, `contentBase64`, `idempotencyKey`, optional `options`. * `PrintJob`: `jobId`, `printerId`, `title`, `format`, lowercase hex `contentSha256`, `contentLength` (decoded bytes), normalized `options`, `createdAt`, `status`. Management responses additionally include `deviceId` and may include latest `message` and `spoolerJobId`. * `JobEvent`: stable `eventId`, `status`, optional `message`, optional string `spoolerJobId`. @@ -161,7 +161,7 @@ Error body: `{ "code": "ERROR_CODE", "message": "Human-readable explanation" }`. ## Branded test-document profiles -`DeviceTestPrintRequest` optionally includes `testProfile`. PDF accepts `LETTER`, `A4`, or a `LABEL_4X6_*` profile (all label profiles produce the same 101.6 × 152.4 mm PDF). ZPL accepts `LABEL_4X6_203`, `LABEL_4X6_300`, or `LABEL_4X6_600`; the suffix selects the printer resolution in dots per inch. Use possible-value selectors in operator interfaces and match the loaded stock and device resolution. The profile defines document layout; it does not reconfigure Windows paper stock. +`DeviceTestPrintRequest` optionally includes `testProfile`. PDF accepts `LETTER`, `A4`, or a `LABEL_4X6_*`, `LABEL_4X2_*` or `LABEL_4X1_*` profile (all profiles of one height produce the same PDF: 101.6 × 152.4, 50.8 or 25.4 mm). ZPL accepts those label profiles with `_203`, `_300` or `_600`; the suffix selects the printer resolution in dots per inch. For the 4 × 2 and 4 × 1 PDFs the server also submits `options.paperWidthMm`/`paperHeightMm` (101.6 × 50.8 or 25.4) so the client sets actual-size job paper; they are never combined with `mediaProfile`. The 4 × 6 PDF and all ZPL labels carry no job paper. These profiles require an agent and server from the same unreleased 0.4 cohort; older agents reject job paper. Use possible-value selectors in operator interfaces and match the loaded stock and device resolution. The profile defines document layout; it does not reconfigure Windows paper stock. Requests that omit the field retain compatible defaults: PDF uses Letter, ZPL uses 4 × 6 at 203 dpi. An explicit null, unknown profile, or Letter/A4 with ZPL is invalid. A stable requestId protects transport retries; changing document media is a new operator request. Each action queues one test on each explicitly selected destination, subject to the existing exposure and approval checks. No discovery or connection event automatically prints. diff --git a/fixtures/device-test-profile-4x1.request.json b/fixtures/device-test-profile-4x1.request.json new file mode 100644 index 0000000..5a4b8df --- /dev/null +++ b/fixtures/device-test-profile-4x1.request.json @@ -0,0 +1 @@ +{"printerId":"printer-zebra","format":"ZPL","requestId":"operator-branded-test-4x1","testProfile":"LABEL_4X1_300"} diff --git a/schemas/protocol.schema.json b/schemas/protocol.schema.json index cbfec3c..ae7be6a 100644 --- a/schemas/protocol.schema.json +++ b/schemas/protocol.schema.json @@ -65,7 +65,13 @@ "enum": [ "LABEL_4X6_203", "LABEL_4X6_300", - "LABEL_4X6_600" + "LABEL_4X6_600", + "LABEL_4X2_203", + "LABEL_4X2_300", + "LABEL_4X2_600", + "LABEL_4X1_203", + "LABEL_4X1_300", + "LABEL_4X1_600" ] } } @@ -79,7 +85,13 @@ "A4", "LABEL_4X6_203", "LABEL_4X6_300", - "LABEL_4X6_600" + "LABEL_4X6_600", + "LABEL_4X2_203", + "LABEL_4X2_300", + "LABEL_4X2_600", + "LABEL_4X1_203", + "LABEL_4X1_300", + "LABEL_4X1_600" ] }, "DeviceRetryRequest": { diff --git a/tools/fixture-types.mjs b/tools/fixture-types.mjs index 89703ca..8c0a5ac 100644 --- a/tools/fixture-types.mjs +++ b/tools/fixture-types.mjs @@ -37,6 +37,7 @@ export const fixtureTypes = { 'printers-profile.request.json': 'PrinterInventory', 'device-test.request.json': 'DeviceTestPrintRequest', 'device-test-profile.request.json': 'DeviceTestPrintRequest', + 'device-test-profile-4x1.request.json': 'DeviceTestPrintRequest', 'device-retry.request.json': 'DeviceRetryRequest', 'create-enrollment.request.json': 'CreateEnrollmentRequest', 'enrollment.response.json': 'EnrollmentCode', diff --git a/tools/verify-fixtures.mjs b/tools/verify-fixtures.mjs index 1b5ec09..4d97f2f 100644 --- a/tools/verify-fixtures.mjs +++ b/tools/verify-fixtures.mjs @@ -122,6 +122,15 @@ assert.throws(() => validate({ ...brandedTest, testProfile: 'UNKNOWN' }, schema. assert.throws(() => validate({ ...brandedTest, testProfile: null }, schema.$defs.DeviceTestPrintRequest, 'nullTestProfile')); assert.throws(() => validate({ format: 'PDF' }, schema.$defs.DeviceTestPrintRequest, 'missingTestIdentity')); validate({ ...brandedTest, format: 'PDF', testProfile: 'A4' }, schema.$defs.DeviceTestPrintRequest, 'a4TestPage'); +for(const testProfile of ['LABEL_4X2_203', 'LABEL_4X2_300', 'LABEL_4X2_600', 'LABEL_4X1_203', 'LABEL_4X1_300', 'LABEL_4X1_600']) +{ + validate({ ...brandedTest, testProfile }, schema.$defs.DeviceTestPrintRequest, `zplShortLabel${testProfile}`); + validate({ ...brandedTest, format: 'PDF', testProfile }, schema.$defs.DeviceTestPrintRequest, `pdfShortLabel${testProfile}`); +} +assert.throws(() => validate({ ...brandedTest, testProfile: 'LABEL_4X3_300' }, schema.$defs.DeviceTestPrintRequest, 'unsupportedLabelHeight')); +// Short PDF labels use explicit actual-size job paper, never a conflicting media selection. +validate({ copies: 1, scale: 'ACTUAL_SIZE', paperWidthMm: 101.6, paperHeightMm: 25.4 }, schema.$defs.PrintOptions, 'fourByOneJobPaper'); +validate({ copies: 1, scale: 'ACTUAL_SIZE', paperWidthMm: 101.6, paperHeightMm: 50.8 }, schema.$defs.PrintOptions, 'fourByTwoJobPaper'); const clear = fixtures['action-clear-queue.request.json']; assert.throws(() => validate({ ...clear, issuedAt: undefined }, schema.$defs.AdminDeviceActionRequest, 'missingIssuedAt')); assert.throws(() => validate({ ...clear, jobId: 'job-example-1' }, schema.$defs.AdminDeviceActionRequest, 'clearCannotTargetSpool')); diff --git a/tools/verify-schema.mjs b/tools/verify-schema.mjs index 64b0e78..9f123f7 100644 --- a/tools/verify-schema.mjs +++ b/tools/verify-schema.mjs @@ -29,6 +29,10 @@ const validateTest = ajv.compile({ $ref: `${schema.$id}#/$defs/DeviceTestPrintRe const testRequest = { printerId: 'printer-zebra', format: 'ZPL', requestId: 'test-1' }; assert(validateTest(testRequest), 'Existing requests without a profile remain valid.'); assert(validateTest({ ...testRequest, testProfile: 'LABEL_4X6_300' })); +assert(validateTest({ ...testRequest, testProfile: 'LABEL_4X1_203' })); +assert(validateTest({ ...testRequest, testProfile: 'LABEL_4X2_600' })); +assert(validateTest({ ...testRequest, format: 'PDF', testProfile: 'LABEL_4X2_300' })); +assert(!validateTest({ ...testRequest, testProfile: 'LABEL_4X3_300' })); assert(!validateTest({ ...testRequest, testProfile: 'LETTER' })); assert(!validateTest({ ...testRequest, testProfile: null })); assert(!validateTest({ format: 'PDF' })); -- 2.54.0 From 8ccbcc7b1ed4ec3f98c364fe299116e2572f98e2 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 16:42:14 -0600 Subject: [PATCH 4/7] fix(ci): constrain the reviewed uri-js tooling license exception Permit only the approved dev-only transitive package while checking its exact version, lock integrity and installed license bytes; preserve all other license and vulnerability gates. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: James Maes --- .github/scripts/check-script-quality.py | 32 ++++++++++++++ .github/scripts/test-script-quality.py | 59 +++++++++++++++++++++++++ .github/workflows/pr-gates.yml | 6 +++ CONTRIBUTING.md | 2 +- 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/.github/scripts/check-script-quality.py b/.github/scripts/check-script-quality.py index 18385f0..fa3b4df 100644 --- a/.github/scripts/check-script-quality.py +++ b/.github/scripts/check-script-quality.py @@ -3,7 +3,9 @@ """Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools.""" +import hashlib import json +import re import shutil import subprocess import sys @@ -14,6 +16,12 @@ PYTHON_SUFFIXES = (".py", ".pyi", ".pyw") SCRIPT_PATTERN = ".github/scripts/*.py" PYRIGHT_CONFIG = ".github" JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx") +# EX-0015: the only npm package whose license classification the PR gate may exempt (the matcher ignores versions, so this guard pins it). +URI_JS_PURL = "pkg:npm/uri-js" +URI_JS_VERSION = "4.4.1" +URI_JS_INTEGRITY = "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==" +URI_JS_LICENSE_SHA256 = "0af366eff4c01ec147c9c61ea9e8ffad64a4294754c9d79355f3fd1b97cb2fb9" +PR_GATES = ".github/workflows/pr-gates.yml" # Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`. PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js" @@ -91,6 +99,29 @@ def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None: raise ValueError("Ruff " + " ".join(arguments) + " failed") +def check_uri_js_exception(root: Path) -> None: + """Allow the EX-0015 license exception only for the reviewed, unmodified, development-only uri-js.""" + packages: dict[str, dict[str, Any]] = json.loads((root / "package-lock.json").read_text(encoding="utf-8"))["packages"] + copies = [entry for path, entry in packages.items() if path.rsplit("node_modules/", 1)[-1] == "uri-js"] + if len(copies) != 1: + raise ValueError(f"EX-0015 covers exactly one locked uri-js copy, found {len(copies)}") + if copies[0].get("version") != URI_JS_VERSION or copies[0].get("integrity") != URI_JS_INTEGRITY or copies[0].get("dev") is not True: + raise ValueError(f"EX-0015 covers only development-only uri-js {URI_JS_VERSION} with the reviewed integrity") + root_entry = packages.get("", {}) + if "uri-js" in {*root_entry.get("dependencies", {}), *root_entry.get("devDependencies", {})}: + raise ValueError("EX-0015 covers uri-js only as a transitive tool dependency, not a direct one") + consumers = [path for path, entry in packages.items() if "uri-js" in entry.get("dependencies", {}) and entry.get("dev") is not True] + if consumers: + raise ValueError("uri-js is required by a non-development package: " + ", ".join(consumers)) + digest = hashlib.sha256((root / "node_modules" / "uri-js" / "LICENSE").read_bytes()).hexdigest() + if digest != URI_JS_LICENSE_SHA256: + raise ValueError(f"Installed uri-js LICENSE bytes differ from the reviewed EX-0015 copy ({digest})") + caller = [line for line in (root / PR_GATES).read_text(encoding="utf-8").splitlines() if not line.lstrip().startswith("#")] + listed = re.findall(r"pkg:npm/[^\s,]+", "\n".join(caller)) + if listed != [URI_JS_PURL]: + raise ValueError(f"EX-0015 permits only {URI_JS_PURL} in allow-dependencies-licenses, found {listed}") + + def main() -> None: """Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count.""" root = Path(__file__).resolve().parents[2] @@ -101,6 +132,7 @@ def main() -> None: run_ruff(root, ["check"], sources) report = run_pyright(root, PYRIGHT_CONFIG) check_pyright_report(report, len(sources)) + check_uri_js_exception(root) print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope") diff --git a/.github/scripts/test-script-quality.py b/.github/scripts/test-script-quality.py index 71a12d9..48673bb 100644 --- a/.github/scripts/test-script-quality.py +++ b/.github/scripts/test-script-quality.py @@ -234,5 +234,64 @@ class JavaScriptProbeTests(unittest.TestCase): self.assertIn("TS2322", bad.stdout) +URI_GATES = "jobs:\n pr:\n with:\n allow-dependencies-licenses: pkg:npm/uri-js\n" +URI_MIXED_GATES = "jobs:\n pr:\n with:\n allow-dependencies-licenses: >-\n pkg:nuget/Microsoft.WindowsAppSDK,\n pkg:npm/uri-js\n" + + +def uri_lock(version: str = "4.4.1", integrity: str | None = None, dev: bool = True, direct: bool = False, consumer_dev: bool = True) -> dict[str, Any]: + """Build a minimal package-lock shape with uri-js under an ESLint-like consumer.""" + root_entry: dict[str, Any] = {"devDependencies": {"uri-js": version} if direct else {"eslint": "10.14.0"}} + return {"packages": {"": root_entry, "node_modules/uri-js": {"version": version, "integrity": integrity or guard.URI_JS_INTEGRITY, "dev": dev}, "node_modules/eslint": {"dev": consumer_dev, "dependencies": {"uri-js": "^4.2.2"}}}} + + +def uri_project(root: Path, lock: dict[str, Any], gates: str = URI_GATES, license_bytes: bytes | None = None) -> None: + """Write a private project with the lock, installed LICENSE and PR caller that the guard inspects.""" + (root / "package-lock.json").write_text(json.dumps(lock), encoding="utf-8") + installed = root / "node_modules" / "uri-js" + installed.mkdir(parents=True) + installed.joinpath("LICENSE").write_bytes((ROOT / "node_modules" / "uri-js" / "LICENSE").read_bytes() if license_bytes is None else license_bytes) + workflows = root / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "pr-gates.yml").write_text("# EX-0015 pkg:npm/uri-js is cited here\n" + gates, encoding="utf-8") + + +class UriJsExceptionTests(unittest.TestCase): + """Allow the EX-0015 license exception only for the reviewed, unmodified, development-only uri-js.""" + + def test_reviewed_uri_js_with_exact_caller_is_accepted(self) -> None: + """Accept the reviewed package, installed license bytes and an exact caller list, mixed with NuGet entries.""" + for gates in (URI_GATES, URI_MIXED_GATES): + with tempfile.TemporaryDirectory() as name, self.subTest(gates=gates): + root = Path(name) + uri_project(root, uri_lock(), gates) + guard.check_uri_js_exception(root) + + def test_other_version_integrity_scope_or_consumer_is_rejected(self) -> None: + """Reject another version, integrity, non-dev or direct use, a non-dev consumer and a second copy.""" + duplicate = uri_lock() + duplicate["packages"]["node_modules/eslint/node_modules/uri-js"] = dict(duplicate["packages"]["node_modules/uri-js"]) + for lock in (uri_lock(version="4.4.2"), uri_lock(integrity="sha512-other"), uri_lock(dev=False), uri_lock(direct=True), uri_lock(consumer_dev=False), duplicate): + with tempfile.TemporaryDirectory() as name, self.subTest(lock=lock), self.assertRaisesRegex(ValueError, "uri-js"): + root = Path(name) + uri_project(root, lock) + guard.check_uri_js_exception(root) + + def test_modified_installed_license_is_rejected(self) -> None: + """Reject installed license bytes that differ from the reviewed copy.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + uri_project(root, uri_lock(), license_bytes=(ROOT / "node_modules" / "uri-js" / "LICENSE").read_bytes() + b"\n") + with self.assertRaisesRegex(ValueError, "LICENSE bytes differ"): + guard.check_uri_js_exception(root) + + def test_caller_must_list_only_uri_js(self) -> None: + """Reject a caller that lists no npm exception, another package, a versioned form or a duplicate.""" + for gates in ("jobs:\n pr:\n uses: shared\n", URI_GATES.rstrip() + ", pkg:npm/other\n", "jobs:\n pr:\n with:\n allow-dependencies-licenses: pkg:npm/other\n", URI_GATES.rstrip() + "@4.4.1\n", URI_GATES.rstrip() + ", pkg:npm/uri-js\n"): + with tempfile.TemporaryDirectory() as name, self.subTest(gates=gates), self.assertRaisesRegex(ValueError, "permits only"): + root = Path(name) + uri_project(root, uri_lock(), gates) + guard.check_uri_js_exception(root) + + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml index b43d1fa..5f22fc0 100644 --- a/.github/workflows/pr-gates.yml +++ b/.github/workflows/pr-gates.yml @@ -18,6 +18,12 @@ concurrency: jobs: pr: uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 + with: + # EX-0015: development-only uri-js 4.4.1 (ESLint transitive; BSD-2-Clause plus an authors' views sentence). + # https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0015 + # The matcher ignores versions; check-script-quality.py pins the version, lock integrity and installed + # license bytes and rejects any other pkg:npm entry here. + allow-dependencies-licenses: pkg:npm/uri-js permissions: contents: read # read the commits being merged pull-requests: read # read the pull request title and dependency diff diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f20f03..a4e3ff5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,4 +21,4 @@ npm run typecheck npm run lint ``` -`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or JavaScript outside the `tsconfig.json` include list, and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print`/`console.log` only in `check-*.py` and `tools/`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. `package.json` overrides `minimatch` to 11.0.1, the patched release for [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (affected before 11.0.1); keep it until ESLint's own dependency range includes a patched release. +`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or JavaScript outside the `tsconfig.json` include list, and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print`/`console.log` only in `check-*.py` and `tools/`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. `package.json` overrides `minimatch` to 11.0.1, the patched release for [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (affected before 11.0.1); keep it until ESLint's own dependency range includes a patched release. The only npm license exception, [EX-0015](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0015) for development-only `uri-js` 4.4.1, is enforced by `check-script-quality.py` (version, lock integrity, installed license bytes and the exact `pkg:npm/uri-js` caller entry); changing that package or the entry needs renewed approval. -- 2.54.0 From 42c0d249f81395f00408ec0f92638dcbbf5deb03 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 17:07:41 -0600 Subject: [PATCH 5/7] docs: record combined protocol verification results Record the passed schema, fixture, type, lint and bundle gates while keeping Gitea CI and deployment status separate. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: James Maes --- docs/SESSION-STATE.md | 12 ++++++------ docs/TODO.md | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 65ade03..0db4e22 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -12,12 +12,12 @@ is never combined with `mediaProfile`. The strictly typed tools keep every exist invalid-input check and add the short-label positive, unsupported-height and job-paper assertions. Expected totals are 76 schema definitions and 79 fixtures. -State: implemented in source. The F06 tooling gates passed on the base commit; the -merged-tree `npm run check`, `npm run typecheck` and `npm run lint` results are -recorded only after the requested post-merge run. Hosted CI, consumer pin updates, -installed-client acceptance and physical output are pending; nothing here claims -them. The consumer repositories pin this protocol by immutable commit after the -final protocol commit exists. +State: implemented and locally verified on the merged tree. `npm run check` passed +76 schema definitions and 79 fixtures, and `npm run typecheck`, `npm run lint` and +`npm run bundle` all exited 0. Hosted Gitea CI, consumer pin updates, installed-client +acceptance and physical output are pending; nothing here claims them. The consumer +repositories pin this protocol by immutable commit after the final protocol commit +exists. ## Echo printer media contract in progress (earlier checkpoint) diff --git a/docs/TODO.md b/docs/TODO.md index 443ca9d..b87a1fa 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,8 +8,8 @@ ## Release cohort integration (0.4.0-rc.1, unreleased) - [x] Integrate short-label (4 × 2 / 4 × 1) test profiles, fixture and job-paper assertions with the strictly typed tools in source. -- [ ] Record the merged-tree `npm run check` (expect 76 definitions / 79 fixtures), `npm run typecheck` and `npm run lint` results. -- [ ] Pin the final protocol commit in both consumers, then observe hosted CI. +- [x] Pass the merged-tree `npm run check` (76 definitions / 79 fixtures), `npm run typecheck`, `npm run lint` and `npm run bundle`. +- [ ] Pin the final protocol commit in both consumers, then observe hosted Gitea CI. - [ ] Complete installed client and physical short-label acceptance separately; schema checks do not establish them. ## Echo media integration -- 2.54.0 From 7cfae8baab7cc3b9d18f71749f59afcf0b999030 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 17:22:26 -0600 Subject: [PATCH 6/7] fix(ci): expose contract validation through the standard test script Run the existing schema and compatibility fixtures through npm test for the shared Gitea Node gate; contract, dependencies and lock integrity remain unchanged. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: James Maes --- CONTRIBUTING.md | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4e3ff5..cb8fefe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Use Node.js 24 or newer. Install the locked development dependencies with `npm ci`, then run `npm run check`. The runtime protocol has no JavaScript dependency; Ajv and ajv-formats are development-only JSON Schema validators. +Use Node.js 24 or newer. Install the locked development dependencies with `npm ci`, then run `npm run check` (`npm test` is the standard entry point for the same check). The runtime protocol has no JavaScript dependency; Ajv and ajv-formats are development-only JSON Schema validators. Make changes through a pull request. Describe the client/server behavior affected and update README, schema, fixtures and changelog together. Keep credentials, customer documents, printer addresses and machine-specific data out of fixtures. Samples must remain synthetic. diff --git a/package.json b/package.json index 5eae796..4e2b6e1 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "check": "node tools/verify-schema.mjs && node tools/verify-fixtures.mjs", + "test": "npm run check", "bundle": "node tools/bundle.mjs", "lint": "eslint . --max-warnings 0", "typecheck": "tsc --noEmit" -- 2.54.0 From fb6cc728acdde25b43e9cbd33c75cecdb79541f4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 18:57:37 -0600 Subject: [PATCH 7/7] ci: expose locked protocol quality entrypoint Run the existing script and source-header checks through the locked uv interpreter for the shared Node profile. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Codex --- CONTRIBUTING.md | 2 ++ package.json | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb8fefe..c76e3c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,4 +21,6 @@ npm run typecheck npm run lint ``` +`npm run quality` runs the repository's Python sequence in one step (`uv sync --locked`, both script-quality commands and both source-header commands); the shared Node CI calls it as its `quality-script`. It needs `uv` on `PATH` and `npm ci` first. + `check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or JavaScript outside the `tsconfig.json` include list, and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print`/`console.log` only in `check-*.py` and `tools/`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. `package.json` overrides `minimatch` to 11.0.1, the patched release for [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (affected before 11.0.1); keep it until ESLint's own dependency range includes a patched release. The only npm license exception, [EX-0015](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0015) for development-only `uri-js` 4.4.1, is enforced by `check-script-quality.py` (version, lock integrity, installed license bytes and the exact `pkg:npm/uri-js` caller entry); changing that package or the entry needs renewed approval. diff --git a/package.json b/package.json index 4e2b6e1..90a2e80 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test": "npm run check", "bundle": "node tools/bundle.mjs", "lint": "eslint . --max-warnings 0", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "quality": "uv sync --locked && uv run --locked --no-sync python .github/scripts/test-script-quality.py && uv run --locked --no-sync python .github/scripts/check-script-quality.py && uv run --locked --no-sync python .github/scripts/test-source-headers.py && uv run --locked --no-sync python .github/scripts/check-source-headers.py" }, "devDependencies": { "ajv": "8.20.0", -- 2.54.0