diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e869953..16d7e82f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,29 @@ jobs: print("interactive e2e ok") PY + cli-io: + name: CLI I/O / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + + - name: Real file stdin and stdout I/O + run: python -m pytest -q tests/test_cli.py tests/test_cli_io_contract.py tests/test_letter_mix_machine_text.py + calibration-readiness: name: Frozen calibration readiness runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b74616..a7a1e01f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +Audit fixes F01-F07 against `be6ae7645fda8b39d1d308722ac249f519e68de5`. Package version remains 0.4.0. Frozen confirmation files and their SHA-256 sums are unchanged. + +- F01: contenteditable rendering sets `textContent`. The 2026-08-26 Chromium contenteditable VERIFIED rows compared blank divs and are not proof of text rendering equivalence. Replacement measurement: `evidence/audit-fixes-2026-08-27/`. +- F02: common relative paths without `./` and Windows paths that use `/` keep exact original bytes. +- F03: Markdown reference labels are protected at uses and definitions. Link checks use reference resolution, not only visible projection. +- F04: file and stdin I/O keep LF, CRLF, CR, mixed endings, and a missing final newline. +- F05: `--text` is literal text. `--file` requires an existing UTF-8 file. Ordinary sentences are not treated as missing files. +- F06: stdin is decoded as strict UTF-8. Invalid bytes exit nonzero with no payload and no clipboard copy. +- F07: letter-mix site selection no longer hashes the whole document per candidate. Compose-time invariant checks remain. + +Live mix bytes can differ from frozen `u034f-ufe00-letter-alt-v1` output hashes when a newly protected span is present. Visible projection remains the source. `cycle8-mix-freeze-v1` still pins `letter_mix_source_sha256` to the freeze-time file. + ## v0.4.0 — Product CLI - Transforms ordinary English ASCII text. Visible words stay exactly the same. diff --git a/README.md b/README.md index 4f06d9e8..90298482 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ Paste or type text, then a line that is only `:done`. The result is copied to th ```text printf 'I do not agree.\n' | fuckmark -fuckmark "I do not agree." -fuckmark notes.txt -o notes.fm.txt +fuckmark --text "I do not agree." +fuckmark --text "I agree. You are right" +fuckmark --file notes.txt -o notes.fm.txt printf 'I do not agree.\n' | fuckmark --visible ``` @@ -48,7 +49,7 @@ Pipes and files write the payload to stdout. `fuckmark --help` is enough to star `VISIBLE(original) == VISIBLE(transformed)`. FuckMark only inserts U+034F and U+FE00 after eligible ASCII letters. It does not contract, paraphrase, homoglyph, or add spaces. -Supported input: tab, newline, carriage return, and ASCII space through tilde. Other Unicode is returned unchanged. Only UTF-8. URLs, code spans, paths, and similar machine text are left intact. +Supported input: tab, newline, carriage return, and ASCII space through tilde. Other Unicode is returned unchanged with exit 0. That status means I/O succeeded, not that hidden characters were inserted. Only UTF-8. URLs, code spans, paths (including `src/main.py` and `C:/Users/...`), Markdown reference labels, and similar machine text are left intact. ## Verified results @@ -66,7 +67,15 @@ Google synthid-text 30-key GPT-2 transformed text: **0/192**. This does not remove every AI watermark. It is not a claim against unknown, proprietary, or future detectors. -Stripping combining marks or default-ignorable characters restores the source. English ASCII only. Cap 192 insertion sites. +Stripping combining marks or default-ignorable characters restores the source. On the frozen Gate v2 confirmation set, mix plus those two stress sanitizers restored detection to 188/192. The stored 0/192 result applies only to the specified detectors, threshold, samples, and required sanitizer paths. + +Primary confirmation uses 64 generated tokens per sample. Insertion stops after the first 192 eligible letter sites, so the tail of a long document is unchanged. That coverage limit is not a measured long-document detection result. + +English ASCII only. Valid Unicode outside that domain, including accented letters, is returned unchanged with exit 0. + +Equal visible projection is not the same as equal behavior in other software. Raw codepoint search, Markdown reference matching, and exact byte paths can still fail if hidden characters remain. Historical Chromium `contenteditable` VERIFIED rows from 2026-08-26 used a blank-div measurement bug and are not proof of rendering equivalence. Safari/WebKit and terminal-pixel results remain UNKNOWN. + +Cap 192 insertion sites. ## How it works diff --git a/docs/cli.md b/docs/cli.md index a22170f9..403af98e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -43,7 +43,13 @@ Redirected or piped stdin does not open this UI. It is processed as a stream. ```text printf 'I do not agree.\n' | fuckmark -fuckmark "I do not agree." +fuckmark --text "I do not agree." +fuckmark --text "I agree. You are right" +fuckmark --text "Version 1.2 works" +fuckmark --text "Use input/output here" +fuckmark --text=-starts-with-hyphen +fuckmark --file notes.txt +fuckmark --file "my notes.txt" -o notes.fm.txt fuckmark notes.txt fuckmark notes.txt -o notes.fm.txt printf 'I do not agree.\n' | fuckmark --copy @@ -52,11 +58,28 @@ printf 'I do not agree.\n' | fuckmark --visible Piped or quoted input writes the hidden payload to stdout. `--visible` writes the original visible text. `--copy` also places whatever was written on the clipboard. +## Input modes + +| Form | Behavior | +| --- | --- | +| `--text TEXT` | Always a literal string. Use this for sentences, decimals, slashes, or text that looks like a path. `--text=-foo` is literal text that starts with a hyphen. | +| `--file FILE` | Always a file. Missing files are errors. Directories are errors. | +| positional operand | If that path exists as a file, it is read. If it looks like a missing path (`notes.txt`, `src/main.py`, `~/mail`, or a slash), it is an error. Otherwise it is literal text. | +| `--stdin` or `-` | Read all of standard input as bytes and decode them as strict UTF-8. | +| no operand, terminal | Paste UI. | +| no operand, pipe | Stream mode. | + +`--text`, `--file`, and `--stdin` are mutually exclusive. Quotes group arguments for the shell. They do not switch FuckMark into text mode. If `notes.txt` exists and you want the string `notes.txt`, use `--text notes.txt`. + +Existing files are read as UTF-8 bytes with no newline conversion. LF, CRLF, CR, mixed endings, and a missing final newline are preserved. Invalid UTF-8 is rejected: nonzero exit, no stdout payload, no clipboard copy, and no traceback. + ## Options | Option | Behavior | | --- | --- | | `--version` | Print `FuckMark 0.4.0`. | +| `--text TEXT` | Transform TEXT as a literal string. | +| `--file FILE` | Read UTF-8 from FILE. | | `--stdin` | Read all of standard input. | | `-o FILE`, `--output FILE` | Write UTF-8 output to FILE. | | `--copy` | Also copy the output to the clipboard. The paste UI always copies. | @@ -67,22 +90,22 @@ Piped or quoted input writes the hidden payload to stdout. `--visible` writes th `--non-interactive` is an alias of `--stdin`. -Quoted text that is not an existing file is transformed as a string. Existing files are read as UTF-8. A path-like argument that is missing (for example `notes.txt`) is an error, not a string. Invalid UTF-8 input is rejected. - ## Supported input -Tab, newline, carriage return, and ASCII space through tilde. Other Unicode is returned unchanged (exit 0). Only UTF-8 files. +Tab, newline, carriage return, and ASCII space through tilde. Other Unicode is returned unchanged (exit 0). Exit 0 means I/O succeeded. It does not mean that a transformation occurred or that a watermark was removed. Only UTF-8 files. -Machine spans stay intact: fenced/inline code, markdown destinations, URLs, emails, IPs, dates, currency, percents, numbers, POSIX/Windows paths, CLI flags. Quote interiors are eligible. Cap 192 insertion sites. +Machine spans stay intact: fenced/inline code, markdown destinations, markdown reference labels, URLs, emails, IPs, dates, currency, percents, numbers, POSIX/Windows paths (including relative paths such as `src/main.py` and `C:/Users/...`), CLI flags. Quote interiors are eligible. Cap 192 insertion sites. Insertions fill the first 192 eligible letter sites and then stop, so the tail of a long document is unchanged. If the text is already transformed, or has no eligible letters, the CLI returns it unchanged. +Same visible words are not the same as identical Markdown, path, or search behavior in other programs. Reports must not treat visible-projection equality as Markdown or filesystem equality. + ## Exit status | Status | Meaning | | ---: | ---: | -| 0 | Output was written or copied. Unsupported input is returned unchanged. | -| 1 | No input, bad file, unsupported encoding, missing `:done`, or output could not be written. | +| 0 | Output was written or copied. Unsupported Unicode is returned unchanged. | +| 1 | No input, bad file, invalid UTF-8, unsupported encoding, missing `:done`, or output could not be written. | | 2 | Transform succeeded, but clipboard copy failed. Stream modes still write stdout. The paste UI does not print the payload. | | 130 | Interrupted with Ctrl+C. | diff --git a/docs/product-contract.md b/docs/product-contract.md index 2c46d947..2c970c6e 100644 --- a/docs/product-contract.md +++ b/docs/product-contract.md @@ -32,11 +32,12 @@ These are not product success: - hyphenation or apostrophe substitution; - extra or missing visible spaces; - newline / layout changes; -- homoglyphs or compatibility characters. +- homoglyphs or compatibility characters; +- the same visible projection implying the same Markdown, path, or search behavior in other software. ## Current product path -`release_transform_registry()` stays empty. The public CLI (`release-cli-v5`) applies frozen `apply_letter_alternating_mix` directly: U+034F on even selected ASCII-letter sites, U+FE00 on odd sites, cap 192, outside hard machine spans. +`release_transform_registry()` stays empty. The public CLI (`release-cli-v5`) applies `apply_letter_alternating_mix` directly: U+034F on even selected ASCII-letter sites, U+FE00 on odd sites, cap 192, outside hard machine spans. Live protection now also covers common relative paths without a leading `./`, Windows paths that use `/`, and Markdown reference labels at both uses and definitions. Those protection changes can change outputs versus the v0.4.0 freeze of `letter_mix.py` (`cycle8-mix-freeze-v1`, `letter_mix_source_sha256` `b1ceec24e584c0e9e7135ef0c89a3bd249b0bda4a45e07aa7190b1b010ba56d4`). Frozen confirmation hashes stay historical. They are not silently rewritten. Live `product_approved_carriers_v1()` is `{U+034F, U+FE00}`. The v1 contract's empty `product_authorized_carriers_v1` list is a historical snapshot of that file. diff --git a/evidence/audit-fixes-2026-08-27/README.md b/evidence/audit-fixes-2026-08-27/README.md new file mode 100644 index 00000000..236ff147 --- /dev/null +++ b/evidence/audit-fixes-2026-08-27/README.md @@ -0,0 +1,46 @@ +# Audit fix evidence (2026-08-27) + +This directory records measurements and invalidations for the F01-F07 audit fixes. It does not rewrite historical evidence files. + +Audited baseline commit: `be6ae7645fda8b39d1d308722ac249f519e68de5` (package 0.4.0). +Fix commit: `1d686b6eb133c29479b2d9bc1b609f929b63e06e`. + +## F01 contenteditable rendering + +`cycle8-benchmark-render-v1` assigned `element.value` for every surface, then checked `'value' in element`. On a contenteditable div that assignment created a `value` property, so `textContent` never ran. Screenshots compared two blank divs and stored `VERIFIED`. + +Those `chromium_contenteditable` VERIFIED rows are not proof of text rendering equivalence: + +- `evidence/cycle8-letter-system-benchmark-2026-08-26/local-system.json` +- `evidence/cycle8-letter-system-benchmark-2026-08-26/scorecard.json` +- `evidence/cycle8-mix-margin-2026-08-26/local-system.json` +- `evidence/cycle8-mix-margin-2026-08-26/scorecard.json` + +Those files and their SHA-256 sums are unchanged. `render-v2.json` is the replacement measurement (`cycle8-benchmark-render-v2`). + +On this host: + +- Chromium: Google Chrome 148.0.7778.96 +- Font: DejaVu Sans Mono +- contenteditable AAAA vs BBBB: REJECTED +- contenteditable AAAA vs AAAA: VERIFIED +- contenteditable AAAA vs empty: REJECTED +- textarea has the same positive, negative, and nonempty-vs-empty controls +- Safari/WebKit: UNKNOWN +- terminal pixels: UNKNOWN + +An unavailable browser returns UNKNOWN, never VERIFIED. + +## F02 and F03 protection + +Live `hard_machine_intervals` now protects common relative paths without `./`, Windows paths that use `/`, and Markdown reference labels at uses and definitions. That can change mix output bytes versus the frozen `u034f-ufe00-letter-alt-v1` snapshot. + +`cycle8-mix-freeze-v1` remains the historical freeze. Its `letter_mix_source_sha256` is pinned to `b1ceec24e584c0e9e7135ef0c89a3bd249b0bda4a45e07aa7190b1b010ba56d4`, the `letter_mix.py` bytes at freeze time. Live `letter_mix.py` may differ. Frozen confirmation output hashes are not regenerated. + +## F07 scan refactor + +`letter-mix-scan.json` is a same-host before/after-style observation of the post-fix scanner. It is not a cross-machine performance claim. Final compose-time invariant checks remain. + +## L01-L04 + +These stay product limitations, not silent bug closures. See README and `docs/cli.md`. diff --git a/evidence/audit-fixes-2026-08-27/letter-mix-scan.json b/evidence/audit-fixes-2026-08-27/letter-mix-scan.json new file mode 100644 index 00000000..a708932d --- /dev/null +++ b/evidence/audit-fixes-2026-08-27/letter-mix-scan.json @@ -0,0 +1,92 @@ +{ + "algorithm_version": "letter-mix-scan-v2-host-observation", + "artifact_hash": "74928e81db58fa5ac1dc007b99f98438315e9647418aa572c3cc7e690aa213ba", + "commit": "1d686b6eb133c29479b2d9bc1b609f929b63e06e", + "note": "Host observations only. Not a cross-machine performance guarantee.", + "timings": [ + { + "input_chars": 1040, + "median_seconds": 0.0013507550002032076, + "min_seconds": 0.0013172589999612683, + "name": "prose_1k", + "output_chars": 1232, + "seconds": [ + 0.0016266380000615754, + 0.0013507550002032076, + 0.0013172589999612683 + ] + }, + { + "input_chars": 10400, + "median_seconds": 0.011578481000015017, + "min_seconds": 0.011543329000005542, + "name": "prose_10k", + "output_chars": 10592, + "seconds": [ + 0.011669592000089324, + 0.011543329000005542, + 0.011578481000015017 + ] + }, + { + "input_chars": 104000, + "median_seconds": 0.1135778840000512, + "min_seconds": 0.11315520800008017, + "name": "prose_100k", + "output_chars": 104192, + "seconds": [ + 0.11315520800008017, + 0.1135778840000512, + 0.11412591299995256 + ] + }, + { + "input_chars": 9249, + "median_seconds": 0.0160103750001781, + "min_seconds": 0.012477553000053376, + "name": "url_only_10k", + "output_chars": 9249, + "seconds": [ + 0.012477553000053376, + 0.01665635100016516, + 0.0160103750001781 + ] + }, + { + "input_chars": 18499, + "median_seconds": 0.024290382999879512, + "min_seconds": 0.02418305700007295, + "name": "url_only_20k", + "output_chars": 18499, + "seconds": [ + 0.024858777999952508, + 0.02418305700007295, + 0.024290382999879512 + ] + }, + { + "input_chars": 2200, + "median_seconds": 0.0031189240000912832, + "min_seconds": 0.003113965000011376, + "name": "no_sites", + "output_chars": 2200, + "seconds": [ + 0.0032563459999437327, + 0.003113965000011376, + 0.0031189240000912832 + ] + }, + { + "input_chars": 4820, + "median_seconds": 0.029459684999892488, + "min_seconds": 0.029254702999878646, + "name": "long_protected", + "output_chars": 4820, + "seconds": [ + 0.029254702999878646, + 0.029459684999892488, + 0.03241230200001155 + ] + } + ] +} diff --git a/evidence/audit-fixes-2026-08-27/render-v2.json b/evidence/audit-fixes-2026-08-27/render-v2.json new file mode 100644 index 00000000..dc945254 --- /dev/null +++ b/evidence/audit-fixes-2026-08-27/render-v2.json @@ -0,0 +1,129 @@ +{ + "algorithm_version": "cycle8-benchmark-render-v2", + "artifact_hash": "79f45bfa0914d673c919beba70b15e9c00191630c61ef82fb9703b7ff9da4ff2", + "browser_version": "Google Chrome 148.0.7778.96", + "chrome_executable": "/usr/local/bin/google-chrome", + "commit": "1d686b6eb133c29479b2d9bc1b609f929b63e06e", + "font": "DejaVu Sans Mono", + "historical_evidence_invalid_because": "cycle8-benchmark-render-v1 assigned element.value for every surface, then checked 'value' in element. That created a value property on contenteditable divs, skipped textContent, and screenshot-compared two blank divs as VERIFIED.", + "historical_files_not_rewritten": true, + "measurement_scope": "contenteditable and textarea AAAA/BBBB positive, negative, and nonempty-vs-empty controls on this host", + "rows": [ + { + "original": "AAAA", + "pair": "negative", + "result": { + "algorithm_version": "cycle8-benchmark-render-v2", + "browser_version": "Google Chrome 148.0.7778.96", + "comparison_hash": "7363f35290941fe43459f5250fa09a1cfc0cc6870522fee1d032661c743ce45e", + "detail": "png_bytes_differ", + "environment": "chromium_contenteditable", + "equal": false, + "font": "DejaVu Sans Mono", + "original_sha256": "63c1dd951ffedf6f7fd968ad4efa39b8ed584f162f46e715114ee184f8de9201", + "status": "REJECTED", + "transformed_sha256": "4a8d8134f29b0b7b60c126f5532bc9f5d9bb73037373cf6fb872d81f1dcefdfd" + }, + "surface": "contenteditable", + "transformed": "BBBB" + }, + { + "original": "AAAA", + "pair": "positive", + "result": { + "algorithm_version": "cycle8-benchmark-render-v2", + "browser_version": "Google Chrome 148.0.7778.96", + "comparison_hash": "f69b8b92d51652f58fcdf30fbe89353c1b7f9a00c49b4dca050ae4001cda41cd", + "detail": "png_bytes_equal", + "environment": "chromium_contenteditable", + "equal": true, + "font": "DejaVu Sans Mono", + "original_sha256": "63c1dd951ffedf6f7fd968ad4efa39b8ed584f162f46e715114ee184f8de9201", + "status": "VERIFIED", + "transformed_sha256": "63c1dd951ffedf6f7fd968ad4efa39b8ed584f162f46e715114ee184f8de9201" + }, + "surface": "contenteditable", + "transformed": "AAAA" + }, + { + "original": "AAAA", + "pair": "nonempty_vs_empty", + "result": { + "algorithm_version": "cycle8-benchmark-render-v2", + "browser_version": "Google Chrome 148.0.7778.96", + "comparison_hash": "c432290011d4d4e4590202997252341293136ecf5430425703a24841eb581a73", + "detail": "png_bytes_differ", + "environment": "chromium_contenteditable", + "equal": false, + "font": "DejaVu Sans Mono", + "original_sha256": "63c1dd951ffedf6f7fd968ad4efa39b8ed584f162f46e715114ee184f8de9201", + "status": "REJECTED", + "transformed_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "surface": "contenteditable", + "transformed": "" + }, + { + "original": "AAAA", + "pair": "negative", + "result": { + "algorithm_version": "cycle8-benchmark-render-v2", + "browser_version": "Google Chrome 148.0.7778.96", + "comparison_hash": "9b7ba94e863ad3c4b267aa59750ddc644b850110dccc4563dabb5eab2f390cb1", + "detail": "png_bytes_differ", + "environment": "chromium_textarea", + "equal": false, + "font": "DejaVu Sans Mono", + "original_sha256": "63c1dd951ffedf6f7fd968ad4efa39b8ed584f162f46e715114ee184f8de9201", + "status": "REJECTED", + "transformed_sha256": "4a8d8134f29b0b7b60c126f5532bc9f5d9bb73037373cf6fb872d81f1dcefdfd" + }, + "surface": "textarea", + "transformed": "BBBB" + }, + { + "original": "AAAA", + "pair": "positive", + "result": { + "algorithm_version": "cycle8-benchmark-render-v2", + "browser_version": "Google Chrome 148.0.7778.96", + "comparison_hash": "c8e0a881d1f514214edfa79d535bdce7de77e11c833bcf44e636c2c7246fb87b", + "detail": "png_bytes_equal", + "environment": "chromium_textarea", + "equal": true, + "font": "DejaVu Sans Mono", + "original_sha256": "63c1dd951ffedf6f7fd968ad4efa39b8ed584f162f46e715114ee184f8de9201", + "status": "VERIFIED", + "transformed_sha256": "63c1dd951ffedf6f7fd968ad4efa39b8ed584f162f46e715114ee184f8de9201" + }, + "surface": "textarea", + "transformed": "AAAA" + }, + { + "original": "AAAA", + "pair": "nonempty_vs_empty", + "result": { + "algorithm_version": "cycle8-benchmark-render-v2", + "browser_version": "Google Chrome 148.0.7778.96", + "comparison_hash": "d27a138c9e29a039f318b7d4bd55e27c1fa6fe0fd1545cb4bbba6eb21f9a97c4", + "detail": "png_bytes_differ", + "environment": "chromium_textarea", + "equal": false, + "font": "DejaVu Sans Mono", + "original_sha256": "63c1dd951ffedf6f7fd968ad4efa39b8ed584f162f46e715114ee184f8de9201", + "status": "REJECTED", + "transformed_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "surface": "textarea", + "transformed": "" + } + ], + "superseded_paths": [ + "evidence/cycle8-letter-system-benchmark-2026-08-26/local-system.json", + "evidence/cycle8-letter-system-benchmark-2026-08-26/scorecard.json", + "evidence/cycle8-mix-margin-2026-08-26/local-system.json", + "evidence/cycle8-mix-margin-2026-08-26/scorecard.json" + ], + "terminal_pixels": "UNKNOWN", + "webkit_safari": "UNKNOWN" +} diff --git a/fuckmark/cli.py b/fuckmark/cli.py index 5d91f28a..b9a18c6a 100644 --- a/fuckmark/cli.py +++ b/fuckmark/cli.py @@ -2,6 +2,7 @@ import argparse import os +import re import shutil import subprocess import sys @@ -111,14 +112,19 @@ def _parser() -> argparse.ArgumentParser: "Examples:\n" " fuckmark\n" " printf 'I do not agree.\\n' | fuckmark\n" + " fuckmark --text \"I do not agree.\"\n" " fuckmark \"I do not agree.\"\n" + " fuckmark --text \"I agree. You are right\"\n" + " fuckmark --file notes.txt -o notes.fm.txt\n" " fuckmark notes.txt -o notes.fm.txt\n" " fuckmark --stdin --copy < notes.txt\n" " fuckmark --stdin --visible < notes.fm.txt\n" "\n" "Supported input: tab, newline, carriage return, and ASCII space through tilde.\n" - "Other Unicode is returned unchanged. Only UTF-8 is supported.\n" - "Visible text stays the same. Use --visible to print that visible text." + "Other Unicode is returned unchanged with exit 0. That status means I/O\n" + "succeeded, not that a transformation occurred. Only UTF-8 is supported.\n" + "Visible text stays the same. Use --visible to print that visible text.\n" + "Use --text for literal strings and --file for existing UTF-8 files." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -128,6 +134,18 @@ def _parser() -> argparse.ArgumentParser: metavar="TEXT_OR_FILE", help="quoted text or UTF-8 file; omit in a terminal to paste; - reads stdin", ) + parser.add_argument( + "--text", + dest="text_literal", + metavar="TEXT", + help="transform TEXT as a literal string (not a filename)", + ) + parser.add_argument( + "--file", + dest="file_explicit", + metavar="FILE", + help="read UTF-8 bytes from FILE without newline conversion", + ) parser.add_argument( "--version", action="version", @@ -186,18 +204,20 @@ def _looks_like_missing_file(value: str) -> bool: if any(separator in value for separator in separators) or value.startswith("~"): return True suffix = expanded.suffix - return len(suffix) >= 2 + if " " in suffix or "\t" in suffix: + return False + return re.fullmatch(r"\.[A-Za-z][A-Za-z0-9]{0,11}", suffix) is not None def _load_source_argument(value: str) -> str: expanded = Path(value).expanduser() if expanded.is_dir(): - raise ValueError(f"{value} is a directory. Pass a UTF-8 text file or quote the text.") + raise ValueError(f"{value} is a directory. Pass a UTF-8 text file or --text.") if expanded.is_file(): return _read_file(str(expanded)) if _looks_like_missing_file(value): raise ValueError( - f"file not found: {value}. Pass an existing UTF-8 file, or quote the text: fuckmark \"...\"" + f"file not found: {value}. Pass an existing UTF-8 file with --file, or a literal string with --text." ) return value @@ -274,13 +294,33 @@ def _styled(text: str, code: str, *, enabled: bool) -> str: return f"{code}{text}{_ANSI_RESET}" if enabled else text +def _decode_utf8_bytes(data: bytes, origin: str) -> str: + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + raise ValueError(f"{origin} is not valid UTF-8. Only UTF-8 is supported.") from error + if any(0xD800 <= ord(character) <= 0xDFFF for character in text): + raise ValueError(f"{origin} is not valid UTF-8. Only UTF-8 is supported.") + return text + + def _read_file(path_value: str) -> str: + path = Path(path_value).expanduser() try: - return Path(path_value).expanduser().read_text(encoding="utf-8") - except UnicodeError as error: - raise ValueError(f"cannot decode {path_value!r} as UTF-8: {error}") from error + data = path.read_bytes() except OSError as error: raise ValueError(f"cannot read {path_value!r}: {error}") from error + return _decode_utf8_bytes(data, path_value) + + +def _read_stream_text(source: TextIO) -> str: + buffer = getattr(source, "buffer", None) + if buffer is not None: + return _decode_utf8_bytes(buffer.read(), "input") + text = source.read() + if any(0xD800 <= ord(character) <= 0xDFFF for character in text): + raise ValueError("input is not valid UTF-8. Only UTF-8 is supported.") + return text def _same_path(left: str, right: str) -> bool: @@ -291,31 +331,30 @@ def _ensure_utf8(stream: TextIO) -> None: reconfigure = getattr(stream, "reconfigure", None) if reconfigure is None: return - encoding = getattr(stream, "encoding", None) - if isinstance(encoding, str) and encoding.replace("-", "").casefold() == "utf8": - return try: - reconfigure(encoding="utf-8", errors="strict") - except (OSError, ValueError, AttributeError): - return + reconfigure(encoding="utf-8", errors="strict", newline="") + except (OSError, ValueError, AttributeError, TypeError): + try: + reconfigure(encoding="utf-8", errors="strict") + except (OSError, ValueError, AttributeError): + return def _write_stdout_utf8(output_stream: TextIO, text: str) -> None: - try: - output_stream.write(text) - output_stream.flush() - return - except UnicodeError: - pass buffer = getattr(output_stream, "buffer", None) - if buffer is None: - raise ValueError("standard output cannot encode UTF-8 product payload") + if buffer is not None: + try: + output_stream.flush() + except UnicodeError: + pass + buffer.write(text.encode("utf-8")) + buffer.flush() + return try: + output_stream.write(text) output_stream.flush() - except UnicodeError: - pass - buffer.write(text.encode("utf-8")) - buffer.flush() + except UnicodeError as error: + raise ValueError("standard output cannot encode UTF-8 product payload") from error def _write_file_atomic(path_value: str, text: str) -> None: @@ -420,23 +459,47 @@ def _run( ) source_arg = arguments.source + text_literal = arguments.text_literal + file_explicit = arguments.file_explicit wants_stdin = arguments.stdin_mode or source_arg == "-" + if text_literal is not None and file_explicit is not None: + return _error(errors, "pass --text or --file, not both") + if wants_stdin and (text_literal is not None or file_explicit is not None): + return _error(errors, "pass --text, --file, or --stdin, not both") if wants_stdin and source_arg not in (None, "-"): return _error(errors, "pass quoted text, a file, or --stdin, not both") + if text_literal is not None and source_arg is not None: + return _error(errors, "pass --text or a positional operand, not both") + if file_explicit is not None and source_arg is not None: + return _error(errors, "pass --file or a positional operand, not both") + file_for_overwrite = file_explicit if file_explicit is not None else source_arg if ( arguments.output not in (None, "-") - and source_arg not in (None, "-") - and Path(source_arg).expanduser().is_file() - and _same_path(source_arg, arguments.output) + and file_for_overwrite not in (None, "-") + and Path(str(file_for_overwrite)).expanduser().is_file() + and _same_path(str(file_for_overwrite), arguments.output) ): return _error(errors, "input and output files must be different") - literal_or_file = source_arg not in (None, "-") and not arguments.stdin_mode + literal_or_file = ( + text_literal is not None + or file_explicit is not None + or (source_arg not in (None, "-") and not arguments.stdin_mode) + ) interactive = (not wants_stdin) and (not literal_or_file) and _is_tty(source) color = _is_tty(errors) and not arguments.no_color and "NO_COLOR" not in os.environ try: - if literal_or_file: + if text_literal is not None: + text = text_literal + elif file_explicit is not None: + expanded = Path(file_explicit).expanduser() + if expanded.is_dir(): + raise ValueError(f"{file_explicit} is a directory. Pass a UTF-8 text file.") + if not expanded.is_file(): + raise ValueError(f"file not found: {file_explicit}") + text = _read_file(str(expanded)) + elif literal_or_file: text = _load_source_argument(str(source_arg)) elif interactive: captured = read_interactive_text(source, errors, color=color) @@ -446,7 +509,7 @@ def _run( return _error(errors, "no input. Paste or type text, then :done.") text = "\n".join(captured) else: - text = source.read() + text = _read_stream_text(source) except UnicodeError: return _error(errors, "input is not valid UTF-8. Only UTF-8 is supported.") except (OSError, ValueError) as error: diff --git a/fuckmark/cycle8/benchmark_render.py b/fuckmark/cycle8/benchmark_render.py index 0894055f..94dee5ed 100644 --- a/fuckmark/cycle8/benchmark_render.py +++ b/fuckmark/cycle8/benchmark_render.py @@ -11,7 +11,22 @@ from ..product.rendering import chrome_executable, compare_chrome_pre_screenshots -BENCHMARK_RENDER_VERSION = "cycle8-benchmark-render-v1" +BENCHMARK_RENDER_VERSION = "cycle8-benchmark-render-v2" +BENCHMARK_RENDER_FONT = "DejaVu Sans Mono" + + +def chrome_version(executable: str | None = None) -> str | None: + path = executable if executable is not None else chrome_executable() + if path is None: + return None + try: + output = subprocess.check_output([path, "--version"], timeout=10, stderr=subprocess.DEVNULL) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + return None + try: + return output.decode("utf-8").strip() + except UnicodeError: + return None def _html_surface(text: str, surface: str) -> str: @@ -23,6 +38,7 @@ def _html_surface(text: str, surface: str) -> str: "caret-color:transparent;outline:none;font:16px/24px 'DejaVu Sans Mono',monospace;" "color:#000;background:#fff\">" ) + assign = f"el.value={payload};actual=el.value;" elif surface == "contenteditable": body = ( "