From a091afaff5ffc0f7ce93252f805ed19572677d3c Mon Sep 17 00:00:00 2001 From: Daniel A Martinez Julio Date: Fri, 12 Jun 2026 08:45:05 -0700 Subject: [PATCH 1/3] feat(SIG-107): add TypeScript compiler evidence --- .github/config/tool-manifest.json | 5 + .github/scripts/run_tsc.sh | 123 +++++++++++++ .github/scripts/sigilix_sarif_contract.py | 2 + .github/scripts/sigilix_sarif_test.py | 2 +- .github/scripts/tsc_to_sarif.py | 132 ++++++++++++++ .github/scripts/tsc_workflow_test.py | 208 ++++++++++++++++++++++ .github/workflows/ci.yml | 1 + .github/workflows/scan.yml | 14 ++ README.md | 4 +- 9 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/run_tsc.sh create mode 100644 .github/scripts/tsc_to_sarif.py create mode 100644 .github/scripts/tsc_workflow_test.py diff --git a/.github/config/tool-manifest.json b/.github/config/tool-manifest.json index 439131b..854b38a 100644 --- a/.github/config/tool-manifest.json +++ b/.github/config/tool-manifest.json @@ -10,6 +10,11 @@ "env": "ESLINT_ENABLED", "output": "eslint.sarif" }, + { + "id": "tsc", + "env": "TSC_ENABLED", + "output": "tsc.sarif" + }, { "id": "ruff", "env": "RUFF_ENABLED", diff --git a/.github/scripts/run_tsc.sh b/.github/scripts/run_tsc.sh new file mode 100644 index 0000000..f81895c --- /dev/null +++ b/.github/scripts/run_tsc.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESULT_CAP:?}" +: "${RUNNER_DIR:?}" +: "${RUNNER_TEMP:?}" +: "${SARIF_DIR:?}" +: "${SOURCE_DIR:?}" +: "${TYPESCRIPT_NPM_INTEGRITY:?}" +: "${TYPESCRIPT_VERSION:?}" + +SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd -P)" +RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)" + +raw="$SARIF_DIR/tsc.txt" +out="$SARIF_DIR/tsc.sarif" +tsc_install_dir="" +tsc_bin="" +tsc_package="" +tsc_version="" +configs_list="" + +mkdir -p "$SARIF_DIR" "$RUNNER_TEMP" +configs_list="$(mktemp "$RUNNER_TEMP/tsc-configs.XXXXXX")" + +cleanup_tsc() { + rm -f "$configs_list" + if [ -n "$tsc_install_dir" ]; then rm -rf "$tsc_install_dir"; fi +} +trap cleanup_tsc EXIT + +emit_empty_raw() { + : > "$raw" +} + +verify_package_integrity() { + local actual + actual="sha512-$(openssl dgst -sha512 -binary "$1" | openssl base64 -A)" + [ "$actual" = "$TYPESCRIPT_NPM_INTEGRITY" ] +} + +discover_tsconfigs() { + find -P . \ + \( -type d \( -name '.git' -o -name 'node_modules' -o -name 'dist' -o -name 'build' \ + -o -name 'coverage' -o -name '.next' -o -name 'out' -o -name '.venv' \ + -o -name 'vendor' -o -name '__pycache__' -o -name '.tox' -o -name '.mypy_cache' \ + -o -name '.pytest_cache' -o -name '.terraform' \) -prune \) -o \ + \( -type f \( -name 'tsconfig.json' -o -name 'tsconfig.*.json' \) -print0 \) +} + +cd "$SOURCE_DIR" +if ! discover_tsconfigs > "$configs_list"; then + echo "::warning::TypeScript config discovery failed - emitting empty TypeScript SARIF run." + emit_empty_raw +elif [[ ! "$TYPESCRIPT_VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo "::warning::TypeScript version must be a pinned x.y.z version - emitting empty TypeScript SARIF run." + emit_empty_raw +elif [[ ! "$TYPESCRIPT_NPM_INTEGRITY" =~ ^sha512-[A-Za-z0-9+/]+={0,2}$ ]]; then + echo "::warning::TypeScript package integrity must be a pinned sha512 value - emitting empty TypeScript SARIF run." + emit_empty_raw +else + configs=() + while IFS= read -r -d '' config; do + configs+=("$config") + done < "$configs_list" + + if [ "${#configs[@]}" -eq 0 ]; then + emit_empty_raw + else + tsc_can_scan=true + tsc_install_dir="$(mktemp -d "$RUNNER_TEMP/tsc-${TYPESCRIPT_VERSION}.XXXXXX")" + tsc_bin="$tsc_install_dir/node_modules/.bin/tsc" + if ! tsc_package="$(npm pack --silent --pack-destination "$tsc_install_dir" \ + --registry=https://registry.npmjs.org \ + "typescript@${TYPESCRIPT_VERSION}" | tail -n 1)"; then + echo "::warning::TypeScript package download failed - emitting empty TypeScript SARIF run." + tsc_can_scan=false + else + tsc_package="$tsc_install_dir/${tsc_package##*/}" + fi + + if [ "$tsc_can_scan" = true ] && [ ! -f "$tsc_package" ]; then + echo "::warning::TypeScript package tarball missing after download - emitting empty TypeScript SARIF run." + tsc_can_scan=false + elif [ "$tsc_can_scan" = true ] && ! verify_package_integrity "$tsc_package"; then + echo "::warning::TypeScript package integrity mismatch - emitting empty TypeScript SARIF run." + tsc_can_scan=false + elif [ "$tsc_can_scan" = true ] && ! npm install --silent --prefix "$tsc_install_dir" --ignore-scripts --omit=optional \ + --registry=https://registry.npmjs.org --no-audit --no-fund \ + "$tsc_package" >/dev/null; then + echo "::warning::TypeScript package install failed - emitting empty TypeScript SARIF run." + tsc_can_scan=false + elif [ "$tsc_can_scan" = true ] && [ ! -x "$tsc_bin" ]; then + echo "::warning::TypeScript compiler binary missing after install - emitting empty TypeScript SARIF run." + tsc_can_scan=false + elif [ "$tsc_can_scan" = true ] && ! tsc_version="$("$tsc_bin" --version 2>/dev/null)"; then + tsc_can_scan=false + elif [ "$tsc_can_scan" = true ]; then + tsc_version="$(printf '%s\n' "$tsc_version" | grep -Eo '^[^0-9]*[0-9]+[.][0-9]+[.][0-9]+' | grep -Eo '[0-9]+[.][0-9]+[.][0-9]+' | head -n 1 || true)" + if [ "$tsc_version" != "$TYPESCRIPT_VERSION" ]; then + tsc_can_scan=false + fi + fi + + if [ "$tsc_can_scan" = false ]; then + echo "::warning::TypeScript version mismatch or unavailable: expected ${TYPESCRIPT_VERSION}, got '${tsc_version:-unavailable}' - emitting empty TypeScript SARIF run." + emit_empty_raw + else + : > "$raw" + for config in "${configs[@]}"; do + tsc_exit=0 + "$tsc_bin" --project "$config" --noEmit --pretty false --skipLibCheck --noErrorTruncation >> "$raw" 2>&1 || tsc_exit=$? + if [ "$tsc_exit" -gt 2 ]; then + echo "::warning::TypeScript exited with code $tsc_exit for $config - results may be incomplete." + fi + printf '\n' >> "$raw" + done + fi + fi +fi + +python3 "$RUNNER_DIR/.github/scripts/tsc_to_sarif.py" "$raw" "$out" \ + --base-dir "$SOURCE_DIR" --cap "$RESULT_CAP" diff --git a/.github/scripts/sigilix_sarif_contract.py b/.github/scripts/sigilix_sarif_contract.py index e53a931..275b733 100644 --- a/.github/scripts/sigilix_sarif_contract.py +++ b/.github/scripts/sigilix_sarif_contract.py @@ -9,6 +9,7 @@ { "semgrep", "eslint", + "tsc", "ruff", "pylint", "knip", @@ -34,6 +35,7 @@ DEFAULT_TOOL_NAMES = { "semgrep": "Semgrep", "eslint": "ESLint", + "tsc": "TypeScript Compiler", "ruff": "Ruff", "pylint": "Pylint", "knip": "Knip", diff --git a/.github/scripts/sigilix_sarif_test.py b/.github/scripts/sigilix_sarif_test.py index 272c45a..47e0565 100644 --- a/.github/scripts/sigilix_sarif_test.py +++ b/.github/scripts/sigilix_sarif_test.py @@ -489,6 +489,7 @@ def test_contract_cli_attaches_metadata_for_new_native_language_tools(self): ALL_TOOL_OUTPUTS = { "semgrep": "semgrep.sarif", "eslint": "eslint.sarif", + "tsc": "tsc.sarif", "ruff": "ruff.sarif", "pylint": "pylint.sarif", "knip": "knip.sarif", @@ -503,7 +504,6 @@ def test_contract_cli_attaches_metadata_for_new_native_language_tools(self): **CONTAINER_TOOL_OUTPUTS, } - class SigilixWorkflowContractTest(unittest.TestCase): def workflow_text(self): path = os.path.join(os.path.dirname(__file__), "..", "workflows", "scan.yml") diff --git a/.github/scripts/tsc_to_sarif.py b/.github/scripts/tsc_to_sarif.py new file mode 100644 index 0000000..48c956b --- /dev/null +++ b/.github/scripts/tsc_to_sarif.py @@ -0,0 +1,132 @@ +import argparse +import os +import re +import sys + +from sarif_converter_common import make_document, make_result, write_json_file + + +TSC_TOOL_ID = "tsc" +TSC_TOOL_NAME = "TypeScript Compiler" +TSC_INFORMATION_URI = "https://www.typescriptlang.org/docs/handbook/compiler-options.html" +MAX_MESSAGE_LENGTH = 4096 +DEPENDENCY_NOISE_CODES = frozenset({"TS2307", "TS2688", "TS7016"}) + +_DIAGNOSTIC_RE = re.compile( + r"^(?P.+)\((?P[0-9]+),(?P[0-9]+)\): " + r"(?Perror|warning) (?PTS[0-9]+): (?P.*)$" +) +_QUOTED_SPECIFIER_RE = re.compile(r"'([^']+)'") + + +def convert_tsc_text(text, base_dir=".", cap=None): + results = [] + for diagnostic in _diagnostics(text): + code = diagnostic["code"] + if _is_dependency_noise(diagnostic): + continue + if not _is_inside_base_dir(diagnostic["path"], base_dir): + continue + results.append( + make_result( + f"tsc/{code}", + _level_for_severity(diagnostic["severity"]), + _bounded_text(diagnostic["message"], MAX_MESSAGE_LENGTH), + diagnostic["path"], + line=diagnostic["line"], + column=diagnostic["column"], + base_dir=base_dir, + ) + ) + return make_document(TSC_TOOL_NAME, TSC_TOOL_ID, results, information_uri=TSC_INFORMATION_URI, cap=cap) + + +def _diagnostics(text): + for line in str(text or "").splitlines(): + match = _DIAGNOSTIC_RE.match(line) + if not match: + continue + groups = match.groupdict() + yield { + "path": groups["path"], + "line": _positive_int(groups["line"]), + "column": _positive_int(groups["column"]), + "severity": groups["severity"], + "code": groups["code"], + "message": groups["message"], + } + + +def _level_for_severity(severity): + return "error" if severity == "error" else "warning" + + +def _is_dependency_noise(diagnostic): + code = diagnostic["code"] + if code not in DEPENDENCY_NOISE_CODES: + return False + specifier = _quoted_specifier(diagnostic["message"]) + return _looks_like_external_package(specifier) + + +def _quoted_specifier(message): + match = _QUOTED_SPECIFIER_RE.search(str(message or "")) + return match.group(1) if match else "" + + +def _looks_like_external_package(specifier): + if not specifier: + return True + if specifier.startswith((".", "/", "#", "@/")): + return False + if specifier.startswith("~") and (len(specifier) == 1 or specifier[1] == "/"): + return False + return "/" not in specifier + + +def _is_inside_base_dir(path, base_dir): + base = os.path.realpath(base_dir or ".") + candidate = path if os.path.isabs(str(path or "")) else os.path.join(base, str(path or "")) + candidate = os.path.realpath(candidate) + try: + return os.path.commonpath([base, candidate]) == base + except ValueError: + return False + + +def _positive_int(value): + try: + integer = int(value) + except (TypeError, ValueError): + return None + return integer if integer > 0 else None + + +def _bounded_text(value, limit): + text = str(value or "").strip() + if len(text) <= limit: + return text + return text[: limit - 3] + "..." + + +def _main(argv): + parser = argparse.ArgumentParser(description="Convert TypeScript compiler output to Sigilix SARIF.") + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--base-dir", default=".") + parser.add_argument("--cap", type=int) + args = parser.parse_args(argv) + + try: + with open(args.input, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + except OSError as exc: + print(f"::warning::Failed to read TypeScript output: {exc}", file=sys.stderr) + text = "" + document = convert_tsc_text(text, base_dir=args.base_dir, cap=args.cap) + write_json_file(args.output, document) + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/.github/scripts/tsc_workflow_test.py b/.github/scripts/tsc_workflow_test.py new file mode 100644 index 0000000..04ab108 --- /dev/null +++ b/.github/scripts/tsc_workflow_test.py @@ -0,0 +1,208 @@ +import base64 +import hashlib +import json +import os +import re +import stat +import subprocess +import tempfile +import unittest + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +WORKFLOW_PATH = os.path.join(ROOT, ".github", "workflows", "scan.yml") +MANIFEST_PATH = os.path.join(ROOT, ".github", "config", "tool-manifest.json") +TSC_RUNNER_PATH = os.path.join(ROOT, ".github", "scripts", "run_tsc.sh") +FAKE_TYPESCRIPT_TARBALL = b"fake typescript package\n" +FAKE_TYPESCRIPT_INTEGRITY = "sha512-" + base64.b64encode(hashlib.sha512(FAKE_TYPESCRIPT_TARBALL).digest()).decode("ascii") + + +class TypeScriptCompilerWorkflowTest(unittest.TestCase): + def read_file(self, path): + with open(path, encoding="utf-8") as handle: + return handle.read() + + def workflow_text(self): + return self.read_file(WORKFLOW_PATH) + + def workflow_input_block(self, input_name): + pattern = rf"\n {re.escape(input_name)}:\n(?P(?: .+\n)+)" + match = re.search(pattern, self.workflow_text()) + self.assertIsNotNone(match) + return match.group("block") + + def workflow_tsc_block(self): + match = re.search( + r"(?ms)^ - name: Run TypeScript compiler to SARIF\n" + r".+?" + r"(?=^ - name: Run Ruff to SARIF)", + self.workflow_text(), + ) + self.assertIsNotNone(match) + return match.group(0) + + def manifest_rows(self): + with open(MANIFEST_PATH, encoding="utf-8") as handle: + return json.load(handle)["tools"] + + def runner_env(self, source_dir, sarif_dir, runner_temp, npm_bin): + env = os.environ.copy() + env.update( + { + "PATH": f"{os.path.dirname(npm_bin)}:{env['PATH']}", + "RESULT_CAP": "50", + "RUNNER_DIR": ROOT, + "RUNNER_TEMP": runner_temp, + "SARIF_DIR": sarif_dir, + "SOURCE_DIR": source_dir, + "TYPESCRIPT_NPM_INTEGRITY": FAKE_TYPESCRIPT_INTEGRITY, + "TYPESCRIPT_VERSION": "6.0.3", + } + ) + return env + + def test_tsc_is_default_on_and_manifested(self): + text = self.workflow_text() + rows = {row["id"]: row for row in self.manifest_rows()} + + self.assertIn(" default: true\n", self.workflow_input_block("tsc")) + self.assertIn("TSC_ENABLED: ${{ inputs.tsc }}", text) + self.assertEqual(rows["tsc"], {"id": "tsc", "env": "TSC_ENABLED", "output": "tsc.sarif"}) + + def test_tsc_workflow_delegates_to_runner_script(self): + block = self.workflow_tsc_block() + text = self.read_file(TSC_RUNNER_PATH) + + self.assertIn('bash "$RUNNER_DIR/.github/scripts/run_tsc.sh"', block) + self.assertIn("TYPESCRIPT_NPM_INTEGRITY", self.workflow_text()) + self.assertIn('"typescript@${TYPESCRIPT_VERSION}"', text) + self.assertIn("npm pack --silent --pack-destination", text) + self.assertIn("verify_package_integrity", text) + self.assertIn("npm install --silent --prefix \"$tsc_install_dir\" --ignore-scripts --omit=optional", text) + self.assertIn("--noEmit", text) + self.assertIn("--pretty false", text) + self.assertIn("--skipLibCheck", text) + self.assertIn("tsc_to_sarif.py", text) + self.assertIn("-name 'tsconfig.json'", text) + self.assertIn("-name 'tsconfig.*.json'", text) + self.assertNotIn("npm exec", text) + self.assertNotIn("npx --yes", text) + + def test_tsc_converter_maps_compiler_diagnostics_to_sarif(self): + from tsc_to_sarif import convert_tsc_text + + document = convert_tsc_text( + "\n".join( + [ + "src/index.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.", + "src/index.ts(1,21): error TS2307: Cannot find module 'missing' or its corresponding type declarations.", + "src/index.ts(4,21): error TS2307: Cannot find module './internal' or its corresponding type declarations.", + "src/index.ts(2,22): error TS7016: Could not find a declaration file for module 'legacy'.", + ] + ), + base_dir="/repo", + ) + + run = document["runs"][0] + self.assertEqual(run["tool"]["driver"]["properties"]["sigilixToolId"], "tsc") + results = run["results"] + self.assertEqual([result["ruleId"] for result in results], ["tsc/TS2322", "tsc/TS2307"]) + self.assertIn("TS2307", convert_tsc_text.__globals__["DEPENDENCY_NOISE_CODES"]) + self.assertIn("TS7016", convert_tsc_text.__globals__["DEPENDENCY_NOISE_CODES"]) + self.assertEqual(results[0]["level"], "error") + self.assertEqual(results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], "src/index.ts") + self.assertEqual(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 3) + self.assertEqual(results[0]["locations"][0]["physicalLocation"]["region"]["startColumn"], 7) + self.assertIn("not assignable", results[0]["message"]["text"]) + self.assertIn("./internal", results[1]["message"]["text"]) + + def test_tsc_converter_drops_diagnostics_outside_source_root(self): + from tsc_to_sarif import convert_tsc_text + + with tempfile.TemporaryDirectory() as tmpdir: + document = convert_tsc_text( + "\n".join( + [ + "src/index.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.", + "../outside.ts(1,1): error TS1005: ';' expected.", + ] + ), + base_dir=tmpdir, + ) + + results = document["runs"][0]["results"] + self.assertEqual([result["ruleId"] for result in results], ["tsc/TS2322"]) + + def test_tsc_runner_converts_type_errors_and_filters_missing_dependency_noise(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = os.path.join(tmpdir, "source") + sarif_dir = os.path.join(tmpdir, "sarif") + runner_temp = os.path.join(tmpdir, "temp") + bin_dir = os.path.join(tmpdir, "bin") + os.makedirs(os.path.join(source_dir, "src")) + os.makedirs(sarif_dir) + os.makedirs(runner_temp) + os.makedirs(bin_dir) + with open(os.path.join(source_dir, "tsconfig.json"), "w", encoding="utf-8") as handle: + handle.write('{"include":["src/**/*.ts"]}\n') + with open(os.path.join(source_dir, "src", "index.ts"), "w", encoding="utf-8") as handle: + handle.write('const value: number = "nope";\n') + npm_bin = os.path.join(bin_dir, "npm") + self.write_fake_npm(npm_bin) + + subprocess.check_call(["bash", TSC_RUNNER_PATH], env=self.runner_env(source_dir, sarif_dir, runner_temp, npm_bin)) + + with open(os.path.join(sarif_dir, "tsc.sarif"), encoding="utf-8") as handle: + document = json.load(handle) + results = document["runs"][0]["results"] + self.assertEqual([result["ruleId"] for result in results], ["tsc/TS2322"]) + + def write_fake_npm(self, path): + script = r'''#!/usr/bin/env bash +set -euo pipefail +if [ "${1:-}" = "pack" ]; then + destination="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--pack-destination" ]; then + destination="$2" + shift 2 + else + shift + fi + done + mkdir -p "$destination" + printf '%s' 'fake typescript package +' > "$destination/typescript-6.0.3.tgz" + printf '%s\n' 'typescript-6.0.3.tgz' + exit 0 +fi +prefix="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--prefix" ]; then + prefix="$2" + shift 2 + else + shift + fi +done +mkdir -p "$prefix/node_modules/.bin" +cat > "$prefix/node_modules/.bin/tsc" <<'TSCSH' +#!/usr/bin/env bash +set -euo pipefail +if [ "${1:-}" = "--version" ]; then + printf '%s\n' 'Version 6.0.3' + exit 0 +fi +printf '%s\n' "src/index.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'." +printf '%s\n' "src/index.ts(1,21): error TS2307: Cannot find module 'missing' or its corresponding type declarations." +exit 2 +TSCSH +chmod +x "$prefix/node_modules/.bin/tsc" +''' + with open(path, "w", encoding="utf-8") as handle: + handle.write(script) + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bd9111..162f534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: set -euo pipefail python3 .github/scripts/sigilix_sarif_test.py python3 .github/scripts/eslint_ts_workflow_test.py + python3 .github/scripts/tsc_workflow_test.py python3 .github/scripts/pylint_workflow_test.py python3 .github/scripts/knip_workflow_test.py python3 .github/scripts/biome_workflow_test.py diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 252a142..f3adc37 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -47,6 +47,11 @@ on: required: false default: "safe" type: string + tsc: + description: "Run TypeScript compiler no-emit diagnostics for type-aware findings." + required: false + default: true + type: boolean ruff: description: "Run Ruff and attach Sigilix deterministic-tool metadata." required: false @@ -205,6 +210,7 @@ jobs: TRIVY_VERSION: "0.71.0" TRUFFLEHOG_VERSION: "3.95.5" TYPESCRIPT_ESLINT_VERSION: "8.61.0" + TYPESCRIPT_NPM_INTEGRITY: "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==" TYPESCRIPT_VERSION: "6.0.3" YAMLLINT_VERSION: "1.37.1" ZIZMOR_VERSION: "1.25.2" @@ -268,6 +274,13 @@ jobs: cd "$SOURCE_DIR" bash "$RUNNER_DIR/.github/scripts/run_eslint.sh" + - name: Run TypeScript compiler to SARIF + if: ${{ inputs.tsc }} + run: | + set -euo pipefail + cd "$SOURCE_DIR" + bash "$RUNNER_DIR/.github/scripts/run_tsc.sh" + - name: Run Ruff to SARIF if: ${{ inputs.ruff }} run: | @@ -819,6 +832,7 @@ jobs: env: SEMGREP_ENABLED: ${{ inputs.semgrep }} ESLINT_ENABLED: ${{ inputs.eslint }} + TSC_ENABLED: ${{ inputs.tsc }} RUFF_ENABLED: ${{ inputs.ruff }} PYLINT_ENABLED: ${{ inputs.pylint }} KNIP_ENABLED: ${{ inputs.knip }} diff --git a/README.md b/README.md index 0fd1f63..9abbe83 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Current staged catalog: | --- | --- | --- | | Semgrep | on | Native SARIF with Sigilix metadata. `semgrep-config` defaults to `auto`. | | ESLint | on | Safe mode by default: no repository config or plugins. Uses Sigilix-owned JavaScript/TypeScript logic, promise, and security rules; typed TypeScript promise rules turn on when a TS config is detected. Use `eslint-mode: repo-config` only when you accept executing the caller repository's ESLint config/plugins in the scan job. | +| TypeScript Compiler | on | Converts `tsc --noEmit` diagnostics to SARIF when TypeScript configs are present. Bare external dependency-resolution noise is filtered, path-like unresolved imports stay visible as review signal, and declaration-file library checks are skipped to keep dependency noise bounded. | | Ruff | on | Native SARIF with Sigilix metadata. | | Pylint | on | Converts Pylint JSON output to SARIF. Uses a Sigilix-owned high-confidence profile: Pylint fatal/error checks only, with caller config ignored and dependency-sensitive `import-error`/`no-member` disabled. | | Knip | on | Converts Knip JSON output to SARIF. Uses a Sigilix-owned JavaScript/TypeScript profile for unresolved imports, unlisted dependencies, and missing package-script binaries; broad unused-export/file reports are not enabled. | @@ -46,6 +47,7 @@ Current staged catalog: > `ast-grep` now defaults to `true`. Set `ast-grep: false` (boolean) in the caller workflow to suppress it. > `pylint` now defaults to `true`. Set `pylint: false` (boolean) in the caller workflow to suppress it. > `knip` now defaults to `true`. Set `knip: false` (boolean) in the caller workflow to suppress it. +> `tsc` now defaults to `true`. Set `tsc: false` (boolean) in the caller workflow to suppress it. > `markdownlint`, `dotenv-linter`, and `checkmake` now default to `true`. Set the matching > boolean input to `false` in the caller workflow to suppress one of them. @@ -87,7 +89,7 @@ a moving ref cannot prove which version of the runner ran. Default-on tool booleans: `semgrep`, `eslint`, `ruff`, `actionlint`, `shellcheck`, `yamllint`, `markdownlint`, `dotenv-linter`, `checkmake`, `gitleaks`, `osv-scanner`, `zizmor`, `hadolint`, -`biome`, `oxlint`, `ast-grep`, `pylint`, and `knip`. +`biome`, `oxlint`, `ast-grep`, `pylint`, `knip`, and `tsc`. Default-off opt-in tool booleans: `checkov`, `trivy`, `trufflehog`, and `tflint`. From 2931177b96cb105b3e4f2134276f1dc496dba21f Mon Sep 17 00:00:00 2001 From: Daniel A Martinez Julio Date: Fri, 12 Jun 2026 08:49:27 -0700 Subject: [PATCH 2/3] fix(SIG-107): address tsc review findings --- .github/scripts/run_tsc.sh | 2 +- .github/scripts/tsc_to_sarif.py | 2 ++ .github/scripts/tsc_workflow_test.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/scripts/run_tsc.sh b/.github/scripts/run_tsc.sh index f81895c..6ee8418 100644 --- a/.github/scripts/run_tsc.sh +++ b/.github/scripts/run_tsc.sh @@ -96,7 +96,7 @@ else elif [ "$tsc_can_scan" = true ] && ! tsc_version="$("$tsc_bin" --version 2>/dev/null)"; then tsc_can_scan=false elif [ "$tsc_can_scan" = true ]; then - tsc_version="$(printf '%s\n' "$tsc_version" | grep -Eo '^[^0-9]*[0-9]+[.][0-9]+[.][0-9]+' | grep -Eo '[0-9]+[.][0-9]+[.][0-9]+' | head -n 1 || true)" + tsc_version="$(printf '%s\n' "$tsc_version" | grep -Eo '^(Version|v)[[:space:]]*[0-9]+[.][0-9]+[.][0-9]+' | grep -Eo '[0-9]+[.][0-9]+[.][0-9]+' | head -n 1 || true)" if [ "$tsc_version" != "$TYPESCRIPT_VERSION" ]; then tsc_can_scan=false fi diff --git a/.github/scripts/tsc_to_sarif.py b/.github/scripts/tsc_to_sarif.py index 48c956b..f734fb1 100644 --- a/.github/scripts/tsc_to_sarif.py +++ b/.github/scripts/tsc_to_sarif.py @@ -81,6 +81,8 @@ def _looks_like_external_package(specifier): return False if specifier.startswith("~") and (len(specifier) == 1 or specifier[1] == "/"): return False + if specifier.startswith("@") and len(specifier) > 1 and specifier[1] not in ("/", "~", "."): + return True return "/" not in specifier diff --git a/.github/scripts/tsc_workflow_test.py b/.github/scripts/tsc_workflow_test.py index 04ab108..eae580c 100644 --- a/.github/scripts/tsc_workflow_test.py +++ b/.github/scripts/tsc_workflow_test.py @@ -79,6 +79,7 @@ def test_tsc_workflow_delegates_to_runner_script(self): self.assertIn("npm pack --silent --pack-destination", text) self.assertIn("verify_package_integrity", text) self.assertIn("npm install --silent --prefix \"$tsc_install_dir\" --ignore-scripts --omit=optional", text) + self.assertIn("'^(Version|v)[[:space:]]*[0-9]+[.][0-9]+[.][0-9]+'", text) self.assertIn("--noEmit", text) self.assertIn("--pretty false", text) self.assertIn("--skipLibCheck", text) @@ -96,6 +97,7 @@ def test_tsc_converter_maps_compiler_diagnostics_to_sarif(self): [ "src/index.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.", "src/index.ts(1,21): error TS2307: Cannot find module 'missing' or its corresponding type declarations.", + "src/index.ts(5,21): error TS2307: Cannot find module '@types/react-dom' or its corresponding type declarations.", "src/index.ts(4,21): error TS2307: Cannot find module './internal' or its corresponding type declarations.", "src/index.ts(2,22): error TS7016: Could not find a declaration file for module 'legacy'.", ] From e558787fc29883376cf23b85ec72dcfec91a519f Mon Sep 17 00:00:00 2001 From: Daniel A Martinez Julio Date: Fri, 12 Jun 2026 08:59:35 -0700 Subject: [PATCH 3/3] fix(SIG-107): refine tsc scoped import filtering --- .github/scripts/run_tsc.sh | 2 +- .github/scripts/tsc_to_sarif.py | 5 ++++- .github/scripts/tsc_workflow_test.py | 11 ++++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/scripts/run_tsc.sh b/.github/scripts/run_tsc.sh index 6ee8418..c23b327 100644 --- a/.github/scripts/run_tsc.sh +++ b/.github/scripts/run_tsc.sh @@ -96,7 +96,7 @@ else elif [ "$tsc_can_scan" = true ] && ! tsc_version="$("$tsc_bin" --version 2>/dev/null)"; then tsc_can_scan=false elif [ "$tsc_can_scan" = true ]; then - tsc_version="$(printf '%s\n' "$tsc_version" | grep -Eo '^(Version|v)[[:space:]]*[0-9]+[.][0-9]+[.][0-9]+' | grep -Eo '[0-9]+[.][0-9]+[.][0-9]+' | head -n 1 || true)" + tsc_version="$(printf '%s\n' "$tsc_version" | grep -Eo '^Version[[:space:]]+[0-9]+[.][0-9]+[.][0-9]+' | grep -Eo '[0-9]+[.][0-9]+[.][0-9]+' | head -n 1 || true)" if [ "$tsc_version" != "$TYPESCRIPT_VERSION" ]; then tsc_can_scan=false fi diff --git a/.github/scripts/tsc_to_sarif.py b/.github/scripts/tsc_to_sarif.py index f734fb1..95e243e 100644 --- a/.github/scripts/tsc_to_sarif.py +++ b/.github/scripts/tsc_to_sarif.py @@ -82,7 +82,10 @@ def _looks_like_external_package(specifier): if specifier.startswith("~") and (len(specifier) == 1 or specifier[1] == "/"): return False if specifier.startswith("@") and len(specifier) > 1 and specifier[1] not in ("/", "~", "."): - return True + parts = specifier.split("/") + if parts[0] == "@types": + return True + return specifier.count("/") == 1 return "/" not in specifier diff --git a/.github/scripts/tsc_workflow_test.py b/.github/scripts/tsc_workflow_test.py index eae580c..35356cb 100644 --- a/.github/scripts/tsc_workflow_test.py +++ b/.github/scripts/tsc_workflow_test.py @@ -79,7 +79,7 @@ def test_tsc_workflow_delegates_to_runner_script(self): self.assertIn("npm pack --silent --pack-destination", text) self.assertIn("verify_package_integrity", text) self.assertIn("npm install --silent --prefix \"$tsc_install_dir\" --ignore-scripts --omit=optional", text) - self.assertIn("'^(Version|v)[[:space:]]*[0-9]+[.][0-9]+[.][0-9]+'", text) + self.assertIn("'^Version[[:space:]]+[0-9]+[.][0-9]+[.][0-9]+'", text) self.assertIn("--noEmit", text) self.assertIn("--pretty false", text) self.assertIn("--skipLibCheck", text) @@ -98,6 +98,8 @@ def test_tsc_converter_maps_compiler_diagnostics_to_sarif(self): "src/index.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.", "src/index.ts(1,21): error TS2307: Cannot find module 'missing' or its corresponding type declarations.", "src/index.ts(5,21): error TS2307: Cannot find module '@types/react-dom' or its corresponding type declarations.", + "src/index.ts(7,21): error TS2307: Cannot find module '@types/react-dom/v18' or its corresponding type declarations.", + "src/index.ts(6,21): error TS2307: Cannot find module '@myorg/utils/subpath' or its corresponding type declarations.", "src/index.ts(4,21): error TS2307: Cannot find module './internal' or its corresponding type declarations.", "src/index.ts(2,22): error TS7016: Could not find a declaration file for module 'legacy'.", ] @@ -108,7 +110,7 @@ def test_tsc_converter_maps_compiler_diagnostics_to_sarif(self): run = document["runs"][0] self.assertEqual(run["tool"]["driver"]["properties"]["sigilixToolId"], "tsc") results = run["results"] - self.assertEqual([result["ruleId"] for result in results], ["tsc/TS2322", "tsc/TS2307"]) + self.assertEqual([result["ruleId"] for result in results], ["tsc/TS2322", "tsc/TS2307", "tsc/TS2307"]) self.assertIn("TS2307", convert_tsc_text.__globals__["DEPENDENCY_NOISE_CODES"]) self.assertIn("TS7016", convert_tsc_text.__globals__["DEPENDENCY_NOISE_CODES"]) self.assertEqual(results[0]["level"], "error") @@ -116,7 +118,10 @@ def test_tsc_converter_maps_compiler_diagnostics_to_sarif(self): self.assertEqual(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 3) self.assertEqual(results[0]["locations"][0]["physicalLocation"]["region"]["startColumn"], 7) self.assertIn("not assignable", results[0]["message"]["text"]) - self.assertIn("./internal", results[1]["message"]["text"]) + messages = "\n".join(result["message"]["text"] for result in results) + self.assertIn("@myorg/utils/subpath", messages) + self.assertIn("./internal", messages) + self.assertNotIn("@types/react-dom", messages) def test_tsc_converter_drops_diagnostics_outside_source_root(self): from tsc_to_sarif import convert_tsc_text