@pipeworx/mcp-edge-case-museum 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -12
- package/bin/cli.js +55 -11
- package/lib/index.js +333 -0
- package/package.json +17 -16
- package/server.json +1 -1
- package/tools.json +98 -0
- package/src/bake.mjs +0 -112
- package/src/ecm-index-data.ts +0 -15
- package/src/exhibits/datetime/add-24-hours-vs-add-1-day-across-dst.json +0 -51
- package/src/exhibits/datetime/add-365-days-to-feb-29-not-next-feb-29.json +0 -49
- package/src/exhibits/datetime/age-calculator-feb29-birthday-crash-vs-silent.json +0 -52
- package/src/exhibits/datetime/century-leap-year-1900-not-leap.json +0 -68
- package/src/exhibits/datetime/datetime-utcnow-naive-deprecated.json +0 -39
- package/src/exhibits/datetime/dst-fall-back-ambiguous-fold.json +0 -51
- package/src/exhibits/datetime/dst-spring-forward-nonexistent-time.json +0 -57
- package/src/exhibits/datetime/fractional-utc-offsets-kathmandu-kolkata.json +0 -50
- package/src/exhibits/datetime/iso-week-year-vs-calendar-year.json +0 -48
- package/src/exhibits/datetime/js-date-field-overflow-rollover.json +0 -49
- package/src/exhibits/datetime/leap-second-rejected-vs-silently-invalid.json +0 -57
- package/src/exhibits/datetime/lord-howe-30-minute-dst.json +0 -47
- package/src/exhibits/datetime/midnight-that-doesnt-exist-sao-paulo.json +0 -53
- package/src/exhibits/datetime/python-naive-aware-comparison-typeerror.json +0 -46
- package/src/exhibits/datetime/samoa-skipped-day-2011.json +0 -50
- package/src/exhibits/datetime/southern-hemisphere-dst-reversed.json +0 -50
- package/src/exhibits/datetime/two-digit-year-century-pivot-mismatch.json +0 -49
- package/src/exhibits/datetime/year-2038-int32-overflow.json +0 -52
- package/src/exhibits/filenames/absolute-second-argument-resets-join.json +0 -62
- package/src/exhibits/filenames/bidi-override-extension-spoof.json +0 -58
- package/src/exhibits/filenames/case-insensitive-filesystem-pure-path-mismatch.json +0 -74
- package/src/exhibits/filenames/dotfile-has-no-extension.json +0 -61
- package/src/exhibits/filenames/extension-trap-double-extension.json +0 -62
- package/src/exhibits/filenames/filename-length-limits-bytes-not-chars.json +0 -66
- package/src/exhibits/filenames/leading-dash-filename-parsed-as-flag.json +0 -67
- package/src/exhibits/filenames/nfc-nfd-filename-lookup-mismatch.json +0 -68
- package/src/exhibits/filenames/ntfs-alternate-data-stream-colon.json +0 -59
- package/src/exhibits/filenames/ntpath-splitdrive-unc-paths.json +0 -58
- package/src/exhibits/filenames/nul-byte-in-filename-raises-at-open.json +0 -60
- package/src/exhibits/filenames/path-join-escapes-the-base-directory.json +0 -63
- package/src/exhibits/filenames/windows-backslash-traversal-ntpath.json +0 -62
- package/src/exhibits/filenames/windows-reserved-device-names.json +0 -73
- package/src/exhibits/filenames/windows-trailing-dot-space-stripped.json +0 -77
- package/src/exhibits/float-money/decimal-from-float-vs-string.json +0 -66
- package/src/exhibits/float-money/float-anchor-0-1-plus-0-2.json +0 -67
- package/src/exhibits/float-money/intl-number-format-de-de-roundtrip-misparsed.json +0 -62
- package/src/exhibits/float-money/iso4217-minor-units-not-always-two.json +0 -78
- package/src/exhibits/float-money/math-abs-negative-zero.json +0 -51
- package/src/exhibits/float-money/max-safe-integer-plus-two.json +0 -62
- package/src/exhibits/float-money/naive-loop-sum-order-dependence.json +0 -71
- package/src/exhibits/float-money/nan-breaks-equality-and-max-min.json +0 -70
- package/src/exhibits/float-money/negative-zero-balance-formatting.json +0 -74
- package/src/exhibits/float-money/number-epsilon-false-positive-small-magnitude.json +0 -52
- package/src/exhibits/float-money/parsefloat-comma-thousands-separator.json +0 -66
- package/src/exhibits/float-money/python-banker-rounding-vs-js-round-half-up.json +0 -65
- package/src/exhibits/float-money/python-round-2-675-float-imprecision.json +0 -64
- package/src/exhibits/float-money/splitting-100-dollars-three-ways-leaves-a-cent.json +0 -63
- package/src/exhibits/float-money/sum-ten-dimes-not-a-dollar.json +0 -77
- package/src/exhibits/float-money/tofixed-1-005-rounds-down-not-up.json +0 -63
- package/src/exhibits/float-money/tofixed-2-345-inconsistent-rounding.json +0 -64
- package/src/exhibits/integers/array-index-above-2-32-2-not-an-index.json +0 -42
- package/src/exhibits/integers/bigint-number-mixing-typeerror.json +0 -49
- package/src/exhibits/integers/bitwise-32-bit-truncation.json +0 -56
- package/src/exhibits/integers/bool-is-int-subclass-dict-key-collision.json +0 -58
- package/src/exhibits/integers/floor-div-mod-sign-python-vs-js.json +0 -48
- package/src/exhibits/integers/json-parse-loses-id-precision.json +0 -58
- package/src/exhibits/integers/max-safe-integer-plus-one-equals-plus-two.json +0 -58
- package/src/exhibits/integers/number-empty-and-whitespace-string-equals-zero.json +0 -55
- package/src/exhibits/integers/parseint-null-radix36-digit-soup.json +0 -57
- package/src/exhibits/integers/parseint-number-argument-scientific-notation.json +0 -48
- package/src/exhibits/integers/parseint-stops-at-first-invalid-char-vs-number.json +0 -58
- package/src/exhibits/integers/python-int-accepts-fullwidth-unicode-digits.json +0 -56
- package/src/exhibits/integers/python-int-accepts-underscores-and-whitespace.json +0 -48
- package/src/exhibits/integers/python-unbounded-int-vs-fixed-width-wrap.json +0 -49
- package/src/exhibits/names-addresses/address-with-no-street-number.json +0 -54
- package/src/exhibits/names-addresses/ascii-regex-rejects-accented-and-hyphenated-names.json +0 -55
- package/src/exhibits/names-addresses/house-number-suffix-collapsed-by-digit-extraction.json +0 -55
- package/src/exhibits/names-addresses/mononym-fails-first-last-required.json +0 -56
- package/src/exhibits/names-addresses/naive-lowercase-breaks-greek-final-sigma.json +0 -64
- package/src/exhibits/names-addresses/null-surname-coerced-to-null-value.json +0 -58
- package/src/exhibits/names-addresses/single-letter-surname-fails-min-length.json +0 -55
- package/src/exhibits/names-addresses/sort-by-surname-particle-convention-mismatch.json +0 -57
- package/src/exhibits/names-addresses/spanish-double-surname-treated-as-single.json +0 -55
- package/src/exhibits/names-addresses/split-surname-on-last-space-loses-particle.json +0 -59
- package/src/exhibits/names-addresses/title-case-mangles-mixed-case-and-apostrophes.json +0 -60
- package/src/exhibits/names-addresses/uk-canada-postal-code-assumed-numeric.json +0 -55
- package/src/exhibits/names-addresses/us-zip-code-leading-zero-lost-as-integer.json +0 -58
- package/src/exhibits/names-addresses/varchar-truncation-splits-name-mid-grapheme.json +0 -63
- package/src/exhibits/parsing/csv-crlf-trailing-blank-line.json +0 -87
- package/src/exhibits/parsing/csv-doubled-quote-escaping.json +0 -70
- package/src/exhibits/parsing/csv-formula-injection-excel.json +0 -71
- package/src/exhibits/parsing/csv-quoted-field-with-comma.json +0 -70
- package/src/exhibits/parsing/csv-quoted-field-with-newline.json +0 -70
- package/src/exhibits/parsing/csv-semicolon-delimited-european-excel.json +0 -70
- package/src/exhibits/parsing/csv-utf8-bom-first-header.json +0 -74
- package/src/exhibits/parsing/email-regex-rejects-valid-apostrophe-address.json +0 -68
- package/src/exhibits/parsing/json-big-integer-precision-loss.json +0 -65
- package/src/exhibits/parsing/json-duplicate-keys-last-wins.json +0 -64
- package/src/exhibits/parsing/json-lone-surrogate-roundtrip.json +0 -58
- package/src/exhibits/parsing/json-nan-infinity-not-valid-json.json +0 -54
- package/src/exhibits/parsing/url-backslash-treated-as-slash-whatwg.json +0 -60
- package/src/exhibits/parsing/url-urlparse-never-raises-on-garbage.json +0 -60
- package/src/exhibits/parsing/url-userinfo-trusted-looking-host.json +0 -60
- package/src/exhibits/sorting/accented-letters-sort-after-z.json +0 -63
- package/src/exhibits/sorting/byte-order-caps-before-lowercase.json +0 -66
- package/src/exhibits/sorting/case-insensitive-sort-lower-vs-casefold.json +0 -56
- package/src/exhibits/sorting/comparator-returns-boolean-js.json +0 -49
- package/src/exhibits/sorting/js-array-sort-stringifies-numbers.json +0 -68
- package/src/exhibits/sorting/locale-collation-de-vs-sv.json +0 -54
- package/src/exhibits/sorting/localecompare-numeric-option.json +0 -50
- package/src/exhibits/sorting/nan-in-sort.json +0 -59
- package/src/exhibits/sorting/natural-sort-breaks-lexicographic.json +0 -63
- package/src/exhibits/sorting/postgres-collation-documented.json +0 -61
- package/src/exhibits/sorting/python-cannot-compare-str-int.json +0 -70
- package/src/exhibits/sorting/reverse-sort-vs-reverse-after-sort-ties.json +0 -48
- package/src/exhibits/sorting/sorting-mixed-none-null.json +0 -56
- package/src/exhibits/sorting/typed-array-sort-is-numeric-by-default.json +0 -48
- package/src/exhibits/unicode/arabic-indic-digit-parses.json +0 -73
- package/src/exhibits/unicode/bidi-override-filename-spoof.json +0 -62
- package/src/exhibits/unicode/cyrillic-homoglyph-paypal-spoof.json +0 -67
- package/src/exhibits/unicode/fullwidth-bypasses-keyword-filter.json +0 -61
- package/src/exhibits/unicode/german-eszett-case-fold.json +0 -72
- package/src/exhibits/unicode/grapheme-vs-codepoint-length.json +0 -57
- package/src/exhibits/unicode/greek-final-sigma-casefold.json +0 -54
- package/src/exhibits/unicode/js-word-regex-ascii-only.json +0 -64
- package/src/exhibits/unicode/nfc-nfd-same-name-two-accounts.json +0 -70
- package/src/exhibits/unicode/nfkc-compatibility-folding.json +0 -63
- package/src/exhibits/unicode/truncation-orphans-combining-mark.json +0 -62
- package/src/exhibits/unicode/truncation-splits-surrogate-pair.json +0 -65
- package/src/exhibits/unicode/turkish-dotless-i-case-mapping.json +0 -72
- package/src/exhibits/unicode/zalgo-combining-mark-stacking.json +0 -57
- package/src/exhibits/unicode/zero-width-space-username.json +0 -62
- package/src/harness.mjs +0 -331
- package/src/index.ts +0 -686
- package/src/server.ts +0 -49
- package/src/verified.json +0 -977
- package/tsconfig.json +0 -18
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "truncation-splits-surrogate-pair",
|
|
3
|
-
"title": "Truncating a JavaScript string to \"4 characters\" can produce an unencodable string",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "Slicing a string to the first N characters always produces a valid, re-encodable string.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"Hi \\u{1F600}\".slice(0, 4) where \\u{1F600} is GRINNING FACE (outside the Basic Multilingual Plane)",
|
|
8
|
-
"description": "A bio/comment preview truncated to a fixed character count for a card or notification, where the full text happens to end exactly on an emoji."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "slice(0, 4) cuts between the two UTF-16 code units of the emoji, leaving a lone surrogate. encodeURIComponent() on the result throws \"URI malformed\", and converting it to UTF-8 (Buffer.from, TextEncoder) silently replaces it with U+FFFD, the replacement character.",
|
|
11
|
-
"why": "JavaScript strings are sequences of 16-bit UTF-16 code units, and .length and .slice() count code units. Any code point above U+FFFF (which includes most emoji) is stored as a surrogate pair, and slicing between the two halves leaves a lone surrogate, which is not well-formed UTF-16. Lone surrogates have no UTF-8 encoding, so encodeURIComponent throws, while TextEncoder and Buffer substitute U+FFFD. Python's str indexes by code point, so a character-count slice never splits a code point; but Python code that truncates encoded UTF-8 by BYTE count (common for a database column limit) has the same failure one level down: decoding raises UnicodeDecodeError, or substitutes U+FFFD with errors=\"replace\".",
|
|
12
|
-
"affects": [
|
|
13
|
-
"javascript"
|
|
14
|
-
],
|
|
15
|
-
"repros": [
|
|
16
|
-
{
|
|
17
|
-
"language": "python",
|
|
18
|
-
"code": "s = \"Hi \\U0001F600\"\nprint(len(s), s[:4] == s)\nb = s.encode(\"utf-8\")\nbcut = b[:5]\ntry:\n bcut.decode(\"utf-8\")\n print(\"decoded clean\")\nexcept UnicodeDecodeError as e:\n print(\"UnicodeDecodeError\")\nprint(bcut.decode(\"utf-8\", errors=\"replace\"))\n",
|
|
19
|
-
"expected_output": "4 True\nUnicodeDecodeError\nHi �",
|
|
20
|
-
"verified_with": "Python 3.14.2"
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"language": "javascript",
|
|
24
|
-
"code": "const s = \"Hi \\u{1F600}\";\nconsole.log(s.length);\nconst cut = s.slice(0, 4);\nconsole.log(JSON.stringify(cut), cut.length);\nconsole.log(Buffer.from(cut, \"utf8\"));\ntry {\n encodeURIComponent(cut);\n console.log(\"encoded clean\");\n} catch (e) {\n console.log(e.message);\n}\n",
|
|
25
|
-
"expected_output": "5\n\"Hi \\ud83d\" 4\n<Buffer 48 69 20 ef bf bd>\nURI malformed",
|
|
26
|
-
"verified_with": "Node.js v22.22.2"
|
|
27
|
-
}
|
|
28
|
-
],
|
|
29
|
-
"fix": "Truncate by grapheme cluster (Intl.Segmenter in JS) or at minimum by code point (Array.from(s) or [...s]), never by raw .length/slice() index, when the text may contain astral characters. In Python, never truncate encoded UTF-8 bytes to a fixed byte budget without first decoding, truncating the string, and re-encoding.",
|
|
30
|
-
"related": [
|
|
31
|
-
"grapheme-vs-codepoint-length",
|
|
32
|
-
"truncation-orphans-combining-mark"
|
|
33
|
-
],
|
|
34
|
-
"sources": [
|
|
35
|
-
{
|
|
36
|
-
"title": "Unicode Standard: Surrogates (Chapter 3, UTF-16)",
|
|
37
|
-
"url": "https://www.unicode.org/versions/latest/"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"title": "MDN: String.prototype.slice()",
|
|
41
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice"
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
"title": "Python docs: codecs error handlers (UnicodeDecodeError)",
|
|
45
|
-
"url": "https://docs.python.org/3/library/codecs.html#error-handlers"
|
|
46
|
-
}
|
|
47
|
-
],
|
|
48
|
-
"tags": [
|
|
49
|
-
"truncation",
|
|
50
|
-
"length validation",
|
|
51
|
-
"display",
|
|
52
|
-
"parsing",
|
|
53
|
-
"url"
|
|
54
|
-
],
|
|
55
|
-
"test_cases": [
|
|
56
|
-
{
|
|
57
|
-
"inputs": [
|
|
58
|
-
"Hi 😀",
|
|
59
|
-
4
|
|
60
|
-
],
|
|
61
|
-
"relation": "no_crash",
|
|
62
|
-
"note": "their_function(text, maxLen) that truncates to maxLen UTF-16 units should not hand back a string that crashes when URL-encoded or re-serialized."
|
|
63
|
-
}
|
|
64
|
-
]
|
|
65
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "turkish-dotless-i-case-mapping",
|
|
3
|
-
"title": "Lowercasing a Turkish İ does not give you plain \"i\"",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "Lowercasing a capital letter and comparing it to the obvious ASCII spelling works the way it does for A-Z.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"\\u0130stanbul\" (capital I with dot above, U+0130) vs \"istanbul\"",
|
|
8
|
-
"description": "A user from Turkey types their city, or their own name, into a search box or signup form that lowercases input to build a case-insensitive lookup key."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "\"\\u0130stanbul\".lower() is not \"istanbul\" — it becomes \"i\\u0307stanbul\" (9 code points instead of 8), so the case-insensitive match against the plain spelling fails, and NFC normalization does not repair it.",
|
|
11
|
-
"why": "Python's str.lower() and JavaScript's toLowerCase() apply Unicode's default, locale-independent case mapping. The default lowercase of U+0130 (LATIN CAPITAL LETTER I WITH DOT ABOVE) is two code points, U+0069 U+0307 (\"i\" plus COMBINING DOT ABOVE), so the dot is preserved without knowing whether the text is Turkish. Turkish and Azerbaijani pair İ with i and I with ı (dotless), which needs a locale-specific mapping: JavaScript has one in toLocaleLowerCase(\"tr\"), which returns plain \"istanbul\" here, but Python's str methods take no locale. There is no precomposed lowercase \"i with dot above\" (i already has a dot), so NFC normalization afterwards does not collapse the pair back to plain \"i\".",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "import unicodedata\ns = \"\\u0130stanbul\"\nlowered = s.lower()\nprint(lowered, len(s), len(lowered))\nprint(lowered == \"istanbul\")\nprint(unicodedata.normalize(\"NFC\", lowered) == \"istanbul\")\n",
|
|
20
|
-
"expected_output": "i̇stanbul 8 9\nFalse\nFalse",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const s = \"\\u0130stanbul\";\nconst lowered = s.toLowerCase();\nconsole.log(lowered, s.length, lowered.length);\nconsole.log(lowered === \"istanbul\");\nconsole.log(lowered.normalize(\"NFC\") === \"istanbul\");\n",
|
|
26
|
-
"expected_output": "i̇stanbul 8 9\nfalse\nfalse",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "Do not build a uniqueness or search key by calling lower()/upper() on raw user text when the content may be Turkish or Azerbaijani. When you know the locale, use locale-aware mapping (toLocaleLowerCase(\"tr\") in JavaScript, ICU case mapping with locale \"tr\"/\"az\" in Python via a third-party binding). When you do not, use casefold() consistently on both sides, and accept that İ/I/ı/i cannot be folded correctly for every language at once.",
|
|
31
|
-
"related": [
|
|
32
|
-
"german-eszett-case-fold",
|
|
33
|
-
"nfc-nfd-same-name-two-accounts"
|
|
34
|
-
],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "Unicode Standard: SpecialCasing.txt (default vs. Turkish/Azeri case mappings)",
|
|
38
|
-
"url": "https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "Python docs: str.lower / str.upper",
|
|
42
|
-
"url": "https://docs.python.org/3/library/stdtypes.html#str.lower"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "MDN: String.prototype.toLowerCase()",
|
|
46
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase"
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"title": "MDN: String.prototype.toLocaleLowerCase()",
|
|
50
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLocaleLowerCase"
|
|
51
|
-
}
|
|
52
|
-
],
|
|
53
|
-
"tags": [
|
|
54
|
-
"username",
|
|
55
|
-
"uniqueness",
|
|
56
|
-
"login",
|
|
57
|
-
"search",
|
|
58
|
-
"i18n",
|
|
59
|
-
"normalization",
|
|
60
|
-
"validation"
|
|
61
|
-
],
|
|
62
|
-
"test_cases": [
|
|
63
|
-
{
|
|
64
|
-
"inputs": [
|
|
65
|
-
"İstanbul",
|
|
66
|
-
"İSTANBUL"
|
|
67
|
-
],
|
|
68
|
-
"relation": "equal",
|
|
69
|
-
"note": "Any case-insensitive key should unify these two (casefold() does). Whether \"İstanbul\" should also match plain \"istanbul\" depends on the locale: yes under Turkish rules, no under the default mapping."
|
|
70
|
-
}
|
|
71
|
-
]
|
|
72
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "zalgo-combining-mark-stacking",
|
|
3
|
-
"title": "A nickname that looks like one letter can be 161 code points long",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "A short-looking string is a short string, so a per-character length limit also bounds how much text it can contain.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"e\" followed by a combining mark repeated 20 times (\"\\u0301\\u0310\\u0327\\u032d\\u0353\\u0338\\u035f\\u0360\" x 20) — \"Zalgo text\"",
|
|
8
|
-
"description": "A display name, comment, or review submitted with Unicode combining marks stacked onto a single base character (easy to generate with any \"Zalgo text\" web tool), occupying one visual line position but arbitrarily many code points."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "The string occupies roughly one glyph's space (an \"e\" under a stack of marks) but is 161 code points and 321 UTF-8 bytes. A \"nickname must be 1-20 characters\" validator that counts grapheme clusters, the measure usually recommended for user-facing length, counts it as 1 character and accepts it, while it takes far more storage than any 20-character name and can render as a tall smear over neighbouring lines.",
|
|
11
|
-
"why": "Unicode's definition of well-formed text puts no limit on how many combining marks (General Category Mn, Mc, Me) may follow one base character. Each mark is a legal code point that stacks on the previous one, and UAX #29 groups the base and all of its marks into a single grapheme cluster, so a grapheme count sees one character however many marks follow. UAX #15 defines a \"Stream-Safe Text Format\" that caps runs of non-starters at 30, but it is a recommendation that nothing enforces by default. Code-point or byte limits catch this particular string, which is why the fix combines both measures.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "base = \"e\"\nmarks = \"\\u0301\\u0310\\u0327\\u032d\\u0353\\u0338\\u035f\\u0360\"\nzalgo = base + marks * 20\nprint(len(zalgo), len(zalgo.encode(\"utf-8\")))\n",
|
|
20
|
-
"expected_output": "161 321",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const base = \"e\";\nconst marks = \"\\u0301\\u0310\\u0327\\u032d\\u0353\\u0338\\u035f\\u0360\";\nconst zalgo = base + marks.repeat(20);\nconsole.log(zalgo.length, Buffer.byteLength(zalgo, \"utf8\"));\n",
|
|
26
|
-
"expected_output": "161 321",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "Enforce a limit in code points or bytes for storage as well as a grapheme-cluster limit for what the user sees, and cap the number of combining marks per base character (a handful covers real orthographies; UAX #15's Stream-Safe Text Format uses 30 as an upper bound).",
|
|
31
|
-
"related": [
|
|
32
|
-
"grapheme-vs-codepoint-length",
|
|
33
|
-
"bidi-override-filename-spoof"
|
|
34
|
-
],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "UAX #29: Unicode Text Segmentation (grapheme clusters)",
|
|
38
|
-
"url": "https://www.unicode.org/reports/tr29/"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "UAX #15: Stream-Safe Text Format",
|
|
42
|
-
"url": "https://www.unicode.org/reports/tr15/#Stream_Safe_Text_Format"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "Big List of Naughty Strings (MIT)",
|
|
46
|
-
"url": "https://github.com/minimaxir/big-list-of-naughty-strings"
|
|
47
|
-
}
|
|
48
|
-
],
|
|
49
|
-
"tags": [
|
|
50
|
-
"username",
|
|
51
|
-
"length validation",
|
|
52
|
-
"forms",
|
|
53
|
-
"display",
|
|
54
|
-
"validation",
|
|
55
|
-
"security"
|
|
56
|
-
]
|
|
57
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "zero-width-space-username",
|
|
3
|
-
"title": "A username with an invisible character survives trim() and slips past a block on \"admin\"",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "strip()/trim() removes anything that isn't visible, so a trimmed string can't contain hidden characters.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"ad\\u200bmin\" (ZERO WIDTH SPACE, U+200B, inserted in the middle) vs \"admin\"",
|
|
8
|
-
"description": "Pasted text from a chat app, a scraped web page, or a deliberately crafted signup request trying to register a name that looks reserved but technically isn't."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "\"ad\\u200bmin\" renders as \"admin\", is not equal to \"admin\", is one code point longer, and strip()/trim() leaves it completely unchanged because U+200B sits in the middle of the string, not at an edge, and Unicode does not even classify it as whitespace.",
|
|
11
|
-
"why": "ZERO WIDTH SPACE (U+200B) has no visible glyph; text-layout engines use it to mark an optional line break inside long words. Its General Category is Cf (format), not Zs (space separator), and it does not have the Unicode White_Space property, so Python's str.strip() and JavaScript's trim() do not treat it as whitespace, and here it is in the middle of the string anyway. ZERO WIDTH JOINER (U+200D) and ZERO WIDTH NO-BREAK SPACE / BOM (U+FEFF) work the same way in the middle of a string. (At the edges JavaScript's trim() does remove U+FEFF, because ECMAScript counts it as whitespace; Python's strip() does not.)",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "a = \"admin\"\nb = \"ad\\u200bmin\"\nprint(a == b, len(a), len(b))\nprint(b.strip() == a, len(b.strip()))\n",
|
|
20
|
-
"expected_output": "False 5 6\nFalse 6",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const a = \"admin\";\nconst b = \"ad\\u200bmin\";\nconsole.log(a === b, a.length, b.length);\nconsole.log(b.trim() === a, b.trim().length);\n",
|
|
26
|
-
"expected_output": "false 5 6\nfalse 6",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "Strip or reject Unicode format characters (category Cf) and other invisible code points explicitly during input sanitization, not just leading/trailing whitespace — don't rely on strip()/trim() to catch them, and don't assume a blocklist check on reserved names (\"admin\", \"root\") is safe without first removing invisibles.",
|
|
31
|
-
"related": [
|
|
32
|
-
"cyrillic-homoglyph-paypal-spoof"
|
|
33
|
-
],
|
|
34
|
-
"sources": [
|
|
35
|
-
{
|
|
36
|
-
"title": "Unicode Character Database: General Category values (Cf = Format)",
|
|
37
|
-
"url": "https://www.unicode.org/reports/tr44/#General_Category_Values"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"title": "Big List of Naughty Strings (MIT)",
|
|
41
|
-
"url": "https://github.com/minimaxir/big-list-of-naughty-strings"
|
|
42
|
-
}
|
|
43
|
-
],
|
|
44
|
-
"tags": [
|
|
45
|
-
"username",
|
|
46
|
-
"uniqueness",
|
|
47
|
-
"login",
|
|
48
|
-
"security",
|
|
49
|
-
"validation",
|
|
50
|
-
"forms"
|
|
51
|
-
],
|
|
52
|
-
"test_cases": [
|
|
53
|
-
{
|
|
54
|
-
"inputs": [
|
|
55
|
-
"admin",
|
|
56
|
-
"admin"
|
|
57
|
-
],
|
|
58
|
-
"relation": "equal",
|
|
59
|
-
"note": "The key used for reserved-name and uniqueness checks should remove invisible format characters (category Cf) so both map to \"admin\" and the second is blocked."
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
}
|
package/src/harness.mjs
DELETED
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Edge Case Museum repro harness (fleet #3034).
|
|
4
|
-
*
|
|
5
|
-
* Every exhibit in src/exhibits/<category>/<id>.json
|
|
6
|
-
* carries one or more `repros`. A repro is either
|
|
7
|
-
* { language: "python" | "javascript", code, expected_output, verified_with }
|
|
8
|
-
* which this harness EXECUTES, or
|
|
9
|
-
* { language, status: "documented", note, source }
|
|
10
|
-
* which it never claims to have run.
|
|
11
|
-
*
|
|
12
|
-
* The rule the museum exists on: no expected_output is typed from memory.
|
|
13
|
-
* It is captured from a run (--capture), and every later run asserts it.
|
|
14
|
-
*
|
|
15
|
-
* Runtimes: python repros run as `python3 <file>.py`; javascript repros run
|
|
16
|
-
* as `node <file>.mjs` (ES module: use `import`, not `require`). Each runs in
|
|
17
|
-
* its own empty temp directory with a fixed environment (TZ=UTC,
|
|
18
|
-
* LC_ALL=en_US.UTF-8, PYTHONHASHSEED=0) so the output does not depend on the
|
|
19
|
-
* machine's clock zone or locale unless the repro sets them itself.
|
|
20
|
-
*
|
|
21
|
-
* Output: src/verified.json maps "<exhibit id>:<language>" to the sha256 of
|
|
22
|
-
* (code, expected_output) and the runtime that produced it. The bake script
|
|
23
|
-
* (src/bake.mjs) refuses to ship any executable
|
|
24
|
-
* repro whose hash is not in that file, so an edited repro that was never
|
|
25
|
-
* re-run cannot reach the pack.
|
|
26
|
-
*
|
|
27
|
-
* Usage:
|
|
28
|
-
* node src/harness.mjs check every repro; exit 1 on any mismatch
|
|
29
|
-
* node src/harness.mjs --capture fill in expected_output where it is missing
|
|
30
|
-
* node src/harness.mjs --recapture overwrite expected_output from the run (review the diff!)
|
|
31
|
-
* node src/harness.mjs --only <id> restrict to exhibits whose id contains <id>
|
|
32
|
-
* node src/harness.mjs --category <c> restrict to one category directory
|
|
33
|
-
* node src/harness.mjs --dir <path> exhibits root (default: the pack's src/exhibits)
|
|
34
|
-
* node src/harness.mjs --no-write do not touch verified.json (used by the red-path test)
|
|
35
|
-
*/
|
|
36
|
-
import { readFileSync, writeFileSync, readdirSync, statSync, mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync } from 'node:fs';
|
|
37
|
-
import { execFileSync, spawn } from 'node:child_process';
|
|
38
|
-
import { createHash } from 'node:crypto';
|
|
39
|
-
import os from 'node:os';
|
|
40
|
-
import path from 'node:path';
|
|
41
|
-
import { fileURLToPath } from 'node:url';
|
|
42
|
-
|
|
43
|
-
// This file lives at <pack>/src/harness.mjs, both in the Pipeworx monorepo (mcps/edge-case-museum) and in the
|
|
44
|
-
// standalone repo, so every path is relative to the pack root rather than to any repository root.
|
|
45
|
-
const PACK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
46
|
-
const ROOT = PACK;
|
|
47
|
-
const argv = process.argv.slice(2);
|
|
48
|
-
const flag = (f) => argv.includes(f);
|
|
49
|
-
const opt = (f) => {
|
|
50
|
-
const i = argv.indexOf(f);
|
|
51
|
-
return i >= 0 ? argv[i + 1] : null;
|
|
52
|
-
};
|
|
53
|
-
const DIR = path.resolve(opt('--dir') ?? path.join(PACK, 'src/exhibits'));
|
|
54
|
-
const VERIFIED = path.join(path.dirname(DIR), 'verified.json');
|
|
55
|
-
const CAPTURE = flag('--capture') || flag('--recapture');
|
|
56
|
-
const RECAPTURE = flag('--recapture');
|
|
57
|
-
const ONLY = opt('--only');
|
|
58
|
-
const CATEGORY = opt('--category');
|
|
59
|
-
const NO_WRITE = flag('--no-write');
|
|
60
|
-
const CONCURRENCY = Number(opt('--jobs') ?? Math.max(2, Math.min(8, os.cpus().length)));
|
|
61
|
-
const TIMEOUT_MS = 15_000;
|
|
62
|
-
|
|
63
|
-
export const CATEGORIES = ['unicode', 'datetime', 'float-money', 'sorting', 'filenames', 'parsing', 'integers', 'names-addresses'];
|
|
64
|
-
export const RUNNABLE = { python: { cmd: 'python3', ext: '.py' }, javascript: { cmd: 'node', ext: '.mjs' } };
|
|
65
|
-
|
|
66
|
-
export function reproHash(code, expected) {
|
|
67
|
-
return createHash('sha256').update(String(code)).update('\0').update(String(expected)).digest('hex');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Trailing whitespace on each line and trailing blank lines are not part of the claim. */
|
|
71
|
-
export function normalizeOutput(s) {
|
|
72
|
-
return String(s)
|
|
73
|
-
.replace(/\r\n/g, '\n')
|
|
74
|
-
.split('\n')
|
|
75
|
-
.map((l) => l.replace(/[ \t]+$/, ''))
|
|
76
|
-
.join('\n')
|
|
77
|
-
.replace(/\n+$/, '');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function runtimeVersion(lang) {
|
|
81
|
-
if (lang !== 'python') return `Node.js ${process.version}`;
|
|
82
|
-
try {
|
|
83
|
-
return execFileSync('python3', ['--version'], { encoding: 'utf8' }).trim();
|
|
84
|
-
} catch {
|
|
85
|
-
return 'python3 unavailable'; // python repros will then fail loudly as crashes, never pass
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Write exhibits into a fresh temp tree (<tmp>/exhibits/<category>/<id>.json) and return its root. Used by the red-path test. */
|
|
90
|
-
export function writeExhibitTree(exhibits) {
|
|
91
|
-
const root = path.join(mkdtempSync(path.join(os.tmpdir(), 'ecm-tree-')), 'exhibits');
|
|
92
|
-
for (const ex of exhibits) {
|
|
93
|
-
const dir = path.join(root, ex.category);
|
|
94
|
-
mkdirSync(dir, { recursive: true });
|
|
95
|
-
writeFileSync(path.join(dir, `${ex.id}.json`), JSON.stringify(ex, null, 2));
|
|
96
|
-
}
|
|
97
|
-
return root;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function loadExhibits(dir = DIR) {
|
|
101
|
-
const out = [];
|
|
102
|
-
if (!existsSync(dir)) return out;
|
|
103
|
-
for (const cat of readdirSync(dir).sort()) {
|
|
104
|
-
const cdir = path.join(dir, cat);
|
|
105
|
-
if (!statSync(cdir).isDirectory()) continue;
|
|
106
|
-
for (const f of readdirSync(cdir).sort()) {
|
|
107
|
-
if (!f.endsWith('.json')) continue;
|
|
108
|
-
const file = path.join(cdir, f);
|
|
109
|
-
let ex;
|
|
110
|
-
try {
|
|
111
|
-
ex = JSON.parse(readFileSync(file, 'utf8'));
|
|
112
|
-
} catch (e) {
|
|
113
|
-
out.push({ file, dirCategory: cat, parseError: e.message });
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
out.push({ file, dirCategory: cat, ex });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return out;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const RELATIONS = new Set(['equal', 'not_equal', 'returns', 'no_crash', 'raises']);
|
|
123
|
-
|
|
124
|
-
/** Structural validation. Returns a list of problems (empty = valid). */
|
|
125
|
-
export function validateExhibit({ file, dirCategory, ex, parseError }) {
|
|
126
|
-
const where = path.relative(ROOT, file);
|
|
127
|
-
if (parseError) return [`${where}: invalid JSON (${parseError})`];
|
|
128
|
-
const errs = [];
|
|
129
|
-
const str = (k) => {
|
|
130
|
-
if (typeof ex[k] !== 'string' || !ex[k].trim()) errs.push(`${where}: "${k}" must be a non-empty string`);
|
|
131
|
-
};
|
|
132
|
-
for (const k of ['id', 'title', 'category', 'assumption', 'what_goes_wrong', 'why', 'fix']) str(k);
|
|
133
|
-
if (ex.id && path.basename(file, '.json') !== ex.id) errs.push(`${where}: file name must equal id (${ex.id})`);
|
|
134
|
-
if (ex.id && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(ex.id)) errs.push(`${where}: id must be kebab-case`);
|
|
135
|
-
if (!CATEGORIES.includes(ex.category)) errs.push(`${where}: category "${ex.category}" not one of ${CATEGORIES.join(', ')}`);
|
|
136
|
-
if (ex.category !== dirCategory) errs.push(`${where}: category "${ex.category}" does not match its directory "${dirCategory}"`);
|
|
137
|
-
if (!ex.input || typeof ex.input.display !== 'string' || !ex.input.display) errs.push(`${where}: input.display (exact value, with escapes) is required`);
|
|
138
|
-
if (!Array.isArray(ex.affects) || !ex.affects.length) errs.push(`${where}: affects must list at least one stack`);
|
|
139
|
-
if (!Array.isArray(ex.tags) || ex.tags.length < 2) errs.push(`${where}: tags must have at least two retrieval contexts`);
|
|
140
|
-
if (!Array.isArray(ex.sources) || !ex.sources.length) errs.push(`${where}: at least one source is required`);
|
|
141
|
-
for (const s of ex.sources ?? []) if (!s || typeof s.title !== 'string' || !/^https?:\/\//.test(s.url ?? '')) errs.push(`${where}: every source needs a title and an http(s) url`);
|
|
142
|
-
if (!Array.isArray(ex.related)) errs.push(`${where}: related must be an array (may be empty)`);
|
|
143
|
-
if (!Array.isArray(ex.repros) || !ex.repros.length) errs.push(`${where}: at least one repro is required`);
|
|
144
|
-
const langs = new Set();
|
|
145
|
-
for (const r of ex.repros ?? []) {
|
|
146
|
-
if (!r || typeof r.language !== 'string') {
|
|
147
|
-
errs.push(`${where}: repro without language`);
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
if (langs.has(r.language)) errs.push(`${where}: two repros for ${r.language}`);
|
|
151
|
-
langs.add(r.language);
|
|
152
|
-
if (r.status === 'documented') {
|
|
153
|
-
if (typeof r.note !== 'string' || !r.note) errs.push(`${where}: documented ${r.language} repro needs a note`);
|
|
154
|
-
if (!/^https?:\/\//.test(r.source ?? '')) errs.push(`${where}: documented ${r.language} repro needs a source url`);
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (!RUNNABLE[r.language]) errs.push(`${where}: ${r.language} repro is not executable here; mark it status:"documented" with a note and source`);
|
|
158
|
-
if (typeof r.code !== 'string' || !r.code.trim()) errs.push(`${where}: ${r.language} repro has no code`);
|
|
159
|
-
}
|
|
160
|
-
if (!langs.has('python') && !langs.has('javascript')) errs.push(`${where}: needs at least one executable python or javascript repro`);
|
|
161
|
-
for (const t of ex.test_cases ?? []) {
|
|
162
|
-
if (!RELATIONS.has(t.relation)) errs.push(`${where}: test_case relation "${t.relation}" not one of ${[...RELATIONS].join(', ')}`);
|
|
163
|
-
if (!Array.isArray(t.inputs) || !t.inputs.length) errs.push(`${where}: test_case needs inputs[]`);
|
|
164
|
-
if ((t.relation === 'equal' || t.relation === 'not_equal') && t.inputs?.length !== 2) errs.push(`${where}: ${t.relation} test_case needs exactly two inputs`);
|
|
165
|
-
if (t.relation === 'returns' && !('expected' in t)) errs.push(`${where}: returns test_case needs expected`);
|
|
166
|
-
}
|
|
167
|
-
return errs;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function runOne(lang, code) {
|
|
171
|
-
const dir = mkdtempSync(path.join(os.tmpdir(), 'ecm-'));
|
|
172
|
-
const file = path.join(dir, `repro${RUNNABLE[lang].ext}`);
|
|
173
|
-
writeFileSync(file, code);
|
|
174
|
-
return new Promise((resolve) => {
|
|
175
|
-
const env = {
|
|
176
|
-
PATH: process.env.PATH,
|
|
177
|
-
HOME: dir,
|
|
178
|
-
TZ: 'UTC',
|
|
179
|
-
LANG: 'en_US.UTF-8',
|
|
180
|
-
LC_ALL: 'en_US.UTF-8',
|
|
181
|
-
PYTHONHASHSEED: '0',
|
|
182
|
-
PYTHONIOENCODING: 'utf-8',
|
|
183
|
-
PYTHONDONTWRITEBYTECODE: '1',
|
|
184
|
-
PYTHONWARNINGS: 'ignore',
|
|
185
|
-
NODE_NO_WARNINGS: '1',
|
|
186
|
-
};
|
|
187
|
-
const child = spawn(RUNNABLE[lang].cmd, [file], { cwd: dir, env });
|
|
188
|
-
let stdout = '';
|
|
189
|
-
let stderr = '';
|
|
190
|
-
child.stdout.on('data', (d) => (stdout += d));
|
|
191
|
-
child.stderr.on('data', (d) => (stderr += d));
|
|
192
|
-
const timer = setTimeout(() => child.kill('SIGKILL'), TIMEOUT_MS);
|
|
193
|
-
child.on('close', (code, signal) => {
|
|
194
|
-
clearTimeout(timer);
|
|
195
|
-
rmSync(dir, { recursive: true, force: true });
|
|
196
|
-
resolve({ exit: code, signal, stdout, stderr });
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function pool(items, n, fn) {
|
|
202
|
-
const results = new Array(items.length);
|
|
203
|
-
let i = 0;
|
|
204
|
-
await Promise.all(
|
|
205
|
-
Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
206
|
-
while (i < items.length) {
|
|
207
|
-
const k = i++;
|
|
208
|
-
results[k] = await fn(items[k]);
|
|
209
|
-
}
|
|
210
|
-
}),
|
|
211
|
-
);
|
|
212
|
-
return results;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export async function runHarness({ dir = DIR, capture = CAPTURE, recapture = RECAPTURE, only = ONLY, category = CATEGORY, write = !NO_WRITE, log = console.log } = {}) {
|
|
216
|
-
const verifiedPath = path.join(path.dirname(dir), 'verified.json');
|
|
217
|
-
const loaded = loadExhibits(dir).filter((e) => (!only || e.ex?.id?.includes(only)) && (!category || e.dirCategory === category));
|
|
218
|
-
const problems = [];
|
|
219
|
-
for (const e of loaded) problems.push(...validateExhibit(e));
|
|
220
|
-
const ids = new Set();
|
|
221
|
-
for (const e of loaded) {
|
|
222
|
-
if (!e.ex) continue;
|
|
223
|
-
if (ids.has(e.ex.id)) problems.push(`duplicate exhibit id ${e.ex.id}`);
|
|
224
|
-
ids.add(e.ex.id);
|
|
225
|
-
}
|
|
226
|
-
const versions = { python: runtimeVersion('python'), javascript: runtimeVersion('javascript') };
|
|
227
|
-
const jobs = [];
|
|
228
|
-
for (const e of loaded) {
|
|
229
|
-
if (!e.ex) continue;
|
|
230
|
-
for (const r of e.ex.repros ?? []) if (r.status !== 'documented' && RUNNABLE[r.language] && typeof r.code === 'string') jobs.push({ e, r });
|
|
231
|
-
}
|
|
232
|
-
const results = await pool(jobs, CONCURRENCY, async ({ e, r }) => ({ e, r, run: await runOne(r.language, r.code) }));
|
|
233
|
-
|
|
234
|
-
let passed = 0;
|
|
235
|
-
let failed = 0;
|
|
236
|
-
let captured = 0;
|
|
237
|
-
const failures = [];
|
|
238
|
-
const dirty = new Set();
|
|
239
|
-
const verified = existsSync(verifiedPath) ? JSON.parse(readFileSync(verifiedPath, 'utf8')) : {};
|
|
240
|
-
const entries = verified.repros ?? {};
|
|
241
|
-
for (const { e, r, run } of results) {
|
|
242
|
-
const key = `${e.ex.id}:${r.language}`;
|
|
243
|
-
const actual = normalizeOutput(run.stdout);
|
|
244
|
-
const crashed = run.exit !== 0 || run.signal;
|
|
245
|
-
if (crashed) {
|
|
246
|
-
failed++;
|
|
247
|
-
failures.push({ key, file: e.file, reason: `exited ${run.signal ?? run.exit}`, stderr: run.stderr.trim().split('\n').slice(-4).join('\n') });
|
|
248
|
-
delete entries[key];
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (!actual) {
|
|
252
|
-
failed++;
|
|
253
|
-
failures.push({ key, file: e.file, reason: 'printed nothing (a repro must print what it demonstrates)' });
|
|
254
|
-
delete entries[key];
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (capture && (recapture || typeof r.expected_output !== 'string')) {
|
|
258
|
-
if (r.expected_output !== actual || r.verified_with !== versions[r.language]) {
|
|
259
|
-
r.expected_output = actual;
|
|
260
|
-
r.verified_with = versions[r.language];
|
|
261
|
-
dirty.add(e);
|
|
262
|
-
captured++;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (typeof r.expected_output !== 'string') {
|
|
266
|
-
failed++;
|
|
267
|
-
failures.push({ key, file: e.file, reason: 'no expected_output (run with --capture)' });
|
|
268
|
-
delete entries[key];
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (normalizeOutput(r.expected_output) === actual) {
|
|
272
|
-
passed++;
|
|
273
|
-
if (r.verified_with !== versions[r.language]) {
|
|
274
|
-
// Same output on a different runtime: record which runtime last confirmed it.
|
|
275
|
-
r.verified_with = versions[r.language];
|
|
276
|
-
dirty.add(e);
|
|
277
|
-
}
|
|
278
|
-
entries[key] = { sha256: reproHash(r.code, normalizeOutput(r.expected_output)), verified_with: versions[r.language] };
|
|
279
|
-
} else {
|
|
280
|
-
failed++;
|
|
281
|
-
failures.push({ key, file: e.file, reason: 'output differs', expected: normalizeOutput(r.expected_output), actual });
|
|
282
|
-
delete entries[key];
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
// Prune verified entries for repros that no longer exist (only on a full run).
|
|
286
|
-
if (!only && !category) {
|
|
287
|
-
const live = new Set(jobs.map(({ e, r }) => `${e.ex.id}:${r.language}`));
|
|
288
|
-
for (const k of Object.keys(entries)) if (!live.has(k)) delete entries[k];
|
|
289
|
-
}
|
|
290
|
-
if (write) {
|
|
291
|
-
for (const e of dirty) writeFileSync(e.file, JSON.stringify(e.ex, null, 2) + '\n');
|
|
292
|
-
const sorted = Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
|
|
293
|
-
writeFileSync(
|
|
294
|
-
verifiedPath,
|
|
295
|
-
JSON.stringify({ _comment: 'GENERATED by src/harness.mjs. One row per executed repro that matched its expected_output; the bake refuses any repro not listed here with the same hash.', runtimes: versions, repros: sorted }, null, 2) + '\n',
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const documented = loaded.reduce((n, e) => n + (e.ex?.repros ?? []).filter((r) => r.status === 'documented').length, 0);
|
|
300
|
-
const byCat = {};
|
|
301
|
-
for (const e of loaded) if (e.ex) byCat[e.ex.category] = (byCat[e.ex.category] ?? 0) + 1;
|
|
302
|
-
for (const p of problems) log(`SCHEMA ${p}`);
|
|
303
|
-
for (const f of failures) {
|
|
304
|
-
log(`FAIL ${f.key} (${path.relative(ROOT, f.file)}): ${f.reason}`);
|
|
305
|
-
if (f.stderr) log(` stderr: ${f.stderr.replace(/\n/g, '\n ')}`);
|
|
306
|
-
if (f.expected !== undefined) {
|
|
307
|
-
log(` expected: ${JSON.stringify(f.expected)}`);
|
|
308
|
-
log(` actual: ${JSON.stringify(f.actual)}`);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
log(`exhibits: ${loaded.length} (${Object.entries(byCat).map(([k, v]) => `${k} ${v}`).join(', ')})`);
|
|
312
|
-
log(`repros executed: ${results.length} passed: ${passed} failed: ${failed} documented (not executed): ${documented}${capture ? ` captured: ${captured}` : ''}`);
|
|
313
|
-
log(`runtimes: ${versions.python} · ${versions.javascript}`);
|
|
314
|
-
const ok = failed === 0 && problems.length === 0;
|
|
315
|
-
log(ok ? 'HARNESS GREEN' : `HARNESS RED (${failed} failing repro(s), ${problems.length} schema problem(s))`);
|
|
316
|
-
return { ok, passed, failed, problems, failures, exhibits: loaded.length, byCat };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// realpath on both sides: through a symlinked path (macOS /tmp -> /private/tmp) a plain
|
|
320
|
-
// path.resolve comparison never matched, so the harness ran NOTHING and exited 0 -- green unrun.
|
|
321
|
-
const isMain = () => {
|
|
322
|
-
try {
|
|
323
|
-
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
324
|
-
} catch {
|
|
325
|
-
return false;
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
if (process.argv[1] && isMain()) {
|
|
329
|
-
const r = await runHarness();
|
|
330
|
-
process.exit(r.ok ? 0 : 1);
|
|
331
|
-
}
|