@@ -115,37 +115,83 @@ jobs:
115115 # Inline invisible character detection (from empty-linter's core patterns).
116116 # Checks for: zero-width spaces, zero-width joiners, BOM, soft hyphens,
117117 # non-breaking spaces, null bytes, and other invisible Unicode in source files.
118- set +e
119- PATTERNS='(*UTF)[\x00-\x08\x0B\x0C\x0E-\x1F\x{a0}\x{ad}\x{200b}-\x{200f}\x{202a}-\x{202f}\x{2060}\x{2066}-\x{2069}\x{feff}]'
120- find "$GITHUB_WORKSPACE" \
121- -not -path '*/.git/*' -not -path '*/node_modules/*' \
122- -not -path '*/.deno/*' -not -path '*/target/*' \
123- -not -path '*/_build/*' -not -path '*/deps/*' \
124- -not -path '*/external_corpora/*' -not -path '*/.lake/*' \
125- -type f \( -name '*.rs' -o -name '*.ex' -o -name '*.exs' -o -name '*.res' \
126- -o -name '*.js' -o -name '*.ts' -o -name '*.json' -o -name '*.toml' \
127- -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' -o -name '*.adoc' \
128- -o -name '*.idr' -o -name '*.zig' -o -name '*.v' -o -name '*.jl' \
129- -o -name '*.gleam' -o -name '*.hs' -o -name '*.ml' -o -name '*.sh' \) \
130- -exec grep -aPl "$PATTERNS" {} + > /tmp/empty-lint-results.txt 2>/dev/null
131- EL_EXIT=$?
132- set -e
118+ python3 - <<'PY'
119+ import os
120+ from pathlib import Path
133121
134- FINDINGS=$(wc -l < /tmp/empty-lint-results.txt 2>/dev/null || echo 0)
135- echo "findings=$FINDINGS" >> "$GITHUB_OUTPUT"
136- echo "exit_code=$EL_EXIT" >> "$GITHUB_OUTPUT"
137- echo "ready=true" >> "$GITHUB_OUTPUT"
122+ root = Path(os.environ["GITHUB_WORKSPACE"])
123+ skipped_dirs = {
124+ ".git", ".deno", ".lake", "_build", "deps",
125+ "external_corpora", "node_modules", "target",
126+ }
127+ source_suffixes = {
128+ ".adoc", ".ex", ".exs", ".gleam", ".hs", ".idr", ".jl",
129+ ".js", ".json", ".md", ".ml", ".res", ".rs", ".sh",
130+ ".toml", ".ts", ".v", ".yaml", ".yml", ".zig",
131+ }
132+ invisible_codepoints = {
133+ 0x00A0, 0x00AD, 0x2060, 0xFEFF,
134+ *range(0x200B, 0x2010),
135+ *range(0x202A, 0x2030),
136+ *range(0x2066, 0x206A),
137+ }
138138
139- # Emit annotations for each file with invisible chars
140- while IFS= read -r filepath; do
141- [ -z "$filepath" ] && continue
142- REL_PATH="${filepath#$GITHUB_WORKSPACE/}"
143- echo "::warning file=${REL_PATH}::Invisible Unicode characters detected (zero-width space, BOM, NBSP, etc.)"
144- done < /tmp/empty-lint-results.txt
139+ def annotation_escape(value):
140+ return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
141+
142+ findings = []
143+ errors = []
144+ for path in root.rglob("*"):
145+ relative = path.relative_to(root)
146+ if (
147+ path.is_symlink()
148+ or not path.is_file()
149+ or path.suffix.lower() not in source_suffixes
150+ or any(part in skipped_dirs for part in relative.parts[:-1])
151+ ):
152+ continue
153+ try:
154+ data = path.read_bytes()
155+ except OSError as error:
156+ errors.append((relative, f"could not read file: {error}"))
157+ continue
158+
159+ reasons = set()
160+ if data.startswith(b"\xef\xbb\xbf"):
161+ reasons.add("leading UTF-8 BOM")
162+ if any(byte <= 0x08 or byte in (0x0B, 0x0C) or 0x0E <= byte <= 0x1F for byte in data):
163+ reasons.add("C0 control character")
164+ try:
165+ text_content = data.decode("utf-8", errors="strict")
166+ except UnicodeDecodeError as error:
167+ errors.append((relative, f"invalid UTF-8 at byte {error.start}"))
168+ continue
169+ if any(ord(character) in invisible_codepoints for character in text_content):
170+ reasons.add("invisible Unicode code point")
171+ if reasons:
172+ findings.append((relative, ", ".join(sorted(reasons))))
173+
174+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
175+ output.write(f"findings={len(findings)}\n")
176+ output.write(f"exit_code={2 if errors else 0}\n")
177+ output.write("ready=true\n")
178+
179+ for relative, reasons in findings:
180+ print(f"::warning file={annotation_escape(relative)}::Invisible characters detected: {reasons}")
181+ for relative, reason in errors:
182+ print(f"::error file={annotation_escape(relative)}::Invisible-character scan failed: {annotation_escape(reason)}")
183+ PY
145184 - name : Write summary
146185 run : |
147186 if [ "${{ steps.lint.outputs.ready }}" = "true" ]; then
148187 FINDINGS="${{ steps.lint.outputs.findings }}"
188+ EXIT_CODE="${{ steps.lint.outputs.exit_code }}"
189+ if [ "$EXIT_CODE" -ne 0 ] 2>/dev/null; then
190+ echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY"
191+ echo "" >> "$GITHUB_STEP_SUMMARY"
192+ echo ":x: Scanner execution failed; see error annotations above." >> "$GITHUB_STEP_SUMMARY"
193+ exit 1
194+ fi
149195 if [ "$FINDINGS" -gt 0 ] 2>/dev/null; then
150196 echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY"
151197 echo "" >> "$GITHUB_STEP_SUMMARY"
0 commit comments