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 deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"exports": "./EmptyLinter.deno.js",
"tasks": {
"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",
"build-all": "bash scripts/build-all.sh",
"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/",
Expand Down
23 changes: 23 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions scripts/build-all.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 hyperpolymath
set -euo pipefail

SOURCES=(
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
)

for f in "${SOURCES[@]}"; do
affinescript compile --deno-esm "$f" -o "${f%.affine}.deno.js"
done

# Workaround: AffineScript alpha compiler (issue #122) does not fully inline
# cross-module dependencies into TextTransform.deno.js. Inject missing symbols
# after compilation: LF/CRLF/CR (zero-arg enum constructors), is_invisible
# (private helper from SafeWhitespace), concat (string stdlib fn).
TARGET="src/core/TextTransform.deno.js"
MARKER="// ---- end runtime ----"
PATCH='const LF={tag:"LF"};const CRLF={tag:"CRLF"};const CR={tag:"CR"};\nfunction is_invisible(c){return(c===0||c===160||c===8203||c===65279||c===173||c===8206||c===8207||c===8204||c===8205||c===8288);}\nfunction concat(a,b){return __as_concat(a,b);}'
if [ -f "$TARGET" ] && ! grep -qF 'const LF={tag:"LF"}' "$TARGET"; then

Check failure on line 29 in scripts/build-all.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_empty-linter&issues=AZ7D8p4zYF4-iXSsBDan&open=AZ7D8p4zYF4-iXSsBDan&pullRequest=25
awk -v marker="$MARKER" -v patch="$PATCH" '
{ print }
$0 == marker { printf "%s\n", patch }
' "$TARGET" > "$TARGET.tmp" && mv "$TARGET.tmp" "$TARGET"
fi

echo "build-all complete"
8 changes: 7 additions & 1 deletion src/core/ByteDetector.affine
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ pub fn get_artifact_def(byte_val: Int) -> Option<ArtifactDef> {
}

pub fn byte_to_hex(v: Int) -> String {
encode_byte(v & 255)
if v <= 255 {
encode_byte(v)
} else if v <= 65535 {
encode_byte((v >> 8) & 255) ++ encode_byte(v & 255)
} else {
encode_byte((v >> 16) & 255) ++ encode_byte((v >> 8) & 255) ++ encode_byte(v & 255)
}
}

pub fn scan(content: String) -> [Artifact] {
Expand Down
6 changes: 3 additions & 3 deletions src/core/TextTransform.affine
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@ pub fn transform(content: String, options: TransformOptions) -> String {
if options.remove_invisibles_opt {
s = remove_invisibles(s);
}
if options.normalize_line_endings_opt {
s = normalize_line_endings(s, options.target_line_ending);
}
if options.collapse_spaces_opt {
s = collapse_spaces(s);
}
Expand All @@ -65,6 +62,9 @@ pub fn transform(content: String, options: TransformOptions) -> String {
if options.trim_document {
s = trim(s);
}
if options.normalize_line_endings_opt {
s = normalize_line_endings(s, options.target_line_ending);
}
if options.ensure_final_newline_opt {
s = ensure_final_newline(s);
}
Expand Down
Binary file added tests/ByteDetector_test.js
Binary file not shown.
100 changes: 100 additions & 0 deletions tests/PathHandler_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
import { assertEquals } from "jsr:@std/assert";
import {
validate, unwrap_path, path_join, sanitize,
is_within, get_parent, filename, has_extension,
is_excluded, from_trusted,
TraversalDetected,

Check warning on line 8 in tests/PathHandler_test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'TraversalDetected'.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_empty-linter&issues=AZ7D6sifq26mY3X2OHFN&open=AZ7D6sifq26mY3X2OHFN&pullRequest=25
} from "../src/core/PathHandler.deno.js";

Deno.test("PathHandler: validate accepts relative paths", () => {
const p = validate("src/main.affine");
assertEquals(p.tag, "Some");
assertEquals(unwrap_path(p.value), "src/main.affine");
});

Deno.test("PathHandler: validate rejects absolute paths", () => {
assertEquals(validate("/etc/passwd").tag, "None");
});

Deno.test("PathHandler: validate rejects path traversal", () => {
assertEquals(validate("../../etc/passwd").tag, "None");
});

Deno.test("PathHandler: validate rejects embedded traversal", () => {
assertEquals(validate("src/../../../etc").tag, "None");
});

Deno.test("PathHandler: sanitize removes dangerous characters", () => {
const clean = sanitize("file<name>.txt");
assertEquals(clean.includes("<"), false);
assertEquals(clean.includes(">"), false);
});

Deno.test("PathHandler: sanitize replaces slashes", () => {
const clean = sanitize("path/to/file");
assertEquals(clean.includes("/"), false);
});

Deno.test("PathHandler: path_join creates valid joined path", () => {
const base = from_trusted("docs");
const result = path_join(base, ["notes", "file.txt"]);
assertEquals(result.tag, "Ok");
assertEquals(unwrap_path(result.value), "docs/notes/file.txt");
});

Deno.test("PathHandler: path_join rejects traversal in components", () => {
const base = from_trusted("home");
const result = path_join(base, ["..", "..", "etc"]);
assertEquals(result.tag, "Err");
assertEquals(result.error.tag, "TraversalDetected");
});

Deno.test("PathHandler: filename extracts basename", () => {
const p = from_trusted("docs/reports/file.pdf");
assertEquals(filename(p), "file.pdf");
});

Deno.test("PathHandler: filename handles no directory", () => {
assertEquals(filename(from_trusted("file.txt")), "file.txt");
});

Deno.test("PathHandler: has_extension checks extension", () => {
const p = from_trusted("src/main.affine");
assertEquals(has_extension(p, ".affine"), true);
assertEquals(has_extension(p, ".js"), false);
});

Deno.test("PathHandler: get_parent extracts directory", () => {
const p = from_trusted("home/user/docs/file.txt");
const parent = get_parent(p);
assertEquals(parent.tag, "Some");
assertEquals(unwrap_path(parent.value), "home/user/docs");
});

Deno.test("PathHandler: get_parent returns None for no directory", () => {
assertEquals(get_parent(from_trusted("file.txt")).tag, "None");
});

Deno.test("PathHandler: is_within checks path containment", () => {
const p = from_trusted("home/user/docs");
const base = from_trusted("home/user");
assertEquals(is_within(p, base), true);
});

Deno.test("PathHandler: is_within rejects unrelated paths", () => {
const p = from_trusted("etc/passwd");
const base = from_trusted("home/user");
assertEquals(is_within(p, base), false);
});

Deno.test("PathHandler: is_excluded matches excluded dirs", () => {
const p = from_trusted("project/node_modules/pkg/index.js");
assertEquals(is_excluded(p, ["node_modules", ".git"]), true);
});

Deno.test("PathHandler: is_excluded allows non-excluded paths", () => {
const p = from_trusted("project/src/main.affine");
assertEquals(is_excluded(p, ["node_modules", ".git"]), false);
});
77 changes: 77 additions & 0 deletions tests/SafeWhitespace_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
import { assertEquals } from "jsr:@std/assert";
import {
LF, CRLF, CR,
remove_invisibles, normalize_line_endings,
collapse_spaces, collapse_blank_lines,
trim_start, trim_end, ensure_final_newline,
detect_invisibles,
} from "../stdlib/SafeWhitespace.deno.js";

Deno.test("SafeWhitespace: trim_start removes leading whitespace", () => {
assertEquals(trim_start(" hello"), "hello");
assertEquals(trim_start("\t\nhello"), "hello");
assertEquals(trim_start("hello"), "hello");
});

Deno.test("SafeWhitespace: trim_end removes trailing whitespace", () => {
assertEquals(trim_end("hello "), "hello");
assertEquals(trim_end("hello\t\n"), "hello");
assertEquals(trim_end("hello"), "hello");
});

Deno.test("SafeWhitespace: collapse_spaces reduces multiple spaces", () => {
assertEquals(collapse_spaces("hello world"), "hello world");
assertEquals(collapse_spaces("a b c"), "a b c");
});

Deno.test("SafeWhitespace: collapse_spaces preserves single spaces", () => {
assertEquals(collapse_spaces("hello world"), "hello world");
});

Deno.test("SafeWhitespace: collapse_blank_lines reduces excess blank lines", () => {
const result = collapse_blank_lines("para1\n\n\n\npara2", 1);
assertEquals(result.includes("\n\n\n"), false);
});

Deno.test("SafeWhitespace: normalize_line_endings converts CRLF to LF", () => {
const result = normalize_line_endings("line1\r\nline2", LF);
assertEquals(result.includes("\r"), false);
});

Deno.test("SafeWhitespace: normalize_line_endings converts LF to CRLF", () => {
const result = normalize_line_endings("line1\nline2", CRLF);
assertEquals(result.includes("\r\n"), true);
});

Deno.test("SafeWhitespace: ensure_final_newline adds newline when missing", () => {
assertEquals(ensure_final_newline("hello").endsWith("\n"), true);
});

Deno.test("SafeWhitespace: ensure_final_newline idempotent when present", () => {
const result = ensure_final_newline("hello\n");
assertEquals(result, "hello\n");
});

Deno.test("SafeWhitespace: remove_invisibles strips known invisible chars", () => {
const result = remove_invisibles("​hello");
assertEquals(result.includes("​"), false);
assertEquals(result.includes(""), false);
});

Deno.test("SafeWhitespace: detect_invisibles finds NBSP", () => {
const found = detect_invisibles("hello world");
assertEquals(found.length, 1);
assertEquals(found[0], 0xa0);
});

Deno.test("SafeWhitespace: detect_invisibles empty for clean string", () => {
assertEquals(detect_invisibles("hello world").length, 0);
});

Deno.test("SafeWhitespace: LineEnding constants have correct tags", () => {
assertEquals(LF.tag, "LF");
assertEquals(CRLF.tag, "CRLF");
assertEquals(CR.tag, "CR");
});
111 changes: 111 additions & 0 deletions tests/TextTransform_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
import { assert, assertEquals } from "jsr:@std/assert";
import {
default_options, transform, transform_default,
get_metrics, metrics_to_string,
check_constraints, format_for_html, format_for_js,
} from "../src/core/TextTransform.deno.js";
import { LF, CRLF } from "../stdlib/SafeWhitespace.deno.js";

Deno.test("TextTransform: transform trims lines when option set", () => {
const opts = { ...default_options(), trim_document: false, ensure_final_newline: false };
const result = transform(" hello \n world ", opts);
assertEquals(result.includes(" hello"), false);
});

Deno.test("TextTransform: transform collapses spaces", () => {
const opts = { ...default_options(), trim_document: false, ensure_final_newline_opt: false, collapse_spaces_opt: true };
const result = transform("hello world", opts);
assertEquals(result.includes(" "), false);
});

Deno.test("TextTransform: transform normalizes CRLF to LF", () => {
const opts = { ...default_options(), target_line_ending: LF };
const result = transform("line1\r\nline2\r\nline3", opts);
assertEquals(result.includes("\r\n"), false);
assertEquals(result.includes("\r"), false);
});

Deno.test("TextTransform: transform normalizes LF to CRLF", () => {
const opts = { ...default_options(), target_line_ending: CRLF, ensure_final_newline_opt: false };
const result = transform("line1\nline2", opts);
assertEquals(result.includes("\r\n"), true);
});

Deno.test("TextTransform: transform collapses excess blank lines", () => {
const opts = { ...default_options(), max_blank_lines: 1, ensure_final_newline: false };
const result = transform("para1\n\n\n\n\npara2", opts);
assertEquals(result.includes("\n\n\n"), false);
});

Deno.test("TextTransform: transform ensures final newline", () => {
const opts = { ...default_options(), ensure_final_newline: true };
assertEquals(transform("no newline", opts).endsWith("\n"), true);
});

Deno.test("TextTransform: transform_default returns a string", () => {
const result = transform_default(" test ");
assertEquals(typeof result, "string");
});

Deno.test("TextTransform: get_metrics counts chars", () => {
assertEquals(get_metrics("Hello World").chars, 11);
});

Deno.test("TextTransform: get_metrics counts words", () => {
assertEquals(get_metrics("Hello World Test").words, 3);
});

Deno.test("TextTransform: get_metrics counts lines", () => {
assertEquals(get_metrics("Line 1\nLine 2\nLine 3").lines, 3);
});

Deno.test("TextTransform: metrics_to_string includes char count", () => {
const m = get_metrics("Hello World");
const s = metrics_to_string(m);
assertEquals(s.includes("11"), true);
});

Deno.test("TextTransform: check_constraints detects char limit exceeded", () => {
const c = { max_chars: { tag: "Some", value: 5 }, max_words: { tag: "None" }, max_lines: { tag: "None" }, max_bytes: { tag: "None" } };
const violations = check_constraints("This is a long string", c);
assert(violations.length > 0);
});

Deno.test("TextTransform: check_constraints passes when within limit", () => {
const c = { max_chars: { tag: "Some", value: 100 }, max_words: { tag: "None" }, max_lines: { tag: "None" }, max_bytes: { tag: "None" } };
assertEquals(check_constraints("Short", c).length, 0);
});

Deno.test("TextTransform: check_constraints detects word limit exceeded", () => {
const c = { max_chars: { tag: "None" }, max_words: { tag: "Some", value: 3 }, max_lines: { tag: "None" }, max_bytes: { tag: "None" } };
const violations = check_constraints("one two three four five", c);
assert(violations.length > 0);
});

Deno.test("TextTransform: check_constraints detects line limit exceeded", () => {
const c = { max_chars: { tag: "None" }, max_words: { tag: "None" }, max_lines: { tag: "Some", value: 2 }, max_bytes: { tag: "None" } };
const violations = check_constraints("a\nb\nc\nd", c);
assert(violations.length > 0);
});

Deno.test("TextTransform: format_for_html escapes < and >", () => {
const result = format_for_html("<script>xss</script>");
assertEquals(result.includes("<script>"), false);
assertEquals(result.includes("&lt;"), true);
});

Deno.test("TextTransform: format_for_html escapes &", () => {
assertEquals(format_for_html("a & b").includes("&amp;"), true);
});

Deno.test("TextTransform: format_for_js escapes newlines", () => {
const result = format_for_js("line1\nline2");
assertEquals(result.includes("\\n"), true);

Check warning on line 105 in tests/TextTransform_test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_empty-linter&issues=AZ7D6siXq26mY3X2OHFL&open=AZ7D6siXq26mY3X2OHFL&pullRequest=25
});

Deno.test("TextTransform: format_for_js escapes double quotes", () => {
const result = format_for_js('say "hello"');
assertEquals(result.includes('\\"'), true);

Check warning on line 110 in tests/TextTransform_test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_empty-linter&issues=AZ7D6siXq26mY3X2OHFM&open=AZ7D6siXq26mY3X2OHFM&pullRequest=25
});
Loading