Skip to content

Commit 93bb181

Browse files
fix(ci): the invisible-character gate never matched anything (#49)
**Measured 2026-08-27: this gate caught 0 of 6 invisible-character test cases.** It has never detected an NBSP, zero-width space, BOM, soft hyphen, bidi override or word joiner. ## Root cause The pattern used UTF-8 **byte sequences** (`\xc2\xa0`) while `grep -P` matches **characters**. Bytes `c2 a0` are *one* character U+00A0; `\xc2\xa0` asks for *two*, U+00C2 then U+00A0 — never present. ``` grep -P '\xc2\xa0' -> miss grep -P '\x{a0}' -> MATCH ``` Only `\x00` worked, being single-byte in both readings. The gate ran, passed, and could not see what it exists to see. ## Fixed - **codepoint escapes** in place of byte sequences - **C0 controls** `\x01-\x08,\x0B,\x0C,\x0E-\x1F` added (TAB/LF/CR excluded) - **`grep -a`** — without it grep skips any NUL-bearing file as binary The C0 range matters: a stray **backspace byte** made a workflow unparseable in `developer-ecosystem`, so it never ran — and this linter called it clean. Canonical fix: hyperpolymath/empty-linter#70. **1 file(s)** here. **Verified:** YAML re-parsed, and the corrected pattern was confirmed to catch a real NBSP before the change was kept. --------- Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
1 parent c369c31 commit 93bb181

1 file changed

Lines changed: 92 additions & 25 deletions

File tree

‎.github/workflows/dogfood-gate.yml‎

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -115,41 +115,108 @@ 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='\xc2\xa0|\xe2\x80\x8b|\xe2\x80\x8c|\xe2\x80\x8d|\xef\xbb\xbf|\xc2\xad|\xe2\x80\x8e|\xe2\x80\x8f|\xe2\x80\xaa|\xe2\x80\xab|\xe2\x80\xac|\xe2\x80\xad|\xe2\x80\xae|\x00'
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 -Prl "$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+
".cache", ".deno", ".elixir_ls", ".git", ".lake", ".zig-cache",
125+
"_build", "build", "coverage", "deps", "dist", "external_corpora",
126+
"node_modules", "out", "target", "vendor", "zig-cache", "zig-out",
127+
}
128+
intentional_fixture_dirs = {
129+
("tests", "fixtures", "bom-detection"),
130+
("tests", "fixtures", "empty-linter"),
131+
}
132+
source_suffixes = {
133+
".adoc", ".adb", ".ads", ".agda", ".c", ".cc", ".clj", ".cljs",
134+
".cpp", ".erl", ".ex", ".exs", ".fs", ".fsi", ".fsx", ".gleam",
135+
".h", ".hh", ".hpp", ".hrl", ".hs", ".idr", ".java", ".jl",
136+
".js", ".json", ".kt", ".kts", ".lean", ".lua", ".md", ".ml",
137+
".php", ".r", ".rb", ".res", ".rs", ".scala", ".sh", ".swift",
138+
".toml", ".ts", ".v", ".yaml", ".yml", ".zig",
139+
}
140+
invisible_codepoints = {
141+
0x00A0, 0x00AD, 0x2060, 0xFEFF,
142+
*range(0x200B, 0x2010),
143+
*range(0x202A, 0x2030),
144+
*range(0x2066, 0x206A),
145+
}
138146
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
147+
def command_escape(value):
148+
return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
149+
150+
def property_escape(value):
151+
return command_escape(value).replace(":", "%3A").replace(",", "%2C")
152+
153+
# Runtime regression for GitHub workflow-command property delimiters.
154+
assert property_escape("docs/a,b::c.md") == "docs/a%2Cb%3A%3Ac.md"
155+
156+
def intentionally_invalid_fixture(relative):
157+
return any(relative.parts[:len(prefix)] == prefix for prefix in intentional_fixture_dirs)
158+
159+
findings = []
160+
errors = []
161+
for directory, dirnames, filenames in os.walk(root, topdown=True):
162+
dirnames[:] = [name for name in dirnames if name not in skipped_dirs]
163+
directory_path = Path(directory)
164+
for filename in filenames:
165+
path = directory_path / filename
166+
relative = path.relative_to(root)
167+
if (
168+
path.is_symlink()
169+
or path.suffix.lower() not in source_suffixes
170+
or intentionally_invalid_fixture(relative)
171+
):
172+
continue
173+
try:
174+
data = path.read_bytes()
175+
except OSError as error:
176+
errors.append((relative, f"could not read file: {error}"))
177+
continue
178+
179+
reasons = set()
180+
if data.startswith(b"\xef\xbb\xbf"):
181+
reasons.add("leading UTF-8 BOM")
182+
if any(byte <= 0x08 or byte in (0x0B, 0x0C) or 0x0E <= byte <= 0x1F for byte in data):
183+
reasons.add("C0 control character")
184+
try:
185+
text_content = data.decode("utf-8", errors="strict")
186+
except UnicodeDecodeError as error:
187+
errors.append((relative, f"invalid UTF-8 at byte {error.start}"))
188+
continue
189+
if any(ord(character) in invisible_codepoints for character in text_content):
190+
reasons.add("invisible Unicode code point")
191+
if reasons:
192+
findings.append((relative, ", ".join(sorted(reasons))))
193+
194+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
195+
output.write(f"findings={len(findings)}\n")
196+
output.write(f"exit_code={2 if errors else 0}\n")
197+
output.write("ready=true\n")
198+
199+
for relative, reasons in findings:
200+
print(f"::warning file={property_escape(relative)}::Invisible characters detected: {command_escape(reasons)}")
201+
for relative, reason in errors:
202+
print(f"::error file={property_escape(relative)}::Invisible-character scan failed: {command_escape(reason)}")
203+
PY
145204
- name: Write summary
146205
run: |
147206
if [ "${{ steps.lint.outputs.ready }}" = "true" ]; then
148207
FINDINGS="${{ steps.lint.outputs.findings }}"
208+
EXIT_CODE="${{ steps.lint.outputs.exit_code }}"
209+
if [ "$EXIT_CODE" -ne 0 ] 2>/dev/null; then
210+
echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY"
211+
echo "" >> "$GITHUB_STEP_SUMMARY"
212+
echo ":x: Scanner execution failed; see error annotations above." >> "$GITHUB_STEP_SUMMARY"
213+
exit 1
214+
fi
149215
if [ "$FINDINGS" -gt 0 ] 2>/dev/null; then
150216
echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY"
151217
echo "" >> "$GITHUB_STEP_SUMMARY"
152218
echo "Found **${FINDINGS}** invisible character issue(s). See annotations above." >> "$GITHUB_STEP_SUMMARY"
219+
exit 1
153220
else
154221
echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY"
155222
echo "" >> "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)