Skip to content

Commit e49bdfd

Browse files
hyperpolymathclaude
andcommitted
test(affine): add 66-test suite; fix byte_to_hex, transform ordering
- Add tests/ covering ByteDetector (17), PathHandler (17), SafeWhitespace (13), TextTransform (19) — all 66 pass - Fix byte_to_hex to handle multi-byte codepoints (>0xFF) - Move normalize_line_endings to run after trim_lines_fn so JS .trim() cannot strip \r from CRLF-normalized lines - Add scripts/build-all.sh; deno.json build-all delegates to it (deno task shell does not support POSIX for-in loops) - Inject missing cross-module symbols into TextTransform.deno.js via post-compile Python patch (compiler issue #122 workaround): LF/CRLF/CR constants, is_invisible helper, concat alias Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e155e6a commit e49bdfd

9 files changed

Lines changed: 364 additions & 5 deletions

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"exports": "./EmptyLinter.deno.js",
55
"tasks": {
66
"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",
7-
"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",
7+
"build-all": "bash scripts/build-all.sh",
88
"clean": "find . -name '*.deno.js' ! -path './stdlib/*' -delete",
99
"dev": "while true; do affinescript compile --deno-esm EmptyLinter.affine -o EmptyLinter.deno.js 2>&1; sleep 2; done",
1010
"test": "deno test --allow-read --allow-write tests/",

deno.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/build-all.sh

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# SPDX-FileCopyrightText: 2026 hyperpolymath
4+
set -euo pipefail
5+
6+
SOURCES=(
7+
stdlib/SafeHex.affine
8+
stdlib/SafeWhitespace.affine
9+
stdlib/SafePath.affine
10+
stdlib/SafeString.affine
11+
src/core/ByteDetector.affine
12+
src/core/TextTransform.affine
13+
src/core/PathHandler.affine
14+
EmptyLinter.affine
15+
src/cli/Main.affine
16+
)
17+
18+
for f in "${SOURCES[@]}"; do
19+
affinescript compile --deno-esm "$f" -o "${f%.affine}.deno.js"
20+
done
21+
22+
# Workaround: AffineScript alpha compiler (issue #122) does not fully inline
23+
# cross-module dependencies into TextTransform.deno.js. Inject missing symbols
24+
# after compilation: LF/CRLF/CR (zero-arg enum constructors), is_invisible
25+
# (private helper from SafeWhitespace), concat (string stdlib fn).
26+
python3 - << 'PYEOF'
27+
PATCH = """\
28+
const LF={tag:"LF"};const CRLF={tag:"CRLF"};const CR={tag:"CR"};
29+
function is_invisible(c){return(c===0||c===160||c===8203||c===65279||c===173||c===8206||c===8207||c===8204||c===8205||c===8288);}
30+
function concat(a,b){return __as_concat(a,b);}
31+
"""
32+
MARKER = "// ---- end runtime ----"
33+
path = "src/core/TextTransform.deno.js"
34+
with open(path) as fh:
35+
content = fh.read()
36+
if PATCH.strip().split("\n")[0] not in content:
37+
content = content.replace(MARKER, MARKER + "\n" + PATCH, 1)
38+
with open(path, "w") as fh:
39+
fh.write(content)
40+
PYEOF
41+
42+
echo "build-all complete"

src/core/ByteDetector.affine

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ pub fn get_artifact_def(byte_val: Int) -> Option<ArtifactDef> {
5252
}
5353

5454
pub fn byte_to_hex(v: Int) -> String {
55-
encode_byte(v & 255)
55+
if v <= 255 {
56+
encode_byte(v)
57+
} else if v <= 65535 {
58+
encode_byte((v >> 8) & 255) ++ encode_byte(v & 255)
59+
} else {
60+
encode_byte((v >> 16) & 255) ++ encode_byte((v >> 8) & 255) ++ encode_byte(v & 255)
61+
}
5662
}
5763

5864
pub fn scan(content: String) -> [Artifact] {

src/core/TextTransform.affine

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,6 @@ pub fn transform(content: String, options: TransformOptions) -> String {
5252
if options.remove_invisibles_opt {
5353
s = remove_invisibles(s);
5454
}
55-
if options.normalize_line_endings_opt {
56-
s = normalize_line_endings(s, options.target_line_ending);
57-
}
5855
if options.collapse_spaces_opt {
5956
s = collapse_spaces(s);
6057
}
@@ -65,6 +62,9 @@ pub fn transform(content: String, options: TransformOptions) -> String {
6562
if options.trim_document {
6663
s = trim(s);
6764
}
65+
if options.normalize_line_endings_opt {
66+
s = normalize_line_endings(s, options.target_line_ending);
67+
}
6868
if options.ensure_final_newline_opt {
6969
s = ensure_final_newline(s);
7070
}

tests/ByteDetector_test.js

3.54 KB
Binary file not shown.

tests/PathHandler_test.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
import { assertEquals } from "jsr:@std/assert";
4+
import {
5+
validate, unwrap_path, path_join, sanitize,
6+
is_within, get_parent, filename, has_extension,
7+
is_excluded, from_trusted,
8+
TraversalDetected,
9+
} from "../src/core/PathHandler.deno.js";
10+
11+
Deno.test("PathHandler: validate accepts relative paths", () => {
12+
const p = validate("src/main.affine");
13+
assertEquals(p.tag, "Some");
14+
assertEquals(unwrap_path(p.value), "src/main.affine");
15+
});
16+
17+
Deno.test("PathHandler: validate rejects absolute paths", () => {
18+
assertEquals(validate("/etc/passwd").tag, "None");
19+
});
20+
21+
Deno.test("PathHandler: validate rejects path traversal", () => {
22+
assertEquals(validate("../../etc/passwd").tag, "None");
23+
});
24+
25+
Deno.test("PathHandler: validate rejects embedded traversal", () => {
26+
assertEquals(validate("src/../../../etc").tag, "None");
27+
});
28+
29+
Deno.test("PathHandler: sanitize removes dangerous characters", () => {
30+
const clean = sanitize("file<name>.txt");
31+
assertEquals(clean.includes("<"), false);
32+
assertEquals(clean.includes(">"), false);
33+
});
34+
35+
Deno.test("PathHandler: sanitize replaces slashes", () => {
36+
const clean = sanitize("path/to/file");
37+
assertEquals(clean.includes("/"), false);
38+
});
39+
40+
Deno.test("PathHandler: path_join creates valid joined path", () => {
41+
const base = from_trusted("docs");
42+
const result = path_join(base, ["notes", "file.txt"]);
43+
assertEquals(result.tag, "Ok");
44+
assertEquals(unwrap_path(result.value), "docs/notes/file.txt");
45+
});
46+
47+
Deno.test("PathHandler: path_join rejects traversal in components", () => {
48+
const base = from_trusted("home");
49+
const result = path_join(base, ["..", "..", "etc"]);
50+
assertEquals(result.tag, "Err");
51+
assertEquals(result.error.tag, "TraversalDetected");
52+
});
53+
54+
Deno.test("PathHandler: filename extracts basename", () => {
55+
const p = from_trusted("docs/reports/file.pdf");
56+
assertEquals(filename(p), "file.pdf");
57+
});
58+
59+
Deno.test("PathHandler: filename handles no directory", () => {
60+
assertEquals(filename(from_trusted("file.txt")), "file.txt");
61+
});
62+
63+
Deno.test("PathHandler: has_extension checks extension", () => {
64+
const p = from_trusted("src/main.affine");
65+
assertEquals(has_extension(p, ".affine"), true);
66+
assertEquals(has_extension(p, ".js"), false);
67+
});
68+
69+
Deno.test("PathHandler: get_parent extracts directory", () => {
70+
const p = from_trusted("home/user/docs/file.txt");
71+
const parent = get_parent(p);
72+
assertEquals(parent.tag, "Some");
73+
assertEquals(unwrap_path(parent.value), "home/user/docs");
74+
});
75+
76+
Deno.test("PathHandler: get_parent returns None for no directory", () => {
77+
assertEquals(get_parent(from_trusted("file.txt")).tag, "None");
78+
});
79+
80+
Deno.test("PathHandler: is_within checks path containment", () => {
81+
const p = from_trusted("home/user/docs");
82+
const base = from_trusted("home/user");
83+
assertEquals(is_within(p, base), true);
84+
});
85+
86+
Deno.test("PathHandler: is_within rejects unrelated paths", () => {
87+
const p = from_trusted("etc/passwd");
88+
const base = from_trusted("home/user");
89+
assertEquals(is_within(p, base), false);
90+
});
91+
92+
Deno.test("PathHandler: is_excluded matches excluded dirs", () => {
93+
const p = from_trusted("project/node_modules/pkg/index.js");
94+
assertEquals(is_excluded(p, ["node_modules", ".git"]), true);
95+
});
96+
97+
Deno.test("PathHandler: is_excluded allows non-excluded paths", () => {
98+
const p = from_trusted("project/src/main.affine");
99+
assertEquals(is_excluded(p, ["node_modules", ".git"]), false);
100+
});

tests/SafeWhitespace_test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
import { assertEquals } from "jsr:@std/assert";
4+
import {
5+
LF, CRLF, CR,
6+
remove_invisibles, normalize_line_endings,
7+
collapse_spaces, collapse_blank_lines,
8+
trim_start, trim_end, ensure_final_newline,
9+
detect_invisibles,
10+
} from "../stdlib/SafeWhitespace.deno.js";
11+
12+
Deno.test("SafeWhitespace: trim_start removes leading whitespace", () => {
13+
assertEquals(trim_start(" hello"), "hello");
14+
assertEquals(trim_start("\t\nhello"), "hello");
15+
assertEquals(trim_start("hello"), "hello");
16+
});
17+
18+
Deno.test("SafeWhitespace: trim_end removes trailing whitespace", () => {
19+
assertEquals(trim_end("hello "), "hello");
20+
assertEquals(trim_end("hello\t\n"), "hello");
21+
assertEquals(trim_end("hello"), "hello");
22+
});
23+
24+
Deno.test("SafeWhitespace: collapse_spaces reduces multiple spaces", () => {
25+
assertEquals(collapse_spaces("hello world"), "hello world");
26+
assertEquals(collapse_spaces("a b c"), "a b c");
27+
});
28+
29+
Deno.test("SafeWhitespace: collapse_spaces preserves single spaces", () => {
30+
assertEquals(collapse_spaces("hello world"), "hello world");
31+
});
32+
33+
Deno.test("SafeWhitespace: collapse_blank_lines reduces excess blank lines", () => {
34+
const result = collapse_blank_lines("para1\n\n\n\npara2", 1);
35+
assertEquals(result.includes("\n\n\n"), false);
36+
});
37+
38+
Deno.test("SafeWhitespace: normalize_line_endings converts CRLF to LF", () => {
39+
const result = normalize_line_endings("line1\r\nline2", LF);
40+
assertEquals(result.includes("\r"), false);
41+
});
42+
43+
Deno.test("SafeWhitespace: normalize_line_endings converts LF to CRLF", () => {
44+
const result = normalize_line_endings("line1\nline2", CRLF);
45+
assertEquals(result.includes("\r\n"), true);
46+
});
47+
48+
Deno.test("SafeWhitespace: ensure_final_newline adds newline when missing", () => {
49+
assertEquals(ensure_final_newline("hello").endsWith("\n"), true);
50+
});
51+
52+
Deno.test("SafeWhitespace: ensure_final_newline idempotent when present", () => {
53+
const result = ensure_final_newline("hello\n");
54+
assertEquals(result, "hello\n");
55+
});
56+
57+
Deno.test("SafeWhitespace: remove_invisibles strips known invisible chars", () => {
58+
const result = remove_invisibles("​hello");
59+
assertEquals(result.includes("​"), false);
60+
assertEquals(result.includes(""), false);
61+
});
62+
63+
Deno.test("SafeWhitespace: detect_invisibles finds NBSP", () => {
64+
const found = detect_invisibles("hello world");
65+
assertEquals(found.length, 1);
66+
assertEquals(found[0], 0xa0);
67+
});
68+
69+
Deno.test("SafeWhitespace: detect_invisibles empty for clean string", () => {
70+
assertEquals(detect_invisibles("hello world").length, 0);
71+
});
72+
73+
Deno.test("SafeWhitespace: LineEnding constants have correct tags", () => {
74+
assertEquals(LF.tag, "LF");
75+
assertEquals(CRLF.tag, "CRLF");
76+
assertEquals(CR.tag, "CR");
77+
});

0 commit comments

Comments
 (0)