From eec727a0db16ca0538fb3539cbfc9c47fddb63f2 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:08:36 +0100 Subject: [PATCH 1/5] fix(ci): restore real Empty-linter audit on Bun --- .github/workflows/dogfood-gate.yml | 68 ++--- .gitignore | 6 +- EXPLAINME.adoc | 10 +- Justfile | 116 +++++---- README.adoc | 197 +++++++-------- TOPOLOGY.adoc | 138 ++++------ deno.lock | 23 -- examples/web-project-bun.json | 13 + examples/web-project-deno.json | 20 -- k9iser.toml | 10 +- mise.toml | 3 +- package.json | 13 + scripts/build-all.sh | 41 +-- scripts/empty-lint-ci.js | 178 +++++++++++++ src/bindings/Deno.affine | 7 - src/core/ByteDetector.affine | 7 - src/core/ByteDetector.bun.js | 234 +++++++++++++++++ stapeln.toml | 8 +- stdlib/ByteDetector.affine | 6 + stdlib/Deno.affine | 388 ----------------------------- tests/ByteDetector_test.js | Bin 3606 -> 4588 bytes tests/PathHandler_test.js | 42 ++-- tests/SafeWhitespace_test.js | 46 ++-- tests/TextTransform_test.js | 52 ++-- tests/empty_lint_ci_test.js | 78 ++++++ 25 files changed, 854 insertions(+), 850 deletions(-) delete mode 100644 deno.lock create mode 100644 examples/web-project-bun.json delete mode 100644 examples/web-project-deno.json create mode 100644 package.json create mode 100644 scripts/empty-lint-ci.js delete mode 100644 src/bindings/Deno.affine delete mode 100644 src/core/ByteDetector.affine create mode 100644 src/core/ByteDetector.bun.js delete mode 100644 stdlib/Deno.affine create mode 100644 tests/empty_lint_ci_test.js diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 0857e9a..9eb7d88 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -69,8 +69,8 @@ jobs: run: | COUNT=$(find . \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path './.git/*' | wc -l) CONFIG_COUNT=$(find . \( -name '*.toml' -o -name '*.yaml' -o -name '*.yml' -o -name '*.json' \) \ - -not -path './.git/*' -not -path './node_modules/*' -not -path './.deno/*' \ - -not -name 'package-lock.json' -not -name 'Cargo.lock' -not -name 'deno.lock' | wc -l) + -not -path './.git/*' -not -path './node_modules/*' \ + -not -name 'package-lock.json' -not -name 'Cargo.lock' -not -name 'bun.lock' | wc -l) echo "k9_count=$COUNT" >> "$GITHUB_OUTPUT" echo "config_count=$CONFIG_COUNT" >> "$GITHUB_OUTPUT" if [ "$COUNT" -eq 0 ] && [ "$CONFIG_COUNT" -gt 0 ]; then @@ -109,57 +109,23 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Scan for invisible characters - id: lint - run: | - # Inline invisible character detection (from empty-linter's core patterns). - # Checks for: zero-width spaces, zero-width joiners, BOM, soft hyphens, - # non-breaking spaces, null bytes, and other invisible Unicode in source files. - set +e - PATTERNS='\x00|[\x01-\x08\x0B\x0C\x0E-\x1F]|\x{a0}|\x{ad}|\x{200b}|\x{200c}|\x{200d}|\x{200e}|\x{200f}|\x{202a}|\x{202b}|\x{202c}|\x{202d}|\x{202e}|\x{2060}|\x{feff}' - find "$GITHUB_WORKSPACE" \ - -not -path '*/.git/*' -not -path '*/node_modules/*' \ - -not -path '*/.deno/*' -not -path '*/target/*' \ - -not -path '*/_build/*' -not -path '*/deps/*' \ - -not -path '*/external_corpora/*' -not -path '*/.lake/*' \ - -type f \( -name '*.rs' -o -name '*.ex' -o -name '*.exs' -o -name '*.res' \ - -o -name '*.js' -o -name '*.ts' -o -name '*.json' -o -name '*.toml' \ - -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' -o -name '*.adoc' \ - -o -name '*.idr' -o -name '*.zig' -o -name '*.v' -o -name '*.jl' \ - -o -name '*.gleam' -o -name '*.hs' -o -name '*.ml' -o -name '*.sh' \) \ - -exec grep -aPrl "$PATTERNS" {} \; > /tmp/empty-lint-results.txt 2>/dev/null - EL_EXIT=$? - set -e - - FINDINGS=$(wc -l < /tmp/empty-lint-results.txt 2>/dev/null || echo 0) - echo "findings=$FINDINGS" >> "$GITHUB_OUTPUT" - echo "exit_code=$EL_EXIT" >> "$GITHUB_OUTPUT" - echo "ready=true" >> "$GITHUB_OUTPUT" - - # Emit annotations for each file with invisible chars - while IFS= read -r filepath; do - [ -z "$filepath" ] && continue - REL_PATH="${filepath#$GITHUB_WORKSPACE/}" - echo "::warning file=${REL_PATH}::Invisible Unicode characters detected (zero-width space, BOM, NBSP, etc.)" - done < /tmp/empty-lint-results.txt + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + - name: Test the compiled scanner and planted controls + run: bun test tests/ByteDetector_test.js tests/empty_lint_ci_test.js + - name: Audit repository with Empty-linter + run: bun run scripts/empty-lint-ci.js --threshold critical . - name: Write summary + if: always() run: | - if [ "${{ steps.lint.outputs.ready }}" = "true" ]; then - FINDINGS="${{ steps.lint.outputs.findings }}" - if [ "$FINDINGS" -gt 0 ] 2>/dev/null; then - echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Found **${FINDINGS}** invisible character issue(s). See annotations above." >> "$GITHUB_STEP_SUMMARY" - else - echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo ":white_check_mark: No invisible character issues found." >> "$GITHUB_STEP_SUMMARY" - fi - else - echo "## Empty-Linter" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Skipped: empty-linter not available." >> "$GITHUB_STEP_SUMMARY" - fi + { + echo "## Empty-linter audit" + echo "" + echo "The Bun-targeted Empty-linter core was tested with planted clean, critical, advisory, and scan-error cases, then used to audit the repository." + echo "Critical findings block this gate; lower-severity Unicode findings are currently advisory." + } >> "$GITHUB_STEP_SUMMARY" # --------------------------------------------------------------------------- # Job 4: Groove manifest check (for repos that should expose services) # --------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index 0305679..bd17951 100644 --- a/.gitignore +++ b/.gitignore @@ -88,4 +88,8 @@ deps/ .cache/ build/ dist/ -*.deno.js +*.bun.js +# Empty-linter's CI entry point is a reviewed compiler artefact. Keeping this +# one output makes the audit runnable without installing an unpublished local +# AffineScript compiler; `just build` regenerates it from the canonical source. +!src/core/ByteDetector.bun.js diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index 9fc1d64..387a756 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -18,7 +18,7 @@ ____ | Technology | Learn More | **Zig** | https://ziglang.org -| **Deno** | https://deno.land +| **Bun** | https://bun.sh | **AffineScript** | https://affinescript-lang.org | **Idris2 ABI** | https://www.idris-lang.org |=== @@ -39,9 +39,15 @@ https://github.com/hyperpolymath/gossamer[gossamer]. | `src/` | Source code | `lib/` | Library code | `ffi/` | Foreign function interface -| `test(s)/` | Test suite +| `tests/` | Bun test suite +| `scripts/empty-lint-ci.js` | Implemented read-only repository audit CLI +| `src/core/ByteDetector.bun.js` | Reviewed Bun-targeted compiler artefact |=== +The current receipt covers the basic detector and audit gate only. The TUI, +settings loader, document-container detectors, automatic repair, and proof +integration remain open work and are not implied by this file. + == Questions? Open an issue or reach out directly — happy to explain anything in more detail. diff --git a/Justfile b/Justfile index c88ed52..8d4a448 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell set shell := ["bash", "-uc"] set dotenv-load := true set positional-arguments := true @@ -21,20 +21,28 @@ default: # BUILD & COMPILE # ═══════════════════════════════════════════════════════════════════════════════ -# Transpile AffineScript to JS for Deno runtime +# Compile AffineScript to ESM, then bundle and tree-shake it for Bun. The +# AffineScript compiler's direct ESM switch is still named --deno-esm; the +# intermediate stays under ignored build/ and is never the shipped runtime. build: @echo "Building {{project}}..." - affinescript build + @set -euo pipefail; \ + compiler="${AFFINESCRIPT_BIN:-affinescript}"; \ + rm -f build/ByteDetector.affine.esm.js; \ + "$compiler" check stdlib/ByteDetector.affine; \ + mkdir -p build; \ + "$compiler" compile --deno-esm -o build/ByteDetector.affine.esm.js stdlib/ByteDetector.affine; \ + bun build build/ByteDetector.affine.esm.js --outfile src/core/ByteDetector.bun.js --target bun # Clean build artifacts clean: @echo "Cleaning {{project}}..." - affinescript clean + @rm -f build/ByteDetector.affine.esm.js -# Watch mode for development +# Watch mode is not yet connected to the two-stage Bun build dev: - @echo "Starting watch mode..." - affinescript build -w + @echo "empty-linter: watch mode is not implemented for the Bun build" >&2 + @exit 2 # ═══════════════════════════════════════════════════════════════════════════════ # TESTING @@ -42,18 +50,18 @@ dev: # Run all tests test: build - @echo "Running tests..." - deno test --allow-read --allow-write tests/ + @echo "Running implemented core and CI audit tests..." + bun test tests/ByteDetector_test.js tests/empty_lint_ci_test.js # Run tests with verbose output test-verbose: build @echo "Running tests (verbose)..." - deno test --allow-read --allow-write tests/ --trace-leaks + bun test --verbose tests/ByteDetector_test.js tests/empty_lint_ci_test.js # Run specific test file test-file file: build @echo "Running {{file}}..." - deno test --allow-read --allow-write tests/{{file}} + bun test tests/{{file}} # ═══════════════════════════════════════════════════════════════════════════════ # LINT & FORMAT (The Crap-Overlay) @@ -61,23 +69,26 @@ test-file file: build # Audit the project for invisible "crap" voids (Magenta Overlay) audit path=".": build - @deno run --allow-read src/cli/Main.res.js audit {{path}} + @bun run scripts/empty-lint-ci.js {{path}} # Quick audit using direct module audit-quick path=".": build - @deno run --allow-read EmptyLinter.res.js {{path}} + @bun run scripts/empty-lint-ci.js {{path}} -# Enforce symbolic intent - auto-fix all artifacts +# Refuse unavailable automatic repair fix path=".": build - @deno run --allow-read --allow-write src/cli/Main.res.js fix {{path}} + @echo "empty-linter: automatic repair is not implemented; audit and review findings instead" >&2 + @exit 2 -# Transform text using default options +# Refuse unavailable transformations transform path: build - @deno run --allow-read --allow-write src/cli/Main.res.js transform {{path}} + @echo "empty-linter: transformation is not implemented" >&2 + @exit 2 -# Check against workspace constraints +# Refuse unavailable workspace constraints check path workspace="twitter": build - @deno run --allow-read src/cli/Main.res.js check -w {{workspace}} {{path}} + @echo "empty-linter: workspace constraints are not implemented" >&2 + @exit 2 # ═══════════════════════════════════════════════════════════════════════════════ # DOCUMENTATION @@ -107,9 +118,10 @@ cookbook: # UTILITIES # ═══════════════════════════════════════════════════════════════════════════════ -# Generate the multi-shell registry (nushell, fish, minix, etc) +# Refuse unavailable shell-wrapper generation gen-shells: - @deno run --allow-write scripts/generate_wrappers.ts + @echo "empty-linter: shell-wrapper generation is not implemented" >&2 + @exit 2 # Run panic-attacker pre-commit scan assail: @@ -140,17 +152,17 @@ doctor: } check "just" just "1.25" check "git" git "2.40" - check "Deno" deno "2.0" - check "AffineScript (resc)" affinescript "12.0" + check "Bun" bun "1.3" + check "AffineScript" affinescript "0.1" check "Zig" zig "0.13" -# Optional tools -if command -v panic-attack >/dev/null 2>&1; then - echo " [OK] panic-attack — available" - PASS=$((PASS + 1)) -else - echo " [WARN] panic-attack — not found (pre-commit scanner)" - WARN=$((WARN + 1)) -fi + # Optional tools + if command -v panic-attack >/dev/null 2>&1; then + echo " [OK] panic-attack — available" + PASS=$((PASS + 1)) + else + echo " [WARN] panic-attack — not found (pre-commit scanner)" + WARN=$((WARN + 1)) + fi echo "" echo " Result: $PASS passed, $FAIL failed, $WARN warnings" if [ "$FAIL" -gt 0 ]; then @@ -159,24 +171,19 @@ fi fi echo " All required tools present." -# Attempt to automatically install missing tools +# Report missing tools without installing software heal: #!/usr/bin/env bash echo "═══════════════════════════════════════════════════" echo " Empty Linter Heal — Automatic Tool Installation" echo "═══════════════════════════════════════════════════" echo "" -if ! command -v deno >/dev/null 2>&1; then - echo "Installing Deno..." - curl -fsSL https://deno.land/install.sh | sh -fi -# Install Deno dependencies -echo "Installing Deno dependencies..." -deno install 2>/dev/null || true -if ! command -v just >/dev/null 2>&1; then - echo "Installing just..." - cargo install just 2>/dev/null || echo "Install just from https://just.systems" -fi + if ! command -v bun >/dev/null 2>&1; then + echo "Bun is required. Install it using the estate toolchain instructions." + fi + if ! command -v just >/dev/null 2>&1; then + echo "Just is required. Install it using the estate toolchain instructions." + fi echo "" echo "Heal complete. Run 'just doctor' to verify." @@ -216,20 +223,19 @@ help-me: echo " Empty Linter — Common Workflows" echo "═══════════════════════════════════════════════════" echo "" -echo "FIRST TIME SETUP:" -echo " just doctor Check toolchain" -echo " just heal Fix missing tools" -echo "" + echo "FIRST TIME SETUP:" + echo " just doctor Check toolchain" + echo " just heal Report missing tools" + echo "" echo "DEVELOPMENT:" - echo " deno task dev Development server" - echo " deno test Run tests" + echo " bun test Run implemented tests" echo "" -echo "PRE-COMMIT:" -echo " just assail Run panic-attacker scan" -echo "" -echo "LEARN:" -echo " just tour Guided project tour" -echo " just default List all recipes" + echo "PRE-COMMIT:" + echo " just assail Run panic-attacker scan" + echo "" + echo "LEARN:" + echo " just tour Guided project tour" + echo " just default List all recipes" # Print the current CRG grade (reads from READINESS.md '**Current Grade:** X' line) diff --git a/README.adoc b/README.adoc index 55610eb..d1523cf 100644 --- a/README.adoc +++ b/README.adoc @@ -1,119 +1,92 @@ -image:https://img.shields.io/badge/Overlay-Magenta-brightgreen.svg[Crap-Overlay] -image:https://img.shields.io/badge/Logic-AffineScript-orange.svg[AffineScript] -image:https://img.shields.io/badge/Runtime-Deno-white.svg[Deno] -image:https://img.shields.io/badge/Idris_Inside-proven-purple.svg[Idris -Inside] - -== The Objective - -*empty-linter* is a toolkit designed to see "`what is not there.`" It -purges invisible artifacts—​NBSPs, Zero-Width spaces, and null bytes—​that -corrupt file integrity and cause neural-generated character mess to fail -in symbolic parsers. - -It acts as the "`Eyes`" for agents, enforcing symbolic structural intent -over hidden "`crap-voids.`" - -* *Config:* Managed via `+config.ncl+` (Nickel). -* *Task Runner:* Managed via `+Justfile+` (Just). -* *Logic:* Written in AffineScript, executed via Deno. -* *Verification:* Powered by -https://github.com/hyperpolymath/proven[proven library] with Idris 2 -dependent types. -* *Deployment:* nerdctl-first, supporting Linux, Minix, macOS, iOS, -Android, and PC. - -== Idris Inside: Mathematically Verified Operations - -This project uses the *proven* library for mathematically verified -operations. The following modules are integrated: - -[width="100%",cols="34%,33%,33%",options="header",] -|=== -|Module |Purpose |Source File -|SafeHex |Constant-time hex encoding/decoding |`+ByteDetector.res+` - -|SafePath |Traversal-proof path validation |`+PathHandler.res+` - -|SafeString |XSS/SQL injection prevention |`+TextTransform.res+` - -|SafeWhitespace |Text normalization without data loss (NEW) -|`+TextTransform.res+` -|=== - -Operations marked with "`Idris Inside`" have compile-time proofs that -they cannot crash or corrupt data. - -== Crap-Overlay Playbook - -[arabic] -. *Audit:* Identify invisible artifacts. -. *Highlight:* Magenta-coded reporting of offsets. -. *Correct:* 0xA0 to 0x20 conversion and ZWSP stripping. - -== Quick Start (Bash) - -The primary entry point for Bash is provided below. For all other -supported shells (including `+nushell+`, `+fish+`, `+minix+` `+shell+`, -`+elvish+`, etc.), please refer to link:docs/SHELLS.adoc[The Multi-Shell -Registry]. - -[source,bash] ----- -#!/usr/bin/env bash -# Primary Bash wrapper for empty-linter -# Usage: ./bin/empty-linter.sh [directory] +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Empty-linter +:toc: left -TARGET=${1:-"."} - -if ! command -v deno &> /dev/null; then - echo "Deno not found. Please install Deno to run empty-linter." - exit 1 -fi - -deno run --allow-read lib/js/src/EmptyLinter.bs.js "$TARGET" ----- - -== Usage via Just - -We prefer the use of `+just+` for all development tasks. Refer to the -link:cookbook.adoc[Cookbook] for a full list of recipes. +image:https://img.shields.io/badge/Logic-AffineScript-orange.svg[AffineScript] +image:https://img.shields.io/badge/Runtime-Bun-black.svg[Bun] + +Empty-linter finds characters and structures that appear empty or invisible but +can change, corrupt, or cause the rejection of a document. Its originating case +was a document repeatedly rejected by the IETF Datatracker because an editor had +inserted a hidden character that was difficult to locate visually. + +== Current, demonstrated capability + +The implemented minimum is deliberately smaller than the intended product: + +* the AffineScript detector recognises NUL, unsafe C0 controls, DEL, NBSP, + ZWSP, BOM, soft hyphen, LRM/RLM, word joiner, ZWNJ, and ZWJ; +* the Bun CLI recursively audits a conservative set of text/source extensions; +* findings include file, one-based line, one-based string column, code point, + name, and severity; +* audit mode never modifies input; +* exit `0` means the scan completed without a finding at the selected threshold, + exit `1` means a policy finding was detected, and exit `2` means the scan did + not complete; +* CI plants clean, critical, advisory, and scan-error cases before auditing this + repository; critical findings block, while lower severities are initially + advisory. [source,bash] ---- -# Run the Magenta crap-overlay report -just audit - -# Sanitise the codebase (Auto-fix voids) -just correct ----- - -== Configuration (Nickel) - -All structural intent is defined in `+config.ncl+`: - -[source,nickel] ----- -{ - linter = { - target_dir = ".", - severity = "error", - overlay_color = "magenta", - auto_fix = true, - } -} ----- - -== Deployment +just build +just test +just audit . -Deployment is handled via Podman to ensure cross-platform compatibility -across edge tech and ASICs. - -[source,bash] +# Tighten the policy when desired +bun run scripts/empty-lint-ci.js --threshold warning . ---- -just deploy-nerctl ----- - -== Architecture -See TOPOLOGY for a visual architecture map and completion dashboard. +The reviewed `src/core/ByteDetector.bun.js` artefact is checked in so CI and +consumers do not depend on an unpublished local compiler. `just build` +regenerates it from `stdlib/ByteDetector.affine`. AffineScript currently names +its direct exportable ESM backend `--deno-esm`; that ignored intermediate is +immediately bundled and tree-shaken by Bun, and no Deno runtime artefact ships. +A native Bun-labelled AffineScript backend remains an upstream task; see +https://github.com/hyperpolymath/affinescript/issues/734[AffineScript issue 734]. + +== Not implemented yet + +These are requirements, not delivered claims: + +* a TUI and a functioning settings/configuration loader; +* exact UTF-8 byte offsets and visible context rendering; +* the broader Unicode/control/bidirectional/tag/variation detector catalogue; +* Zalgo and suspicious combining-mark analysis; +* hidden text, zero-size fonts, and Office/OpenXML/PDF/publisher artefacts; +* spreadsheet formula cells whose displayed result is empty; +* safe patch generation, review, provenance, and rescan records; +* automatic repair, transformation profiles, and workspace constraints; +* Formatrix Docs, Blocky Writer, Docmatrix, Berrywiki, ProgBlocks, Groove, + Spline, Cleave, or ForthWall integration; +* completed Idris proofs for the active scanner and repair path. + +The complete restoration scope and IETF incident fixture are tracked in +https://github.com/hyperpolymath/empty-linter/issues/74[issue 74]. + +Commands for unavailable mutating features refuse with a non-zero status. They +must not be described as demo-complete or silently replaced by inline grep. + +== Safety direction + +The default product posture is `audit`: inspect and report without mutation. +Potentially semantic characters such as joiners must not be removed merely +because they are invisible. Future repair support must produce an inspectable +plan, preserve an immutable input, record provenance, and independently rescan +the candidate output. + +ForthWall is only a proposed critical-mode bounded execution facility. It must +remain disconnected or proposal-only until its authority boundary, operation +semantics, non-interference properties, and independent verifier have been +proved. Critical mode means narrower authority and more evidence, not more +aggressive automation. + +== Runtime and build + +The project runtime is Bun. AffineScript is the detector source language and +Just is the task runner. `mise.toml` describes development tools, but the +minimum CI audit requires only the checked-in Bun artefact and Bun itself. + +See link:TOPOLOGY.adoc[TOPOLOGY] for the implemented boundary and the +non-implemented expansion map. diff --git a/TOPOLOGY.adoc b/TOPOLOGY.adoc index 3ef7ac0..8d3fc18 100644 --- a/TOPOLOGY.adoc +++ b/TOPOLOGY.adoc @@ -1,99 +1,71 @@ -== empty-linter — Project Topology +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Empty-linter topology and readiness -=== System Architecture +== Implemented CI path .... - ┌─────────────────────────────────────────┐ - │ OPERATOR / AGENT │ - │ (just audit / correct / shell) │ - └───────────────────┬─────────────────────┘ - │ - ▼ - ┌─────────────────────────────────────────┐ - │ ORCHESTRATION LAYER │ - │ ┌───────────┐ ┌───────────────────┐ │ - │ │ Nickel │ │ Justfile │ │ - │ │ (config) │ │ (Task Runner) │ │ - │ └─────┬─────┘ └────────┬──────────┘ │ - └────────│─────────────────│──────────────┘ - │ │ - ▼ ▼ - ┌─────────────────────────────────────────┐ - │ LOGIC LAYER (AFFINESCRIPT) │ - │ (Negative-Space Diagnostics, Fixes) │ - │ ┌───────────┐ ┌───────────────────┐ │ - │ │ Audit │ │ Correction │ │ - │ │ Engine │ │ (Sanitization) │ │ - │ └─────┬─────┘ └────────┬──────────┘ │ - └────────│─────────────────│──────────────┘ - │ │ - ▼ ▼ - ┌─────────────────────────────────────────┐ - │ RUNTIME (DENO) │ - │ (Secure FS access, JS execution) │ - └──────────┬───────────────────┬──────────┘ - │ │ - ▼ ▼ - ┌───────────────────────┐ ┌────────────────────────────────┐ - │ IDRIS INSIDE (PROVEN) │ │ TARGET FILESYSTEM │ - │ - SafeWhitespace │ │ (Purging NBSP, ZWSP, │ - │ - SafePath, SafeHex │ │ Null Bytes) │ - └───────────────────────┘ └────────────────────────────────┘ - - ┌─────────────────────────────────────────┐ - │ REPO INFRASTRUCTURE │ - │ Multi-Shell Shims .machine_readable/ │ - │ VS Code Extension Userscripts │ - └─────────────────────────────────────────┘ +stdlib/ByteDetector.affine + │ AffineScript exportable-ESM intermediate (build-only) + ▼ + Bun bundler + │ + ▼ +src/core/ByteDetector.bun.js ──► scripts/empty-lint-ci.js + │ │ + └──────── Bun tests ───────────┤ + ▼ + read-only repository audit + 0 clean / 1 finding / 2 error .... -=== Completion Dashboard +The workflow tests planted positive and negative controls before trusting the +repository result. It does not suppress scanner errors and does not modify +files. -.... -COMPONENT STATUS NOTES -───────────────────────────────── ────────────────── ───────────────────────────────── -DIAGNOSTIC CORE - Audit Engine (AffineScript) ██████████ 100% NBSP/ZWSP detection stable - Correction Logic ██████████ 100% 0xA0 -> 0x20 auto-fix active - Magenta Crap-Overlay ████████░░ 80% Offset reporting verified +== Honest readiness -LOGIC & VERIFICATION - Idris Inside (proven) ██████████ 100% SafeWhitespace modules active - SafePath / SafeHex ██████████ 100% FS access verified - Nickel config.ncl ██████████ 100% Structural intent defined +[cols="2,1,4",options="header"] +|=== +| Capability | Status | Evidence or remaining work -INTERFACES & DEPLOY - Justfile Automation ██████████ 100% Audit/Correct/Deploy recipes - Multi-Shell Registry ████████░░ 80% 18+ shell shims expanding - VS Code Extension ██████░░░░ 60% Real-time linting in progress +| Basic code-point detector +| Implemented +| AffineScript type-check plus 17 Bun unit tests -REPO INFRASTRUCTURE - Deno Tooling ██████████ 100% Secure-by-default execution - .machine_readable/ ██████████ 100% STATE.adoc tracking - Podman / nerdctl build ██████████ 100% Deterministic containers +| Repository audit gate +| Implemented +| Four end-to-end controls; distinct clean/finding/error exits -───────────────────────────────────────────────────────────────────────────── -OVERALL: █████████░ ~90% Alpha release production-ready -.... +| Bun runtime +| Implemented for the active path +| Bun-targeted bundled artefact, CLI, tests, and SHA-pinned CI setup -=== Key Dependencies +| Full detector catalogue +| Not implemented +| Unicode, document-container, hidden-style, spreadsheet, and Zalgo work remains -.... -Nickel Config ───► Just Runner ───► Deno Exec ───► AffineScript Logic - │ │ - ▼ ▼ - Target FS ◄──── Proven Proofs -.... +| Settings and TUI +| Not implemented +| Existing Nickel data is not loaded by the active scanner + +| Automatic repair +| Not implemented +| Must gain conservative plans, approval, provenance, and independent rescan -=== Update Protocol +| Idris-backed scanner proof +| Not demonstrated +| Proof obligations must be connected to the active implementation -This file is maintained by both humans and AI agents. When updating: +| Precision-suite integration +| Not implemented +| Product-owned Groove capabilities and typed Spline records remain design work -[arabic] -. *After completing a component*: Change its bar and percentage -. *After adding a component*: Add a new row in the appropriate section -. *After architectural changes*: Update the ASCII diagram -. *Date*: Update the `+Last updated+` comment at the top of this file +| ForthWall critical execution +| Not implemented +| Blocked on explicit proofs and an independent verifier +|=== -Progress bars use: `+█+` (filled) and `+░+` (empty), 10 characters wide. -Percentages: 0%, 10%, 20%, … 100% (in 10% increments). +No overall completion percentage is published: combining implemented and +aspirational components into one percentage would imply evidence that does not +exist. diff --git a/deno.lock b/deno.lock deleted file mode 100644 index b6ea69d..0000000 --- a/deno.lock +++ /dev/null @@ -1,23 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@*": "1.0.19", - "jsr:@std/internal@^1.0.12": "1.0.14" - }, - "jsr": { - "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.14": { - "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" - } - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@1" - ] - } -} diff --git a/examples/web-project-bun.json b/examples/web-project-bun.json new file mode 100644 index 0000000..cbb6201 --- /dev/null +++ b/examples/web-project-bun.json @@ -0,0 +1,13 @@ +{ + "name": "affinescript-bun-example", + "private": true, + "type": "module", + "engines": { + "bun": ">=1.3.0" + }, + "scripts": { + "build": "affinescript compile --deno-esm -o build/app.affine.esm.js src/App.affine && bun build build/app.affine.esm.js --outfile dist/app.bun.js --target bun", + "test": "bun test", + "serve": "bunx serve ." + } +} diff --git a/examples/web-project-deno.json b/examples/web-project-deno.json deleted file mode 100644 index ee775a4..0000000 --- a/examples/web-project-deno.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "// NOTE": "Example deno.json for AffineScript web projects", - "tasks": { - "build": "deno run -A npm:affinescript", - "clean": "deno run -A npm:affinescript clean", - "watch": "deno run -A npm:affinescript -w", - "serve": "deno run -A jsr:@std/http/file-server .", - "test": "deno test --allow-all" - }, - "imports": { - "affinescript": "^12.0.0", - "@affinescript/core": "npm:@affinescript/core@^1.6.0", - "safe-dom/": "https://raw.githubusercontent.com/hyperpolymath/affinescript-dom-mounter/main/src/", - "proven/": "../proven/bindings/affinescript/src/" - }, - "compilerOptions": { - "allowJs": true, - "checkJs": false - } -} diff --git a/k9iser.toml b/k9iser.toml index 6b14c92..f359374 100644 --- a/k9iser.toml +++ b/k9iser.toml @@ -2,16 +2,16 @@ # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # k9iser manifest for empty-linter -# Empty file linter — Deno-based tool to detect and flag unintentionally empty source files +# Negative-space diagnostics with a Bun audit runtime [project] name = "empty-linter" safety_tier = "hunt" [[source]] -path = "deno.json" -type = "deno" -output = "generated/k9iser/deno-workspace.k9" +path = "package.json" +type = "json" +output = "generated/k9iser/bun-package.k9" [[source]] path = "Justfile" @@ -28,5 +28,5 @@ rule = "build.dependencies has no banned_packages" severity = "error" [[constraint]] -rule = "deno.imports has no npm: specifiers" +rule = "runtime is bun" severity = "error" diff --git a/mise.toml b/mise.toml index 6dd983f..10dcf9a 100644 --- a/mise.toml +++ b/mise.toml @@ -6,8 +6,7 @@ rust = "latest" go = "latest" zig = "latest" java = "latest" -bun = "latest" -denojs = "latest" +bun = "1.3.14" # Package managers npm = "latest" diff --git a/package.json b/package.json new file mode 100644 index 0000000..f0b8895 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "@hyperpolymath/empty-linter", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "bun": ">=1.3.0" + }, + "scripts": { + "test": "bun test tests/ByteDetector_test.js tests/empty_lint_ci_test.js", + "audit": "bun run scripts/empty-lint-ci.js --threshold critical ." + } +} diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 0f77587..e630f9f 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -3,34 +3,15 @@ # 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 -) +# Only ByteDetector currently has a complete semantic AffineScript +# implementation. Do not generate apparently usable artefacts from the TODO +# stubs elsewhere under src/. +compiler="${AFFINESCRIPT_BIN:-affinescript}" +mkdir -p build +"$compiler" check stdlib/ByteDetector.affine +"$compiler" compile --deno-esm \ + -o build/ByteDetector.affine.esm.js stdlib/ByteDetector.affine +bun build build/ByteDetector.affine.esm.js \ + --outfile src/core/ByteDetector.bun.js --target bun -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 - 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" +echo "Built the implemented ByteDetector for Bun. Other source modules remain unimplemented." diff --git a/scripts/empty-lint-ci.js b/scripts/empty-lint-ci.js new file mode 100644 index 0000000..8d2ddfe --- /dev/null +++ b/scripts/empty-lint-ci.js @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +import { + Critical, + Info, + SevError, + Warning, + scan, +} from "../src/core/ByteDetector.bun.js"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import { extname, join } from "node:path"; + +const EXIT_FINDINGS = 1; +const EXIT_SCAN_ERROR = 2; + +const DEFAULT_EXTENSIONS = new Set([ + ".a2ml", ".adoc", ".affine", ".c", ".cc", ".cpp", ".css", ".csv", + ".ex", ".exs", ".gleam", ".h", ".hpp", ".hs", ".html", ".idr", + ".java", ".jl", ".js", ".json", ".jsx", ".k9", ".md", ".ml", + ".ncl", ".res", ".rs", ".sh", ".svg", ".tex", ".toml", ".ts", + ".tsx", ".txt", ".v", ".xml", ".yaml", ".yml", ".zig", +]); + +const DEFAULT_IGNORED_DIRECTORIES = new Set([ + ".git", ".lake", "_build", "deps", "external_corpora", + "node_modules", "target", +]); + +const SEVERITY_RANK = new Map([ + [Info, 1], + [Warning, 2], + [SevError, 3], + [Critical, 4], +]); + +function usage() { + console.log(`Usage: bun run scripts/empty-lint-ci.js [options] [path ...] + +Options: + --threshold critical|error|warning|info Lowest severity that fails (default: critical) + --all-files Scan every UTF-8-decodable regular file + --help Show this help + +Exit status: + 0 Scan completed with no findings at or above the threshold + 1 Findings at or above the threshold + 2 The scan could not be completed + +The command never modifies input. Findings below the threshold are still reported.`); +} + +function parseArguments(args) { + let threshold = "critical"; + let allFiles = false; + const paths = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--help") { + usage(); + process.exit(0); + } else if (arg === "--all-files") { + allFiles = true; + } else if (arg === "--threshold") { + index += 1; + if (index >= args.length) throw new Error("--threshold requires a value"); + threshold = args[index].toLowerCase(); + } else if (arg.startsWith("--threshold=")) { + threshold = arg.slice("--threshold=".length).toLowerCase(); + } else if (arg.startsWith("-")) { + throw new Error(`unknown option: ${arg}`); + } else { + paths.push(arg); + } + } + + const thresholds = { + critical: Critical, + error: SevError, + warning: Warning, + info: Info, + }; + if (!(threshold in thresholds)) { + throw new Error(`invalid threshold: ${threshold}`); + } + + return { + allFiles, + paths: paths.length === 0 ? ["."] : paths, + threshold: thresholds[threshold], + thresholdName: threshold, + }; +} + +function extension(path) { + return extname(path).toLowerCase(); +} + +function shouldScan(path, allFiles) { + return allFiles || DEFAULT_EXTENSIONS.has(extension(path)); +} + +async function collectFiles(path, allFiles, files) { + const info = await lstat(path); + if (info.isSymbolicLink()) return; + if (info.isFile()) { + if (shouldScan(path, allFiles)) files.push(path); + return; + } + if (!info.isDirectory()) return; + + const entries = await readdir(path, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (entry.isDirectory() && DEFAULT_IGNORED_DIRECTORIES.has(entry.name)) continue; + const child = path === "." ? entry.name : join(path, entry.name); + await collectFiles(child, allFiles, files); + } +} + +function severityName(severity) { + if (severity === Critical) return "critical"; + if (severity === SevError) return "error"; + if (severity === Warning) return "warning"; + return "info"; +} + +function annotation(path, artifact, blocking) { + const level = blocking ? "error" : "warning"; + const message = `${artifact.name} U+${artifact.byte_value.toString(16).toUpperCase().padStart(4, "0")} (${severityName(artifact.severity)})`; + if (process.env.GITHUB_ACTIONS === "true") { + console.log(`::${level} file=${path},line=${artifact.line},col=${artifact.column}::${message}`); + } else { + console.log(`${path}:${artifact.line}:${artifact.column}: ${level}: ${message}`); + } +} + +async function main() { + let options; + try { + options = parseArguments(process.argv.slice(2)); + } catch (error) { + console.error(`empty-linter: ${error.message}`); + usage(); + process.exit(EXIT_SCAN_ERROR); + } + + const files = []; + try { + for (const path of options.paths) await collectFiles(path, options.allFiles, files); + } catch (error) { + console.error(`empty-linter: could not enumerate input: ${error.message}`); + process.exit(EXIT_SCAN_ERROR); + } + + let findings = 0; + let blockingFindings = 0; + try { + for (const path of files) { + const content = await readFile(path, "utf8"); + for (const artifact of scan(content)) { + findings += 1; + const blocking = SEVERITY_RANK.get(artifact.severity) >= SEVERITY_RANK.get(options.threshold); + if (blocking) blockingFindings += 1; + annotation(path, artifact, blocking); + } + } + } catch (error) { + console.error(`empty-linter: scan failed: ${error.message}`); + process.exit(EXIT_SCAN_ERROR); + } + + console.log(`empty-linter: scanned ${files.length} file(s); ${findings} finding(s), ${blockingFindings} blocking at threshold ${options.thresholdName}`); + process.exit(blockingFindings > 0 ? EXIT_FINDINGS : 0); +} + +await main(); diff --git a/src/bindings/Deno.affine b/src/bindings/Deno.affine deleted file mode 100644 index 0d05f97..0000000 --- a/src/bindings/Deno.affine +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor - -module Deno; - -// TODO: Complete semantic implementation diff --git a/src/core/ByteDetector.affine b/src/core/ByteDetector.affine deleted file mode 100644 index 54146a9..0000000 --- a/src/core/ByteDetector.affine +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor - -module ByteDetector; - -// TODO: Complete semantic implementation diff --git a/src/core/ByteDetector.bun.js b/src/core/ByteDetector.bun.js new file mode 100644 index 0000000..08066d6 --- /dev/null +++ b/src/core/ByteDetector.bun.js @@ -0,0 +1,234 @@ +// @bun +// build/ByteDetector.affine.esm.js +var Some = (value) => ({ tag: "Some", value }); +var None = { tag: "None" }; +var Ok = (value) => ({ tag: "Ok", value }); +var Err = (error) => ({ tag: "Err", error }); +var __as_concat = (a, b) => Array.isArray(a) ? a.concat(b) : a + b; +var __as_strSub = (s, start, n) => String(s).slice(start, start + n); +var __as_strGet = (s, i) => String(s)[i]; +var __as_charToInt = (c) => String(c).codePointAt(0); +var __as_show = (v) => typeof v === "string" ? v : JSON.stringify(v); +function encode_bytes(bytes) { + let result = ""; + for (const b of bytes) { + if (b < 0 || b > 255) { + return Err("byte out of range"); + } + result = __as_concat(result, encode_byte(b)); + } + return Ok(result); +} +function encode_string(s) { + const n = s.length; + let result = ""; + let i = 0; + while (i < n) { + const code = __as_charToInt(__as_strGet(s, i)); + result = __as_concat(result, encode_byte(code & 255)); + i = i + 1; + } + return result; +} +function encode_byte(v) { + const nibble_hi = v >> 4 & 15; + const nibble_lo = v & 15; + const hex = "0123456789abcdef"; + return __as_concat(__as_strSub(hex, nibble_hi, 1), __as_strSub(hex, nibble_lo, 1)); +} +function detect_invisibles(s) { + const n = s.length; + let result = []; + let i = 0; + while (i < n) { + const code = __as_charToInt(__as_strGet(s, i)); + if (is_invisible(code)) { + result = __as_concat(result, [code]); + } + i = i + 1; + } + return result; +} +var Critical = { tag: "Critical" }; +var SevError = { tag: "SevError" }; +var Warning = { tag: "Warning" }; +var Info = { tag: "Info" }; +function known_artifacts() { + return [{ 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" }]; +} +function get_artifact_def(byte_val) { + if (byte_val >= 1 && byte_val <= 8 || byte_val === 11 || byte_val === 12 || byte_val >= 14 && byte_val <= 31) { + return Some({ name: "C0_CONTROL", byte_value: byte_val, severity: Critical, fix_action: "review" }); + } + if (byte_val === 127) { + return Some({ name: "DELETE", byte_value: byte_val, severity: Critical, fix_action: "review" }); + } + const defs = known_artifacts(); + for (const d of defs) { + if (d.byte_value === byte_val) { + return Some(d); + } + } + return None; +} +function byte_to_hex(v) { + return v <= 255 ? (() => { + return encode_byte(v); + })() : v <= 65535 ? (() => { + return __as_concat(encode_byte(v >> 8 & 255), encode_byte(v & 255)); + })() : (() => { + return __as_concat(__as_concat(encode_byte(v >> 16 & 255), encode_byte(v >> 8 & 255)), encode_byte(v & 255)); + })(); +} +function scan(content) { + let results = []; + let line = 1; + let col = 1; + const n = content.length; + let i = 0; + while (i < n) { + const c = __as_strGet(content, i); + const code = __as_charToInt(c); + if (code === 10) { + line = line + 1; + col = 1; + } else { + { + const __scrut = get_artifact_def(code); + if (__scrut.tag === "Some") { + const def = __scrut.value; + results = __as_concat(results, [{ 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; + } else if (__scrut.tag === "None") { + col = col + 1; + } else + throw new Error("non-exhaustive match"); + } + } + i = i + 1; + } + return results; +} +function scan_to_hex(content) { + const artifacts = scan(content); + let lines = ""; + let first = true; + for (const a of artifacts) { + if (!first) { + lines = __as_concat(lines, ` +`); + } + lines = __as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(lines, "0x"), String(a.hex_value).toUpperCase()), " ["), a.name), "] at L:"), String(a.line)), " C:"), String(a.column)); + first = false; + } + return lines; +} +function apply_fixes(content) { + let result = content; + let count = 0; + const defs = known_artifacts(); + for (const def of defs) { + if (def.fix_action === "remove") { + const parts_count = result.length; + const fixed = replace_char(result, def.byte_value, ""); + const new_count = fixed.length; + count = count + (parts_count - new_count); + result = fixed; + } else { + if (def.fix_action === "replace:20") { + result = replace_char(result, def.byte_value, " "); + } + } + } + return [result, count]; +} +function replace_char(s, target_code, replacement) { + const n = s.length; + let result = ""; + let i = 0; + while (i < n) { + const c = __as_strGet(s, i); + const code = __as_charToInt(c); + if (code === target_code) { + result = __as_concat(result, replacement); + } else { + result = __as_concat(result, __as_show(c)); + } + i = i + 1; + } + return result; +} +function severity_order(s) { + return ((__scrut) => { + if (__scrut.tag === "Critical") { + return 4; + } + if (__scrut.tag === "SevError") { + return 3; + } + if (__scrut.tag === "Warning") { + return 2; + } + if (__scrut.tag === "Info") { + return 1; + } + throw new Error("non-exhaustive match"); + })(s); +} +function filter_by_severity(artifacts, min_severity) { + const min_order = severity_order(min_severity); + let result = []; + for (const a of artifacts) { + if (severity_order(a.severity) >= min_order) { + result = __as_concat(result, [a]); + } + } + return result; +} +function generate_report(artifacts) { + return artifacts.length === 0 ? (() => { + return "No invisible artifacts detected."; + })() : (() => { + const header = __as_concat(__as_concat("Found ", String(artifacts.length)), ` invisible artifact(s): +`); + let lines = header; + for (const a of artifacts) { + const sev_str = ((__scrut) => { + if (__scrut.tag === "Critical") { + return "CRITICAL"; + } + if (__scrut.tag === "SevError") { + return "ERROR"; + } + if (__scrut.tag === "Warning") { + return "WARNING"; + } + if (__scrut.tag === "Info") { + return "INFO"; + } + throw new Error("non-exhaustive match"); + })(a.severity); + lines = __as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(__as_concat(lines, "["), sev_str), "] "), a.name), " (0x"), String(a.hex_value).toUpperCase()), ") at L:"), String(a.line)), " C:"), String(a.column)), " - "), a.fix_action), ` +`); + } + return lines; + })(); +} +export { + scan_to_hex, + scan, + known_artifacts, + get_artifact_def, + generate_report, + filter_by_severity, + encode_string, + encode_bytes, + encode_byte, + detect_invisibles, + byte_to_hex, + apply_fixes, + Warning, + SevError, + Info, + Critical +}; diff --git a/stapeln.toml b/stapeln.toml index 0d80b8e..904c11c 100644 --- a/stapeln.toml +++ b/stapeln.toml @@ -28,22 +28,22 @@ verify = true [layers.toolchain] description = "Build tools and dependencies" extends = "base" -packages = ["deno"] +packages = ["bun"] cache = true [layers.build] description = "empty-linter build" extends = "toolchain" -commands = ["deno cache src/main.ts"] +commands = ["bun test tests/ByteDetector_test.js tests/empty_lint_ci_test.js"] [layers.runtime] description = "Minimal runtime" from = "cgr.dev/chainguard/wolfi-base:latest" -packages = ["ca-certificates", "curl"] +packages = ["bun", "ca-certificates"] copy-from = [ { layer = "build", src = "/app/", dst = "/app/" }, ] -entrypoint = ["/app/bin/deno-run"] +entrypoint = ["bun", "run", "/app/scripts/empty-lint-ci.js"] user = "nonroot" # ── Security ─────────────────────────────────────────────────── diff --git a/stdlib/ByteDetector.affine b/stdlib/ByteDetector.affine index 0d229c2..a205498 100644 --- a/stdlib/ByteDetector.affine +++ b/stdlib/ByteDetector.affine @@ -42,6 +42,12 @@ pub fn known_artifacts() -> [ArtifactDef] { } pub fn get_artifact_def(byte_val: Int) -> Option { + if (byte_val >= 1 && byte_val <= 8) || byte_val == 11 || byte_val == 12 || (byte_val >= 14 && byte_val <= 31) { + return Some(#{ name: "C0_CONTROL", byte_value: byte_val, severity: Critical, fix_action: "review" }); + } + if byte_val == 127 { + return Some(#{ name: "DELETE", byte_value: byte_val, severity: Critical, fix_action: "review" }); + } let defs = known_artifacts(); for d in defs { if d.byte_value == byte_val { diff --git a/stdlib/Deno.affine b/stdlib/Deno.affine deleted file mode 100644 index b3e6d11..0000000 --- a/stdlib/Deno.affine +++ /dev/null @@ -1,388 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell -// -// Deno.affine — issue #122 host bindings for the Deno-ESM backend. -// -// Unlike stdlib/Vscode.affine (issue #35), these externs are NOT a -// wasm-FFI surface with an Int-handle/readString contract. The -// `--deno-esm` backend (lib/codegen_deno.ml) is a *direct* AST → ES -// module transpiler with no wasm boundary, so each `extern fn` below is -// lowered, at compile time, straight to its host expression: -// -// writeTextFile(p, c) -> Deno.writeTextFileSync(p, c) -// jsonParse(s) -> JSON.parse(s) -// dateNow() -> Date.now() -// ... -// -// The lowering table lives in lib/codegen_deno.ml (`deno_builtins`); -// the non-trivial leaves are emitted inlined into every module's -// prelude so the output is genuinely drop-in (no runtime adapter, no -// extra package to resolve). packages/affine-deno/mod.js mirrors the -// same surface as a standalone ESM module for `deno test`. -// -// All FS operations are synchronous (`Deno.*Sync`). `await` on a -// synchronously-returned value is valid JS, so an async-shaped consumer -// (e.g. hyperpolymath/ubicity) keeps working without an async-extern -// ABI (issue #103 — documented future work, not required here). -// -// Names here MUST match the `deno_builtins` table keys exactly; an -// unmatched extern silently falls through to a same-named host symbol. - -module Deno; - -// ── Opaque host value types ──────────────────────────────────────── -// -// `Json` is any structured JS value (object/array/string/number/bool/ -// null) crossing the boundary opaquely — the AffineScript side never -// inspects it, it only routes it between JSON.* and the host. `Bytes` -// is a Uint8Array; `WasmExports` is an instantiated module's exports. - -pub extern type Json; -pub extern type Bytes; -pub extern type WasmExports; - -// ── Filesystem (synchronous) ─────────────────────────────────────── - -/// `Deno.writeTextFileSync(path, content)`. Returns 0. -pub extern fn writeTextFile(path: String, content: String) -> Int; - -/// `Deno.readTextFileSync(path)`. Throws on missing file — pair with -/// `isNotFound` in a `try`/`catch` for the not-found-is-null pattern. -pub extern fn readTextFile(path: String) -> String; - -/// `Deno.readFileSync(path)` — raw bytes (for wasm modules). -pub extern fn readFileBytes(path: String) -> Bytes; - -/// `Deno.removeSync(path)`. Throws if absent (catch + `isNotFound`). -pub extern fn removePath(path: String) -> Int; - -/// `Deno.mkdirSync(path, { recursive: true })`. -pub extern fn mkdirRecursive(path: String) -> Int; - -/// mkdir -p that swallows AlreadyExists (idempotent ensure-directory). -pub extern fn ensureDir(path: String) -> Int; - -/// Names of the *file* entries in `path` (skips sub-directories). -pub extern fn readDirNames(path: String) -> [String]; - -/// `Deno.statSync(path).size` in bytes. -pub extern fn statSize(path: String) -> Int; - -/// `Deno.statSync(path).isFile` — true if `path` is a regular file. -/// Throws on a missing path (pair with `isNotFound` for the absent case). -pub extern fn statIsFile(path: String) -> Bool; - -/// `Deno.statSync(path).isDirectory` — true if `path` is a directory. -/// Throws on a missing path (pair with `isNotFound` for the absent case). -pub extern fn statIsDirectory(path: String) -> Bool; - -/// Recursive walk under `root` — every file path beneath it, depth-first. -/// Mirrors `std/fs/walk` for the common case (no glob filter; callers -/// filter by extension). Throws on a missing root via `Deno.readDirSync`. -pub extern fn walkRecursive(root: String) -> [String]; - -// ── Bytes I/O (construction + LE getters/setters) ────────────────── -// -// Construction + per-field read/write at byte offsets. Companion to -// the read-only `bytesLength` / `bytesByteAt` / `bytesAsciiSlice` -// accessors (campaign #239 STEP 3 / standards#242). All multi-byte -// integer variants are little-endian — the estate's C ABI contracts -// (raze-tui `raze-events.ads`, Idris2 `Events.idr`) are LE-pinned. -// -// Setters return `Int = 0` so they compose in expression-statement -// position; the caller is responsible for the buffer-bounds invariant -// (an out-of-range offset throws `RangeError` at the host boundary). -// Bounds-check via `bytesLength` from STEP 3. - -/// `new Uint8Array(n)` — zeroed buffer of `n` bytes. -pub extern fn bytes_new(n: Int) -> Bytes; - -/// `new Uint8Array(n).fill(byte & 0xFF)` — all-`byte` buffer. -pub extern fn bytes_fill(n: Int, byte: Int) -> Bytes; - -/// Write `v & 0xFF` to byte `offset`. -pub extern fn bytes_set_u8(b: Bytes, offset: Int, v: Int) -> Int; - -/// Write `v & 0xFFFF` to bytes `[offset, offset+2)` as little-endian u16. -pub extern fn bytes_set_u16_le(b: Bytes, offset: Int, v: Int) -> Int; - -/// Write `v >>> 0` to bytes `[offset, offset+4)` as little-endian u32. -pub extern fn bytes_set_u32_le(b: Bytes, offset: Int, v: Int) -> Int; - -/// Write `v | 0` to bytes `[offset, offset+4)` as little-endian i32. -pub extern fn bytes_set_i32_le(b: Bytes, offset: Int, v: Int) -> Int; - -/// Read byte at `offset` (0..255). -pub extern fn bytes_get_u8(b: Bytes, offset: Int) -> Int; - -/// Read bytes `[offset, offset+2)` as little-endian u16 (0..65535). -pub extern fn bytes_get_u16_le(b: Bytes, offset: Int) -> Int; - -/// Read bytes `[offset, offset+4)` as little-endian u32 (0..4294967295). -pub extern fn bytes_get_u32_le(b: Bytes, offset: Int) -> Int; - -/// Read bytes `[offset, offset+4)` as little-endian i32 (-2147483648..2147483647). -pub extern fn bytes_get_i32_le(b: Bytes, offset: Int) -> Int; - -// ── Bytes I/O (read-only accessors, STEP 3 / standards#242) ─────── -// -// Read-only accessors over a `Bytes` buffer. Compile-time lowerings -// live in lib/codegen_deno.ml `deno_builtins`: -// bytesLength(b) -> (b).length -// bytesByteAt(b, i) -> (b)[i] -// bytesAsciiSlice(b,a,c) -> String.fromCharCode(...(b).slice(a, c)) -// These three declarations restore the STEP 3 surface that the codegen -// has wired since #504 (#52ccaf1) but which never landed in the stdlib -// module — leaving the resolver unable to satisfy `use Deno::{ ... }` -// imports that reach for them. The (in-tree) regression test that -// surfaced this gap is `tests/codegen-deno/deno_scripting_part2.affine`. - -/// Length of `b` in bytes (`.length`). -pub extern fn bytesLength(b: Bytes) -> Int; - -/// Byte at `offset` (0..255). Out-of-range reads return JS `undefined` -/// which coerces to `NaN` on numeric use — bounds-check via `bytesLength`. -pub extern fn bytesByteAt(b: Bytes, offset: Int) -> Int; - -/// Decode `b[a..c)` as if it were ASCII text (each byte becomes one -/// `char`code). Cheap header-snippet/magic-string extractor; for full -/// UTF-8 decoding go through a `TextDecoder` extern instead. -pub extern fn bytesAsciiSlice(b: Bytes, a: Int, c: Int) -> String; - -// ── Path ─────────────────────────────────────────────────────────── - -/// Single-segment join with a `/` separator (idempotent on a trailing -/// slash). Sufficient for the storage-layout use-case. -pub extern fn pathJoin(a: String, b: String) -> String; - -// ── Error classification ─────────────────────────────────────────── - -/// `e instanceof Deno.errors.NotFound` — the only error class the -/// storage layer special-cases (missing file/dir => null/empty). -pub extern fn isNotFound(e: Json) -> Bool; - -// ── JSON ─────────────────────────────────────────────────────────── - -pub extern fn jsonStringify(v: Json) -> String; - -/// `JSON.stringify(v, null, 2)` — the on-disk pretty form. -pub extern fn jsonStringifyPretty(v: Json) -> String; - -pub extern fn jsonParse(s: String) -> Json; - -/// JS `null` as an opaque Json (the not-found / absent sentinel). -pub extern fn jsonNull() -> Json; - -/// Opaque field/index read: `value[key]`. The boundary primitive for -/// treating an arbitrary host JS value as data without the AffineScript -/// side modelling its shape (e.g. `experience.id`). -pub extern fn jsonGet(value: Json, key: String) -> Json; -pub extern fn jsonGetStr(value: Json, key: String) -> String; - -/// Nullish default — `x ?? d`. Preserves a JS default parameter when -/// the caller omits the argument. -pub extern fn orDefault(x: String, d: String) -> String; - -/// Kilobyte display string: `(bytes / 1024).toFixed(2)`. Runtime number -/// formatting is an honest host primitive (cf. Rust `format!`). -pub extern fn kbString(bytes: Int) -> String; - -// ── Misc host ────────────────────────────────────────────────────── - -/// `Date.now()` — epoch millis (used for timestamped report names). -pub extern fn dateNow() -> Int; - -/// `new Date().toISOString()` — UTC ISO-8601 timestamp string -/// (e.g. `"2026-05-30T12:34:56.789Z"`). Distinct from `dateNow()` which -/// returns epoch millis as `Int`. -pub extern fn dateNowIso() -> String; - -// ── Module identity ──────────────────────────────────────────────── - -/// `import.meta.url` — the absolute URL of the importing module. The JS -/// idiom for "find my own location" (cf. `__dirname` / `__filename`). At -/// Deno-ESM top level, lowers to the bare `import.meta.url` expression; -/// callers parse it (`new URL(...)`/`fileURLToPath`/string split) for -/// directory-relative behaviour. -pub extern fn importMetaUrl() -> String; - -// ── CLI ──────────────────────────────────────────────────────────── - -/// `Deno.args` — command-line arguments (excludes argv[0]). -pub extern fn args() -> [String]; - -/// `Deno.exit(code)` — terminate the process with `code`. Never returns; -/// the `Int` return type is for type-level compatibility with `if/else` -/// arms that flow through `exit` in their non-returning branch. -pub extern fn exit(code: Int) -> Int; - -// ── Diagnostics ──────────────────────────────────────────────────── - -/// `console.error(s)` — write to stderr. (Use `print`/`println` for -/// stdout.) Returns 0 for chaining. -pub extern fn consoleError(s: String) -> Int; - -// ── Regex ────────────────────────────────────────────────────────── - -/// `new RegExp(pat).test(s)` — true iff `s` matches the JS regex source -/// `pat`. Minimal regex surface; for extraction or replace, add a -/// specialised extern. Invalid `pat` throws at call time. -pub extern fn regexMatch(s: String, pat: String) -> Bool; - -/// `(Number(bytes) / 1024).toFixed(2)` — kilobyte display string. -pub extern fn numToFixed2(bytes: Int) -> String; - -pub extern fn endsWith(s: String, suffix: String) -> Bool; - -/// `s` with a trailing `suffix` removed (no-op if absent). -pub extern fn stripSuffix(s: String, suffix: String) -> String; - -// ── Randomness + high-res clock (STEP 4-B / standards#327) ───────── -// -// Compile-time lowerings in lib/codegen_deno.ml `deno_builtins`: -// math_random() -> Math.random() -// random_u32() -> ((Math.random() * 4294967296) >>> 0) -// random_in_range(lo, hi) -> Math.floor(Math.random()*(hi-lo)) + lo -// performance_now() -> performance.now() -// These declarations restore the STEP 4-B surface that codegen has -// wired since #509 (319bc84) but which never landed as stdlib externs -// — leaving `use Deno::{math_random, ...}` unresolvable and the -// `random_smoke` codegen-deno harness red at compile time. -// -// `math_random` is the JS PRNG (NOT cryptographic). For crypto-grade -// random bytes route through a separate `crypto_random_bytes` extern -// (different host call: `crypto.getRandomValues`, different threat -// model — not in scope here). - -/// JS PRNG draw in `[0.0, 1.0)`. Non-cryptographic. -pub extern fn math_random() -> Float; - -/// Uniform 32-bit unsigned integer draw, `[0, 2^32)`. Non-cryptographic. -pub extern fn random_u32() -> Int; - -/// Uniform integer draw, `[lo, hi)`. Caller's responsibility to ensure -/// `lo < hi`; an empty range collapses to `lo` (no error). -pub extern fn random_in_range(lo: Int, hi: Int) -> Int; - -/// `performance.now()` — high-resolution sub-millisecond monotone timer. -/// Distinct from `dateNow()` (epoch millis, Int) and `dateNowIso()` -/// (ISO-8601 string). Use for bench / latency-measurement work. -pub extern fn performance_now() -> Float; - -// ── WebAssembly (synchronous instantiate) ────────────────────────── - -/// `new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports`. -pub extern fn wasmInstance(bytes: Bytes) -> WasmExports; - -/// `exports[name](...args)` — invoke a named export with a list of -/// Float arguments. WebAssembly's i32/i64/f32/f64 scalar types all -/// coerce to JS Number, so a single Float-typed surface covers the -/// common case (multi-value / void returns are out of scope here — -/// add a specialised extern when needed). Caller is responsible for -/// the export existing and having a compatible arity; absent exports -/// throw `TypeError: ... is not a function` at the host boundary. -/// -/// Example: -/// -/// use Deno::{Bytes, WasmExports, wasmInstance, wasmCall}; -/// -/// pub fn addViaWasm(bytes: Bytes, a: Float, b: Float) -> Float = { -/// let exports = wasmInstance(bytes); -/// wasmCall(exports, "add", [a, b]) -/// }; -pub extern fn wasmCall(exports: WasmExports, name: String, args: [Float]) -> Float; - -// ── WebAssembly typed export call (#455 — Tier 1 #5, Option B) ──── -// -// Generic `wasm_export_call` covering any wasm signature including i64, -// multi-typed args, future spec additions. Future-proof: no binding -// change required as wasm evolves. Tiny addition vs Option A's ~30 -// per-signature variants. Typed wrappers can be layered on top of this -// generic as ergonomic helpers in a follow-up sub-issue. -// -// Trade-off: weaker static safety at the call site — user marshals -// manually via the `wv_*` constructors and reads via `wv_as_*`. Errors -// (wrong arity, missing export, type mismatch) deferred to runtime per -// owner's accepted trade-off in #455 comment. -// -// **Encoding decision (2026-05-30):** `WasmValue` lands as an OPAQUE -// extern type rather than a true AffineScript sum type. Rationale: -// the JS interop boundary needs a hand-written marshaller that pairs -// `wv_i32(42) -> { tag: "i32", v: 42 }` with the export-call dispatch -// `__as_wasm_export_call(exports, name, args)`. Mirrors the existing -// `WasmExports` opaque pattern. A true sum-type variant on top of this -// opaque base ships in a follow-up once `json.affine`-style tagged- -// variant codegen lands for the Deno-ESM backend. - -/// Opaque wasm scalar value. Constructed via `wv_i32` / `wv_i64` / -/// `wv_f32` / `wv_f64`. Read via `wv_as_int` (i32/i64 → Int) or -/// `wv_as_float` (f32/f64 → Float). The kind tag is opaque to AS code -/// but inspectable host-side via `wv_kind` for diagnostics. -pub extern type WasmValue; - -/// Wrap an `Int` as a wasm i32. Truncates to the low 32 bits at the -/// host boundary if `n` exceeds the i32 range. -pub extern fn wv_i32(n: Int) -> WasmValue; - -/// Wrap an `Int` as a wasm i64. Crosses the boundary as a `BigInt` -/// host-side. Values outside the safe-integer range (>= 2^53) are -/// preserved as BigInt; arithmetic on the AS side that goes through -/// `wv_as_int` truncates to the safe-integer range. -pub extern fn wv_i64(n: Int) -> WasmValue; - -/// Wrap a `Float` as a wasm f32. Rounded to f32 precision via -/// `Math.fround` at the host boundary. -pub extern fn wv_f32(f: Float) -> WasmValue; - -/// Wrap a `Float` as a wasm f64. Preserved at full f64 precision. -pub extern fn wv_f64(f: Float) -> WasmValue; - -/// Read a wasm scalar back as `Int`. Defined for both i32 and i64 -/// variants. For f32/f64, truncates toward zero. Caller is responsible -/// for knowing the variant — there is no runtime check; reading the -/// wrong kind silently coerces. -pub extern fn wv_as_int(v: WasmValue) -> Int; - -/// Read a wasm scalar back as `Float`. Defined for both f32 and f64 -/// variants. For i32/i64, converts via JS `Number()` — i64 values -/// beyond 2^53 lose precision; caller can detect via `wv_kind`. -pub extern fn wv_as_float(v: WasmValue) -> Float; - -/// Return the kind tag ("i32" / "i64" / "f32" / "f64") for runtime -/// dispatch when the AS-side caller doesn't statically know the -/// variant. Use sparingly — the typed `wv_as_*` accessors should be -/// the default path. -pub extern fn wv_kind(v: WasmValue) -> String; - -/// `exports[name](...args)` with typed `WasmValue` marshalling. -/// Returns a `WasmValue` wrapping the export's return — kind is `f64` -/// by default (the lossless choice for any numeric return); callers -/// expecting i32/i64 should rebuild via `wv_i32(wv_as_int(result))` -/// or inspect `wv_kind` host-side. Multi-value returns are out of -/// scope at this binding — add a `wasm_export_call_multi` extern when -/// needed. -/// -/// Example: -/// -/// use Deno::{ -/// Bytes, WasmExports, wasmInstance, wasm_export_call, -/// wv_i32, wv_as_int, -/// }; -/// -/// pub fn addI32ViaWasm(bytes: Bytes, a: Int, b: Int) -> Int { -/// let exports = wasmInstance(bytes); -/// let result = wasm_export_call( -/// exports, "add", [wv_i32(a), wv_i32(b)]); -/// wv_as_int(result) -/// } -pub extern fn wasm_export_call( - exports: WasmExports, name: String, args: [WasmValue] -) -> WasmValue; - -// ── Array helper ─────────────────────────────────────────────────── -// -// AffineScript has no mutable-array push primitive in this subset; -// this fluent helper appends and returns the array so accumulation -// reads functionally: `acc = arrayPush(acc, x)`. - -pub extern fn arrayPush(arr: [Json], v: Json) -> [Json]; diff --git a/tests/ByteDetector_test.js b/tests/ByteDetector_test.js index c88c0dcb7eb2c3a1e7142f3946744f020ed2b894..f2ad305ee3e0334f38560abd6c95c13b8c891edc 100644 GIT binary patch delta 1301 zcma)6&ubGw7{w&5$@Z5RwQ4K<8UrR6LRy5XrJ#)g(KIPdq*4R!CYf!fq_gSlq-j}} z+`ZH?pcnB^u!2{?t9bAKV8Me&kAA!P)i$koSZ0`c-}m0W@9n;u{B`@Swg2^?S*)}8 zh8dby=w{8}0xS^36kvnSjZFyQ^+V?X0g7H{B|!}anrRX)Qd@PcYDTn@z%>SBr$$PG zl%rrN091#_bhfyGM{#-vsD%P(iV8Oxz(@m9oEto%cKSfrdX~#(;-=t~ty6tB(z%J+ zF<3W5KcFQgYOB$Clwd1%pXQ8lN_O)M-TxuGPj#7=fuW|B#(rr%?Z?EG4Aw-iGcVGx z06F(P{96bo;uNWger2> zH!VM0hy>NAq*^t?mLtVpHF&kG$^-9&{Ow7|Tb@@g?xUxNbCjIKEx4GR_fAj^TgYy^ z9pO~3L+?Grt;W?fWn`w!*Px=}nowZQ@od;~+q=ikt|KKNKluUU7lL`}Kz9XPfp_uNPRwDATFFdCTtymBh!byRZG|s8EP70h8QRggdojIX7 zj9bSAC7F1g%x3Z{*|ePYz)h2E6Hdi$43fCel2%1O?T)8|Pg3brKIITc)gsXiPR`+& znMD>&7le1I##+UpV-0V}L!T*^27clLa&3^x?}LS_Xf)=_ZoAlWUHuZfIDSDM`9pHU z|KM_Wu83=o;oxgpXj}$|_S^d!PV>4<4?j9l+@8NG-w(&- tkKvUGH!U#JiU(jm+?YDE?3fmn+aqK0%}9RKF~w2s#?QH)l7GCH{sQ8)x0?U} delta 709 zcmaE(JWXaowS_`radB!ikOYckc&Vq=2Dy@)A}P zG`oJXnkgyyWagzB9$HYIS(2gP=M)?elLrzp0@@B1iVC0nky#GtP)oZ%XtDtq!fv_h|7aRiQ&)!paYdQ|K}2CM3Otr!#CN2$C3x)Zw7?Zr}FSiC^0B$ z>L{cEqazjFev!$idCVtA^N6{kgf1w26wsqiBQ3KcHAP1OLnTT`icGHOjpbJgNi8lp zv>-V@uOv0EL}~H|UJE4G*7FKX_Tei;66xococxM!B8o^6l*cF#!38xB7^L8cuNRO( zQo3HiP81vo3WkuNS1>xXAS1OX6)BNuPWBPxN79oo=)j?LXaP8;p~`qC-w_l508vQq A?f?J) diff --git a/tests/PathHandler_test.js b/tests/PathHandler_test.js index 6ad6e63..6c36c3c 100644 --- a/tests/PathHandler_test.js +++ b/tests/PathHandler_test.js @@ -1,100 +1,104 @@ // SPDX-License-Identifier: MPL-2.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -import { assertEquals } from "jsr:@std/assert"; +import { test } from "bun:test"; import { validate, unwrap_path, path_join, sanitize, is_within, get_parent, filename, has_extension, is_excluded, from_trusted, TraversalDetected, -} from "../src/core/PathHandler.deno.js"; +} from "../src/core/PathHandler.bun.js"; -Deno.test("PathHandler: validate accepts relative paths", () => { +function assertEquals(actual, expected) { + if (!Object.is(actual, expected)) throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + +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", () => { +test("PathHandler: validate rejects absolute paths", () => { assertEquals(validate("/etc/passwd").tag, "None"); }); -Deno.test("PathHandler: validate rejects path traversal", () => { +test("PathHandler: validate rejects path traversal", () => { assertEquals(validate("../../etc/passwd").tag, "None"); }); -Deno.test("PathHandler: validate rejects embedded traversal", () => { +test("PathHandler: validate rejects embedded traversal", () => { assertEquals(validate("src/../../../etc").tag, "None"); }); -Deno.test("PathHandler: sanitize removes dangerous characters", () => { +test("PathHandler: sanitize removes dangerous characters", () => { const clean = sanitize("file.txt"); assertEquals(clean.includes("<"), false); assertEquals(clean.includes(">"), false); }); -Deno.test("PathHandler: sanitize replaces slashes", () => { +test("PathHandler: sanitize replaces slashes", () => { const clean = sanitize("path/to/file"); assertEquals(clean.includes("/"), false); }); -Deno.test("PathHandler: path_join creates valid joined path", () => { +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", () => { +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", () => { +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", () => { +test("PathHandler: filename handles no directory", () => { assertEquals(filename(from_trusted("file.txt")), "file.txt"); }); -Deno.test("PathHandler: has_extension checks extension", () => { +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", () => { +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", () => { +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", () => { +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", () => { +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", () => { +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", () => { +test("PathHandler: is_excluded allows non-excluded paths", () => { const p = from_trusted("project/src/main.affine"); assertEquals(is_excluded(p, ["node_modules", ".git"]), false); }); diff --git a/tests/SafeWhitespace_test.js b/tests/SafeWhitespace_test.js index a818528..0b20f3d 100644 --- a/tests/SafeWhitespace_test.js +++ b/tests/SafeWhitespace_test.js @@ -1,76 +1,84 @@ // SPDX-License-Identifier: MPL-2.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -import { assertEquals } from "jsr:@std/assert"; +import { test } from "bun:test"; 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"; +} from "../stdlib/SafeWhitespace.bun.js"; -Deno.test("SafeWhitespace: trim_start removes leading whitespace", () => { +function assertEquals(actual, expected) { + if (!Object.is(actual, expected)) throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + +const NBSP = String.fromCodePoint(0xa0); +const ZWSP = String.fromCodePoint(0x200b); +const BOM = String.fromCodePoint(0xfeff); + +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", () => { +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", () => { +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", () => { +test("SafeWhitespace: collapse_spaces preserves single spaces", () => { assertEquals(collapse_spaces("hello world"), "hello world"); }); -Deno.test("SafeWhitespace: collapse_blank_lines reduces excess blank lines", () => { +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", () => { +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", () => { +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", () => { +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", () => { +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); +test("SafeWhitespace: remove_invisibles strips known invisible chars", () => { + const result = remove_invisibles(`${ZWSP}${BOM}hello`); + assertEquals(result.includes(ZWSP), false); + assertEquals(result.includes(BOM), false); }); -Deno.test("SafeWhitespace: detect_invisibles finds NBSP", () => { - const found = detect_invisibles("hello world"); +test("SafeWhitespace: detect_invisibles finds NBSP", () => { + const found = detect_invisibles(`hello${NBSP}world`); assertEquals(found.length, 1); assertEquals(found[0], 0xa0); }); -Deno.test("SafeWhitespace: detect_invisibles empty for clean string", () => { +test("SafeWhitespace: detect_invisibles empty for clean string", () => { assertEquals(detect_invisibles("hello world").length, 0); }); -Deno.test("SafeWhitespace: LineEnding constants have correct tags", () => { +test("SafeWhitespace: LineEnding constants have correct tags", () => { assertEquals(LF.tag, "LF"); assertEquals(CRLF.tag, "CRLF"); assertEquals(CR.tag, "CR"); diff --git a/tests/TextTransform_test.js b/tests/TextTransform_test.js index b95e677..b401528 100644 --- a/tests/TextTransform_test.js +++ b/tests/TextTransform_test.js @@ -1,111 +1,119 @@ // SPDX-License-Identifier: MPL-2.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -import { assert, assertEquals } from "jsr:@std/assert"; +import { test } from "bun:test"; 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"; +} from "../src/core/TextTransform.bun.js"; +import { LF, CRLF } from "../stdlib/SafeWhitespace.bun.js"; -Deno.test("TextTransform: transform trims lines when option set", () => { +function assert(condition, message = "assertion failed") { + if (!condition) throw new Error(message); +} + +function assertEquals(actual, expected) { + if (!Object.is(actual, expected)) throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + +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", () => { +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", () => { +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", () => { +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", () => { +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", () => { +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", () => { +test("TextTransform: transform_default returns a string", () => { const result = transform_default(" test "); assertEquals(typeof result, "string"); }); -Deno.test("TextTransform: get_metrics counts chars", () => { +test("TextTransform: get_metrics counts chars", () => { assertEquals(get_metrics("Hello World").chars, 11); }); -Deno.test("TextTransform: get_metrics counts words", () => { +test("TextTransform: get_metrics counts words", () => { assertEquals(get_metrics("Hello World Test").words, 3); }); -Deno.test("TextTransform: get_metrics counts lines", () => { +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", () => { +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", () => { +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", () => { +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", () => { +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", () => { +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 >", () => { +test("TextTransform: format_for_html escapes < and >", () => { const result = format_for_html(""); assertEquals(result.includes("