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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# // Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
# SPDX-License-Identifier: PMPL-1.0
# SPDX-License-Identifier: MPL-2.0
name: Scorecards supply-chain security

on:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/secret-scanner.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# // Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
# SPDX-License-Identifier: PMPL-1.0
# SPDX-License-Identifier: MPL-2.0
name: Secret Scanner
on:
pull_request:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ deps/
.cache/
build/
dist/
*.deno.js
38 changes: 38 additions & 0 deletions EmptyLinter.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 hyperpolymath

module EmptyLinter;

use Deno::{readTextFile, writeTextFile};
use ByteDetector::{scan, apply_fixes};
use TextTransform::{get_metrics};
use PathHandler::{from_trusted, unwrap_path};

pub fn audit_file(path: String) {
let content = readTextFile(path);
scan(content)
}

pub fn fix_file(path: String) -> Int {
let content = readTextFile(path);
let (fixed, count) = apply_fixes(content);
if count > 0 {
writeTextFile(path, fixed);
count
} else {
0
}
}

pub fn get_file_metrics(path: String) {
let content = readTextFile(path);
get_metrics(content)
}

pub fn batch_audit(paths: [String]) {
let mut results = [];
for p in paths {
results = results ++ [audit_file(p)];
}
results
}
13 changes: 7 additions & 6 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
{
"name": "@hyperpolymath/empty-linter",
"version": "0.1.0",
"exports": "./EmptyLinter.res.js",
"exports": "./EmptyLinter.deno.js",
"tasks": {
"build": "rescript build",
"clean": "rescript clean",
"dev": "rescript build -w",
"build": "affinescript compile --deno-esm EmptyLinter.affine -o EmptyLinter.deno.js && affinescript compile --deno-esm src/cli/Main.affine -o src/cli/Main.deno.js",
"build-all": "for f in stdlib/SafeHex.affine stdlib/SafeWhitespace.affine stdlib/SafePath.affine stdlib/SafeString.affine src/core/ByteDetector.affine src/core/TextTransform.affine src/core/PathHandler.affine EmptyLinter.affine src/cli/Main.affine; do affinescript compile --deno-esm $f -o ${f%.affine}.deno.js; done",
"clean": "find . -name '*.deno.js' ! -path './stdlib/*' -delete",
"dev": "while true; do affinescript compile --deno-esm EmptyLinter.affine -o EmptyLinter.deno.js 2>&1; sleep 2; done",
"test": "deno test --allow-read --allow-write tests/",
"lint": "deno run --allow-read src/cli/Main.res.js",
"check": "deno check src/**/*.res.js"
"lint": "deno run --allow-read src/cli/Main.deno.js",
"check": "deno check EmptyLinter.deno.js"
},
"imports": {
"@std/assert": "jsr:@std/assert@1"
Expand Down
114 changes: 114 additions & 0 deletions src/cli/Main.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 hyperpolymath

module Main;

use Deno::{readTextFile, writeTextFile, args, exit, consoleError, walkRecursive, statIsDirectory};
use ByteDetector::{scan, apply_fixes, generate_report};

fn get_files(path: String) -> [String] {
if statIsDirectory(path) {
walkRecursive(path)
} else {
[path]
}
}

fn audit_paths(paths: [String]) -> Int {
let mut found = 0;
let mut pi = 0;
let paths_len = len(paths);
while pi < paths_len {
let path = paths[pi];
let files = get_files(path);
let mut fi = 0;
let files_len = len(files);
while fi < files_len {
let file = files[fi];
let content = readTextFile(file);
let artifacts = scan(content);
if len(artifacts) > 0 {
println(file ++ ": " ++ int_to_string(len(artifacts)) ++ " artifact(s)");
println(generate_report(artifacts));
found = found + len(artifacts);
}
fi = fi + 1;
}
pi = pi + 1;
}
found
}

fn fix_paths(paths: [String]) -> Int {
let mut fixed_count = 0;
let mut pi = 0;
let paths_len = len(paths);
while pi < paths_len {
let path = paths[pi];
let files = get_files(path);
let mut fi = 0;
let files_len = len(files);
while fi < files_len {
let file = files[fi];
let content = readTextFile(file);
let (fixed, count) = apply_fixes(content);
if count > 0 {
writeTextFile(file, fixed);
println("Fixed " ++ int_to_string(count) ++ " artifact(s) in " ++ file);
fixed_count = fixed_count + count;
}
fi = fi + 1;
}
pi = pi + 1;
}
fixed_count
}

pub fn main() -> Int {
let argv = args();
let argc = len(argv);

if argc == 0 {
println("empty-linter v0.1.0");
println("Usage: empty-linter <command> [paths...]");
println("Commands: audit, fix, help, version");
return exit(0);
}

let cmd = argv[0];

if cmd == "help" || cmd == "--help" || cmd == "-h" {
println("empty-linter v0.1.0 - Invisible artifact detector");
println("Usage: empty-linter <command> [paths...]");
println("Commands:");
println(" audit [path...] - Scan for invisible artifacts");
println(" fix [path...] - Fix invisible artifacts");
println(" help - Show this help");
println(" version - Show version");
return exit(0);
}

if cmd == "version" || cmd == "--version" {
println("empty-linter v0.1.0");
return exit(0);
}

let paths = if argc > 1 { argv[1:] } else { ["."] };

if cmd == "audit" {
let found = audit_paths(paths);
if found > 0 {
return exit(1);
}
return exit(0);
}

if cmd == "fix" {
let fixed_count = fix_paths(paths);
println("Total fixes: " ++ int_to_string(fixed_count));
return exit(0);
}

consoleError("Unknown command: " ++ cmd);
exit(1)
}
180 changes: 180 additions & 0 deletions src/core/ByteDetector.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 hyperpolymath

module ByteDetector;

use prelude::{Option, Some, None};
use SafeHex::{encode_bytes, encode_string, encode_byte};
use SafeWhitespace::{detect_invisibles};

pub type Severity = Critical | SevError | Warning | Info;

pub type ArtifactDef = {
name: String,
byte_value: Int,
severity: Severity,
fix_action: String
}

pub type Artifact = {
line: Int,
column: Int,
byte_value: Int,
hex_value: String,
name: String,
severity: Severity,
fix_action: String
}

pub fn known_artifacts() -> [ArtifactDef] {
[
#{ name: "NULL", byte_value: 0, severity: Critical, fix_action: "remove" },
#{ name: "NBSP", byte_value: 160, severity: SevError, fix_action: "replace:20" },
#{ name: "ZWSP", byte_value: 8203, severity: SevError, fix_action: "remove" },
#{ name: "BOM", byte_value: 65279, severity: Warning, fix_action: "remove" },
#{ name: "SHY", byte_value: 173, severity: Info, fix_action: "remove" },
#{ name: "LRM", byte_value: 8206, severity: Info, fix_action: "remove" },
#{ name: "RLM", byte_value: 8207, severity: Info, fix_action: "remove" },
#{ name: "WJ", byte_value: 8288, severity: Info, fix_action: "remove" },
#{ name: "ZWNJ", byte_value: 8204, severity: Warning, fix_action: "keep" },
#{ name: "ZWJ", byte_value: 8205, severity: Warning, fix_action: "keep" }
]
}

pub fn get_artifact_def(byte_val: Int) -> Option<ArtifactDef> {
let defs = known_artifacts();
for d in defs {
if d.byte_value == byte_val {
return Some(d);
}
}
None
}

pub fn byte_to_hex(v: Int) -> String {
encode_byte(v & 255)
}

pub fn scan(content: String) -> [Artifact] {
let mut results = [];
let mut line = 1;
let mut col = 1;
let n = len(content);
let mut i = 0;
while i < n {
let c = string_get(content, i);
let code = char_to_int(c);
if code == 10 {
line = line + 1;
col = 1;
} else {
match get_artifact_def(code) {
Some(def) => {
results = results ++ [#{
line: line,
column: col,
byte_value: code,
hex_value: byte_to_hex(code),
name: def.name,
severity: def.severity,
fix_action: def.fix_action
}];
col = col + 1;
},
None => {
col = col + 1;
}
}
}
i = i + 1;
}
results
}

pub fn scan_to_hex(content: String) -> String {
let artifacts = scan(content);
let mut lines = "";
let mut first = true;
for a in artifacts {
if !first {
lines = lines ++ "\n";
}
lines = lines ++ "0x" ++ to_uppercase(a.hex_value) ++ " [" ++ a.name ++ "] at L:" ++ int_to_string(a.line) ++ " C:" ++ int_to_string(a.column);
first = false;
}
lines
}

pub fn apply_fixes(content: String) -> (String, Int) {
let mut result = content;
let mut count = 0;
let defs = known_artifacts();
for def in defs {
if def.fix_action == "remove" {
let parts_count = len(result);
let fixed = replace_char(result, def.byte_value, "");
let new_count = len(fixed);
count = count + (parts_count - new_count);
result = fixed;
} else if def.fix_action == "replace:20" {
result = replace_char(result, def.byte_value, " ");
}
}
(result, count)
}

fn replace_char(s: String, target_code: Int, replacement: String) -> String {
let n = len(s);
let mut result = "";
let mut i = 0;
while i < n {
let c = string_get(s, i);
let code = char_to_int(c);
if code == target_code {
result = result ++ replacement;
} else {
result = result ++ show(c);
}
i = i + 1;
}
result
}

fn severity_order(s: Severity) -> Int {
match s {
Critical => 4,
SevError => 3,
Warning => 2,
Info => 1
}
}

pub fn filter_by_severity(artifacts: [Artifact], min_severity: Severity) -> [Artifact] {
let min_order = severity_order(min_severity);
let mut result = [];
for a in artifacts {
if severity_order(a.severity) >= min_order {
result = result ++ [a];
}
}
result
}

pub fn generate_report(artifacts: [Artifact]) -> String {
if len(artifacts) == 0 {
"No invisible artifacts detected."
} else {
let header = "Found " ++ int_to_string(len(artifacts)) ++ " invisible artifact(s):\n";
let mut lines = header;
for a in artifacts {
let sev_str = match a.severity {
Critical => "CRITICAL",
SevError => "ERROR",
Warning => "WARNING",
Info => "INFO"
};
lines = lines ++ "[" ++ sev_str ++ "] " ++ a.name ++ " (0x" ++ to_uppercase(a.hex_value) ++ ") at L:" ++ int_to_string(a.line) ++ " C:" ++ int_to_string(a.column) ++ " - " ++ a.fix_action ++ "\n";
}
lines
}
}
Loading
Loading