@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.
Files changed (135) hide show
  1. package/README.md +17 -12
  2. package/bin/cli.js +55 -11
  3. package/lib/index.js +333 -0
  4. package/package.json +17 -16
  5. package/server.json +1 -1
  6. package/tools.json +98 -0
  7. package/src/bake.mjs +0 -112
  8. package/src/ecm-index-data.ts +0 -15
  9. package/src/exhibits/datetime/add-24-hours-vs-add-1-day-across-dst.json +0 -51
  10. package/src/exhibits/datetime/add-365-days-to-feb-29-not-next-feb-29.json +0 -49
  11. package/src/exhibits/datetime/age-calculator-feb29-birthday-crash-vs-silent.json +0 -52
  12. package/src/exhibits/datetime/century-leap-year-1900-not-leap.json +0 -68
  13. package/src/exhibits/datetime/datetime-utcnow-naive-deprecated.json +0 -39
  14. package/src/exhibits/datetime/dst-fall-back-ambiguous-fold.json +0 -51
  15. package/src/exhibits/datetime/dst-spring-forward-nonexistent-time.json +0 -57
  16. package/src/exhibits/datetime/fractional-utc-offsets-kathmandu-kolkata.json +0 -50
  17. package/src/exhibits/datetime/iso-week-year-vs-calendar-year.json +0 -48
  18. package/src/exhibits/datetime/js-date-field-overflow-rollover.json +0 -49
  19. package/src/exhibits/datetime/leap-second-rejected-vs-silently-invalid.json +0 -57
  20. package/src/exhibits/datetime/lord-howe-30-minute-dst.json +0 -47
  21. package/src/exhibits/datetime/midnight-that-doesnt-exist-sao-paulo.json +0 -53
  22. package/src/exhibits/datetime/python-naive-aware-comparison-typeerror.json +0 -46
  23. package/src/exhibits/datetime/samoa-skipped-day-2011.json +0 -50
  24. package/src/exhibits/datetime/southern-hemisphere-dst-reversed.json +0 -50
  25. package/src/exhibits/datetime/two-digit-year-century-pivot-mismatch.json +0 -49
  26. package/src/exhibits/datetime/year-2038-int32-overflow.json +0 -52
  27. package/src/exhibits/filenames/absolute-second-argument-resets-join.json +0 -62
  28. package/src/exhibits/filenames/bidi-override-extension-spoof.json +0 -58
  29. package/src/exhibits/filenames/case-insensitive-filesystem-pure-path-mismatch.json +0 -74
  30. package/src/exhibits/filenames/dotfile-has-no-extension.json +0 -61
  31. package/src/exhibits/filenames/extension-trap-double-extension.json +0 -62
  32. package/src/exhibits/filenames/filename-length-limits-bytes-not-chars.json +0 -66
  33. package/src/exhibits/filenames/leading-dash-filename-parsed-as-flag.json +0 -67
  34. package/src/exhibits/filenames/nfc-nfd-filename-lookup-mismatch.json +0 -68
  35. package/src/exhibits/filenames/ntfs-alternate-data-stream-colon.json +0 -59
  36. package/src/exhibits/filenames/ntpath-splitdrive-unc-paths.json +0 -58
  37. package/src/exhibits/filenames/nul-byte-in-filename-raises-at-open.json +0 -60
  38. package/src/exhibits/filenames/path-join-escapes-the-base-directory.json +0 -63
  39. package/src/exhibits/filenames/windows-backslash-traversal-ntpath.json +0 -62
  40. package/src/exhibits/filenames/windows-reserved-device-names.json +0 -73
  41. package/src/exhibits/filenames/windows-trailing-dot-space-stripped.json +0 -77
  42. package/src/exhibits/float-money/decimal-from-float-vs-string.json +0 -66
  43. package/src/exhibits/float-money/float-anchor-0-1-plus-0-2.json +0 -67
  44. package/src/exhibits/float-money/intl-number-format-de-de-roundtrip-misparsed.json +0 -62
  45. package/src/exhibits/float-money/iso4217-minor-units-not-always-two.json +0 -78
  46. package/src/exhibits/float-money/math-abs-negative-zero.json +0 -51
  47. package/src/exhibits/float-money/max-safe-integer-plus-two.json +0 -62
  48. package/src/exhibits/float-money/naive-loop-sum-order-dependence.json +0 -71
  49. package/src/exhibits/float-money/nan-breaks-equality-and-max-min.json +0 -70
  50. package/src/exhibits/float-money/negative-zero-balance-formatting.json +0 -74
  51. package/src/exhibits/float-money/number-epsilon-false-positive-small-magnitude.json +0 -52
  52. package/src/exhibits/float-money/parsefloat-comma-thousands-separator.json +0 -66
  53. package/src/exhibits/float-money/python-banker-rounding-vs-js-round-half-up.json +0 -65
  54. package/src/exhibits/float-money/python-round-2-675-float-imprecision.json +0 -64
  55. package/src/exhibits/float-money/splitting-100-dollars-three-ways-leaves-a-cent.json +0 -63
  56. package/src/exhibits/float-money/sum-ten-dimes-not-a-dollar.json +0 -77
  57. package/src/exhibits/float-money/tofixed-1-005-rounds-down-not-up.json +0 -63
  58. package/src/exhibits/float-money/tofixed-2-345-inconsistent-rounding.json +0 -64
  59. package/src/exhibits/integers/array-index-above-2-32-2-not-an-index.json +0 -42
  60. package/src/exhibits/integers/bigint-number-mixing-typeerror.json +0 -49
  61. package/src/exhibits/integers/bitwise-32-bit-truncation.json +0 -56
  62. package/src/exhibits/integers/bool-is-int-subclass-dict-key-collision.json +0 -58
  63. package/src/exhibits/integers/floor-div-mod-sign-python-vs-js.json +0 -48
  64. package/src/exhibits/integers/json-parse-loses-id-precision.json +0 -58
  65. package/src/exhibits/integers/max-safe-integer-plus-one-equals-plus-two.json +0 -58
  66. package/src/exhibits/integers/number-empty-and-whitespace-string-equals-zero.json +0 -55
  67. package/src/exhibits/integers/parseint-null-radix36-digit-soup.json +0 -57
  68. package/src/exhibits/integers/parseint-number-argument-scientific-notation.json +0 -48
  69. package/src/exhibits/integers/parseint-stops-at-first-invalid-char-vs-number.json +0 -58
  70. package/src/exhibits/integers/python-int-accepts-fullwidth-unicode-digits.json +0 -56
  71. package/src/exhibits/integers/python-int-accepts-underscores-and-whitespace.json +0 -48
  72. package/src/exhibits/integers/python-unbounded-int-vs-fixed-width-wrap.json +0 -49
  73. package/src/exhibits/names-addresses/address-with-no-street-number.json +0 -54
  74. package/src/exhibits/names-addresses/ascii-regex-rejects-accented-and-hyphenated-names.json +0 -55
  75. package/src/exhibits/names-addresses/house-number-suffix-collapsed-by-digit-extraction.json +0 -55
  76. package/src/exhibits/names-addresses/mononym-fails-first-last-required.json +0 -56
  77. package/src/exhibits/names-addresses/naive-lowercase-breaks-greek-final-sigma.json +0 -64
  78. package/src/exhibits/names-addresses/null-surname-coerced-to-null-value.json +0 -58
  79. package/src/exhibits/names-addresses/single-letter-surname-fails-min-length.json +0 -55
  80. package/src/exhibits/names-addresses/sort-by-surname-particle-convention-mismatch.json +0 -57
  81. package/src/exhibits/names-addresses/spanish-double-surname-treated-as-single.json +0 -55
  82. package/src/exhibits/names-addresses/split-surname-on-last-space-loses-particle.json +0 -59
  83. package/src/exhibits/names-addresses/title-case-mangles-mixed-case-and-apostrophes.json +0 -60
  84. package/src/exhibits/names-addresses/uk-canada-postal-code-assumed-numeric.json +0 -55
  85. package/src/exhibits/names-addresses/us-zip-code-leading-zero-lost-as-integer.json +0 -58
  86. package/src/exhibits/names-addresses/varchar-truncation-splits-name-mid-grapheme.json +0 -63
  87. package/src/exhibits/parsing/csv-crlf-trailing-blank-line.json +0 -87
  88. package/src/exhibits/parsing/csv-doubled-quote-escaping.json +0 -70
  89. package/src/exhibits/parsing/csv-formula-injection-excel.json +0 -71
  90. package/src/exhibits/parsing/csv-quoted-field-with-comma.json +0 -70
  91. package/src/exhibits/parsing/csv-quoted-field-with-newline.json +0 -70
  92. package/src/exhibits/parsing/csv-semicolon-delimited-european-excel.json +0 -70
  93. package/src/exhibits/parsing/csv-utf8-bom-first-header.json +0 -74
  94. package/src/exhibits/parsing/email-regex-rejects-valid-apostrophe-address.json +0 -68
  95. package/src/exhibits/parsing/json-big-integer-precision-loss.json +0 -65
  96. package/src/exhibits/parsing/json-duplicate-keys-last-wins.json +0 -64
  97. package/src/exhibits/parsing/json-lone-surrogate-roundtrip.json +0 -58
  98. package/src/exhibits/parsing/json-nan-infinity-not-valid-json.json +0 -54
  99. package/src/exhibits/parsing/url-backslash-treated-as-slash-whatwg.json +0 -60
  100. package/src/exhibits/parsing/url-urlparse-never-raises-on-garbage.json +0 -60
  101. package/src/exhibits/parsing/url-userinfo-trusted-looking-host.json +0 -60
  102. package/src/exhibits/sorting/accented-letters-sort-after-z.json +0 -63
  103. package/src/exhibits/sorting/byte-order-caps-before-lowercase.json +0 -66
  104. package/src/exhibits/sorting/case-insensitive-sort-lower-vs-casefold.json +0 -56
  105. package/src/exhibits/sorting/comparator-returns-boolean-js.json +0 -49
  106. package/src/exhibits/sorting/js-array-sort-stringifies-numbers.json +0 -68
  107. package/src/exhibits/sorting/locale-collation-de-vs-sv.json +0 -54
  108. package/src/exhibits/sorting/localecompare-numeric-option.json +0 -50
  109. package/src/exhibits/sorting/nan-in-sort.json +0 -59
  110. package/src/exhibits/sorting/natural-sort-breaks-lexicographic.json +0 -63
  111. package/src/exhibits/sorting/postgres-collation-documented.json +0 -61
  112. package/src/exhibits/sorting/python-cannot-compare-str-int.json +0 -70
  113. package/src/exhibits/sorting/reverse-sort-vs-reverse-after-sort-ties.json +0 -48
  114. package/src/exhibits/sorting/sorting-mixed-none-null.json +0 -56
  115. package/src/exhibits/sorting/typed-array-sort-is-numeric-by-default.json +0 -48
  116. package/src/exhibits/unicode/arabic-indic-digit-parses.json +0 -73
  117. package/src/exhibits/unicode/bidi-override-filename-spoof.json +0 -62
  118. package/src/exhibits/unicode/cyrillic-homoglyph-paypal-spoof.json +0 -67
  119. package/src/exhibits/unicode/fullwidth-bypasses-keyword-filter.json +0 -61
  120. package/src/exhibits/unicode/german-eszett-case-fold.json +0 -72
  121. package/src/exhibits/unicode/grapheme-vs-codepoint-length.json +0 -57
  122. package/src/exhibits/unicode/greek-final-sigma-casefold.json +0 -54
  123. package/src/exhibits/unicode/js-word-regex-ascii-only.json +0 -64
  124. package/src/exhibits/unicode/nfc-nfd-same-name-two-accounts.json +0 -70
  125. package/src/exhibits/unicode/nfkc-compatibility-folding.json +0 -63
  126. package/src/exhibits/unicode/truncation-orphans-combining-mark.json +0 -62
  127. package/src/exhibits/unicode/truncation-splits-surrogate-pair.json +0 -65
  128. package/src/exhibits/unicode/turkish-dotless-i-case-mapping.json +0 -72
  129. package/src/exhibits/unicode/zalgo-combining-mark-stacking.json +0 -57
  130. package/src/exhibits/unicode/zero-width-space-username.json +0 -62
  131. package/src/harness.mjs +0 -331
  132. package/src/index.ts +0 -686
  133. package/src/server.ts +0 -49
  134. package/src/verified.json +0 -977
  135. package/tsconfig.json +0 -18
package/src/index.ts DELETED
@@ -1,686 +0,0 @@
1
- interface McpToolDefinition {
2
- name: string;
3
- description: string;
4
- /** Human-facing one-liner (fleet #1967). Optional; consumers fall back to
5
- * description. Kept in step with shared/src/types.ts — scripts/lib/
6
- * check-inlined-types.mjs reports drift at publish time. */
7
- summary?: string;
8
- inputSchema: {
9
- type: 'object';
10
- properties: Record<string, unknown>;
11
- required?: string[];
12
- anyOf?: Array<{ required: string[] }>;
13
- oneOf?: Array<{ required: string[] }>;
14
- allOf?: Array<{ required: string[] }>;
15
- };
16
- outputSchema?: Record<string, unknown>;
17
- }
18
-
19
- interface McpToolExport {
20
- tools: McpToolDefinition[];
21
- callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
22
- meter?: { credits: number };
23
- cost?: Record<string, unknown>;
24
- provider?: string;
25
- }
26
-
27
- /**
28
- * Edge Case Museum MCP — "Your code works. Except for these wonderfully
29
- * terrible inputs."
30
- *
31
- * Describe what you are implementing ("username uniqueness", "parse a CSV
32
- * upload", "store an amount of money", "schedule a reminder in the user's
33
- * timezone") and get back the surprising inputs that break that kind of code:
34
- * the assumption each one breaks, the exact input, what goes wrong and why,
35
- * a repro in Python and JavaScript whose output was captured by actually
36
- * running it, the safer pattern, and the spec or documentation that explains
37
- * it.
38
- *
39
- * Data: a curated collection written for this pack, one JSON file per exhibit
40
- * (src/exhibits/<category>/<id>.json). Every executable
41
- * repro's expected_output was produced by src/harness.mjs
42
- * running it (the runtime is named in `verified_with`); stacks we could not
43
- * execute are labelled `documented`, with a cited source, never as verified.
44
- * Some inputs come from the Big List of Naughty Strings (MIT licence,
45
- * https://github.com/minimaxir/big-list-of-naughty-strings) and are attributed
46
- * per exhibit.
47
- *
48
- * The collection is a generated module (src/ecm-index-data.ts, written by
49
- * src/bake.mjs) that the gateway injects as
50
- * `args._bakedIndex` (workers/gateway/src/pack-baked-indexes.json). NEVER
51
- * value-import that module here; a type-only import is erased.
52
- *
53
- * No upstream calls and no model in the request path: retrieval is keyword and
54
- * tag scoring over the exhibits.
55
- *
56
- * Tools:
57
- * - edge_cases_find what you are implementing -> the relevant exhibits, with a repro in your language
58
- * - edge_case_get one exhibit in full: every repro, the why, the fix, sources
59
- * - edge_cases_browse the collection by category or tag
60
- * - edge_cases_test_file a runnable pytest / node:test file of the relevant inputs, pointed at your function
61
- */
62
-
63
- import type { Exhibit, Repro, TestCase } from './ecm-index-data.js';
64
-
65
- const CATEGORY_LABELS: Record<string, string> = {
66
- unicode: 'Unicode: normalization, confusables, invisible characters, case mapping, length',
67
- datetime: 'Dates and time: DST gaps and overlaps, offsets, leap days and seconds, 2038, week years',
68
- 'float-money': 'Floating point and money: binary fractions, rounding modes, -0, NaN, currency minor units',
69
- sorting: 'Sorting and comparison: collation, locale, stability, natural order, comparators',
70
- filenames: 'Filenames and paths: reserved names, traversal, case, length, extensions',
71
- parsing: 'Parsing: CSV quoting and BOMs, JSON duplicates and big numbers, URLs, email addresses',
72
- integers: 'Integers: overflow, precision limits, parseInt, division and modulo signs',
73
- 'names-addresses': 'Names and addresses: name order, mononyms, scripts, postal codes, house numbers',
74
- };
75
-
76
- // ───────────────────────── baked index ─────────────────────────
77
-
78
- interface Index {
79
- exhibits: Exhibit[];
80
- byId: Map<string, Exhibit>;
81
- data_as_of: string;
82
- runtimes: Record<string, string>;
83
- idf: Map<string, number>;
84
- docs: Map<string, { tags: Set<string>; tagTokens: Set<string>; title: Set<string>; body: Set<string>; deep: Set<string> }>;
85
- }
86
-
87
- let INDEX: Index | null = null;
88
- let INDEX_SRC: unknown = null;
89
-
90
- function ensureIndex(args: Record<string, unknown>): Index {
91
- const raw = args._bakedIndex as Record<string, unknown> | null | undefined;
92
- if (INDEX && raw === INDEX_SRC) return INDEX;
93
- const exhibits = raw?.EXHIBITS as Exhibit[] | undefined;
94
- const data_as_of = raw?.DATA_AS_OF as string | undefined;
95
- if (!Array.isArray(exhibits) || exhibits.length < 10 || !data_as_of) {
96
- // Loud by design: a missing index would otherwise answer "no match" to every question.
97
- throw new Error(
98
- `edge-case-museum: collection unavailable (${Array.isArray(exhibits) ? exhibits.length : 'no'} exhibits loaded); ` +
99
- 'the generated index (static-json:baked-index:edge-case-museum) was not injected. Retry; if it persists this is a deploy defect, not an answer.',
100
- );
101
- }
102
- const byId = new Map(exhibits.map((e) => [e.id, e]));
103
- const docs: Index['docs'] = new Map();
104
- const df = new Map<string, number>();
105
- for (const e of exhibits) {
106
- const tags = new Set(e.tags.map((t) => t.toLowerCase()));
107
- const tagTokens = new Set(e.tags.flatMap((t) => tokens(t)));
108
- const title = new Set(tokens(e.title));
109
- const body = new Set([...tokens(e.assumption), ...tokens(e.what_goes_wrong), ...tokens(e.input.description ?? ''), ...tokens(e.category.replace('-', ' '))]);
110
- const deep = new Set([...tokens(e.why), ...tokens(e.fix)]);
111
- docs.set(e.id, { tags, tagTokens, title, body, deep });
112
- for (const t of new Set([...tagTokens, ...title, ...body, ...deep])) df.set(t, (df.get(t) ?? 0) + 1);
113
- }
114
- const n = exhibits.length;
115
- const idf = new Map<string, number>();
116
- for (const [t, c] of df) idf.set(t, Math.log(1 + n / c));
117
- INDEX = { exhibits, byId, data_as_of, runtimes: (raw?.RUNTIMES ?? {}) as Record<string, string>, idf, docs };
118
- INDEX_SRC = raw;
119
- return INDEX;
120
- }
121
-
122
- // ───────────────────────── text matching ─────────────────────────
123
-
124
- const STOP = new Set(['a', 'an', 'the', 'of', 'in', 'on', 'for', 'to', 'and', 'or', 'with', 'without', 'is', 'are', 'be', 'by', 'from', 'that', 'this', 'it', 'as', 'at', 'into', 'i', 'we', 'my', 'our', 'their', 'user', 'users', 'need', 'want', 'how', 'what', 'which', 'implement', 'implementing', 'build', 'building', 'write', 'writing', 'handle', 'handling', 'code', 'function', 'some', 'up', 'do', 'make', 'new', 'via', 's']);
125
-
126
- function stem(w: string): string {
127
- if (w.length <= 3) return w;
128
- return w
129
- .replace(/ies$/, 'y')
130
- .replace(/(ations?|ition)$/, '')
131
- .replace(/(ing|ed|es|s)$/, '')
132
- .replace(/e$/, '');
133
- }
134
-
135
- function tokens(s: string): string[] {
136
- return s
137
- .toLowerCase()
138
- .normalize('NFKD')
139
- .replace(/[̀-ͯ]/g, '')
140
- .replace(/[^a-z0-9]+/g, ' ')
141
- .split(' ')
142
- .filter((w) => w && !STOP.has(w))
143
- .map(stem);
144
- }
145
-
146
- /** Developer phrasing -> the vocabulary exhibits are tagged with. Values are expanded at half weight. */
147
- const SYNONYMS: Record<string, string[]> = {
148
- username: ['login', 'uniqueness', 'signup', 'account'],
149
- handle: ['username'],
150
- signup: ['username', 'uniqueness', 'login'],
151
- register: ['username', 'uniqueness', 'signup'],
152
- unique: ['uniqueness'],
153
- duplicate: ['uniqueness'],
154
- dedupe: ['uniqueness'],
155
- money: ['currency', 'price', 'rounding', 'invoice', 'accounting'],
156
- amount: ['money', 'currency'],
157
- price: ['money', 'currency'],
158
- payment: ['money', 'currency'],
159
- balance: ['money', 'accounting'],
160
- total: ['money', 'sum'],
161
- add: ['sum'],
162
- cents: ['money', 'currency'],
163
- dollar: ['money', 'currency'],
164
- tax: ['money', 'rounding'],
165
- csv: ['parsing', 'upload', 'spreadsheet'],
166
- spreadsheet: ['csv'],
167
- excel: ['csv', 'spreadsheet'],
168
- import: ['upload', 'parsing'],
169
- upload: ['filename', 'path'],
170
- file: ['filename', 'path'],
171
- json: ['parsing'],
172
- api: ['json', 'parsing'],
173
- sort: ['sorting', 'comparison', 'collation'],
174
- order: ['sorting'],
175
- alphabetical: ['sorting', 'collation'],
176
- compare: ['comparison'],
177
- timezone: ['scheduling', 'dst', 'reminder'],
178
- zone: ['timezone'],
179
- schedule: ['scheduling', 'timezone', 'reminder'],
180
- reminder: ['scheduling', 'timezone'],
181
- calendar: ['scheduling', 'date'],
182
- date: ['timestamps', 'date parsing'],
183
- time: ['timestamps', 'timezone'],
184
- birthday: ['age calculation', 'date'],
185
- age: ['age calculation'],
186
- email: ['validation', 'parsing'],
187
- name: ['names'],
188
- person: ['names'],
189
- address: ['addresses', 'forms'],
190
- postal: ['addresses'],
191
- zip: ['addresses'],
192
- form: ['forms', 'validation'],
193
- validate: ['validation'],
194
- id: ['counter', 'json'],
195
- integer: ['counter', 'id'],
196
- number: ['parsing', 'integer'],
197
- url: ['parsing', 'security'],
198
- link: ['url'],
199
- path: ['filename', 'security'],
200
- search: ['normalization', 'comparison'],
201
- truncate: ['truncation', 'length validation'],
202
- length: ['length validation', 'truncation'],
203
- limit: ['length validation'],
204
- display: ['rendering'],
205
- slug: ['url', 'normalization'],
206
- };
207
-
208
- interface Scored {
209
- ex: Exhibit;
210
- score: number;
211
- matched: string[];
212
- }
213
-
214
- function score(idx: Index, context: string): Scored[] {
215
- const lower = context.toLowerCase();
216
- const base = tokens(context);
217
- const weights = new Map<string, number>();
218
- for (const t of base) weights.set(t, Math.max(weights.get(t) ?? 0, 1));
219
- for (const w of lower.split(/[^a-z0-9]+/).filter(Boolean)) {
220
- for (const syn of SYNONYMS[w] ?? SYNONYMS[stem(w)] ?? []) for (const t of tokens(syn)) if (!weights.has(t)) weights.set(t, 0.5);
221
- }
222
- const out: Scored[] = [];
223
- for (const ex of idx.exhibits) {
224
- const d = idx.docs.get(ex.id)!;
225
- let s = 0;
226
- const matched = new Set<string>();
227
- // A whole tag phrase present in the context is the strongest signal ("date parsing", "csv").
228
- for (const tag of d.tags) {
229
- const tt = tokens(tag);
230
- if (tt.length && tt.every((t) => weights.has(t))) {
231
- const w = Math.min(...tt.map((t) => weights.get(t)!));
232
- s += 4 * w * (tt.length > 1 ? 1.5 : 1);
233
- matched.add(tag);
234
- }
235
- }
236
- for (const [t, w] of weights) {
237
- const rare = idx.idf.get(t) ?? 0;
238
- if (!rare) continue;
239
- if (d.tagTokens.has(t)) s += 1.5 * w * rare;
240
- if (d.title.has(t)) s += 1.2 * w * rare;
241
- if (d.body.has(t)) s += 0.6 * w * rare;
242
- if (d.deep.has(t)) s += 0.2 * w * rare;
243
- }
244
- if (s > 0) out.push({ ex, score: s, matched: [...matched] });
245
- }
246
- out.sort((a, b) => b.score - a.score || a.ex.id.localeCompare(b.ex.id));
247
- return out;
248
- }
249
-
250
- // ───────────────────────── shaping ─────────────────────────
251
-
252
- type Lang = 'python' | 'javascript';
253
-
254
- function normLang(v: unknown): Lang | null {
255
- if (v == null || v === '') return null;
256
- const s = String(v).toLowerCase().trim();
257
- if (['python', 'py', 'python3', 'pytest'].includes(s)) return 'python';
258
- if (['javascript', 'js', 'node', 'nodejs', 'node.js', 'typescript', 'ts', 'ecmascript', 'mjs'].includes(s)) return 'javascript';
259
- throw new Error(`edge-case-museum: language "${String(v)}" has no executed repros. Use "python" or "javascript" (node and typescript map to javascript).`);
260
- }
261
-
262
- function verifiedRepro(ex: Exhibit, lang: Lang): Repro | undefined {
263
- return ex.repros.find((r) => r.language === lang && r.status === 'verified');
264
- }
265
-
266
- function reproOut(r: Repro) {
267
- return r.status === 'verified'
268
- ? { language: r.language, status: r.status, code: r.code, expected_output: r.expected_output, verified_with: r.verified_with }
269
- : { language: r.language, status: r.status, note: r.note, source: r.source };
270
- }
271
-
272
- function summary(ex: Exhibit, lang: Lang | null, matched?: string[]) {
273
- const languages = ex.repros.map((r) => `${r.language}${r.status === 'documented' ? ' (documented)' : ''}`);
274
- const out: Record<string, unknown> = {
275
- id: ex.id,
276
- title: ex.title,
277
- category: ex.category,
278
- assumption: ex.assumption,
279
- input: ex.input.display,
280
- what_goes_wrong: ex.what_goes_wrong,
281
- fix: ex.fix,
282
- };
283
- if (lang) {
284
- const r = verifiedRepro(ex, lang);
285
- out.repro = r ? reproOut(r) : null;
286
- if (!r) out.repro_note = `No executed ${lang} repro for this exhibit; it has: ${languages.join(', ')}. edge_case_get returns them.`;
287
- } else {
288
- const first = ex.repros.find((r) => r.status === 'verified');
289
- out.repro = first ? reproOut(first) : null;
290
- }
291
- out.languages = languages;
292
- if (matched?.length) out.matched_tags = matched;
293
- return out;
294
- }
295
-
296
- function refineTags(list: Exhibit[], exclude: Set<string>, max = 15): string[] {
297
- const counts = new Map<string, number>();
298
- for (const ex of list) for (const t of ex.tags) if (!exclude.has(t)) counts.set(t, (counts.get(t) ?? 0) + 1);
299
- return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, max).map(([t]) => t);
300
- }
301
-
302
- function provenance(idx: Index) {
303
- return {
304
- data_as_of: idx.data_as_of,
305
- total_exhibits: idx.exhibits.length,
306
- verification: `expected_output for every "verified" repro was captured by executing it (${Object.values(idx.runtimes).join(', ') || 'see verified_with'}); "documented" repros were not executed and cite their source.`,
307
- collection: 'Edge Case Museum by Mojibake, creators of Pipeworx (https://pipeworx.io)',
308
- };
309
- }
310
-
311
- // ───────────────────────── test file generation ─────────────────────────
312
-
313
- function pyString(s: string): string {
314
- let out = '"';
315
- for (const ch of s) {
316
- const cp = ch.codePointAt(0)!;
317
- if (ch === '"') out += '\\"';
318
- else if (ch === '\\') out += '\\\\';
319
- else if (ch === '\n') out += '\\n';
320
- else if (ch === '\r') out += '\\r';
321
- else if (ch === '\t') out += '\\t';
322
- else if (cp >= 0x20 && cp <= 0x7e) out += ch;
323
- else if (cp <= 0xff) out += `\\x${cp.toString(16).padStart(2, '0')}`;
324
- else if (cp <= 0xffff) out += `\\u${cp.toString(16).padStart(4, '0')}`;
325
- else out += `\\U${cp.toString(16).padStart(8, '0')}`;
326
- }
327
- return out + '"';
328
- }
329
-
330
- function toPy(v: unknown): string {
331
- if (v === null || v === undefined) return 'None';
332
- if (v === true) return 'True';
333
- if (v === false) return 'False';
334
- if (typeof v === 'number') return Number.isInteger(v) ? String(v) : String(v);
335
- if (typeof v === 'string') return pyString(v);
336
- if (Array.isArray(v)) return `[${v.map(toPy).join(', ')}]`;
337
- if (typeof v === 'object') return `{${Object.entries(v as Record<string, unknown>).map(([k, x]) => `${pyString(k)}: ${toPy(x)}`).join(', ')}}`;
338
- return pyString(String(v));
339
- }
340
-
341
- function jsString(s: string): string {
342
- // Escape every UTF-16 code unit outside printable ASCII, so invisible and bidi characters stay visible in the file.
343
- let out = "'";
344
- for (let i = 0; i < s.length; i++) {
345
- const c = s.charCodeAt(i);
346
- const ch = s[i];
347
- if (ch === "'") out += "\\'";
348
- else if (ch === '\\') out += '\\\\';
349
- else if (ch === '\n') out += '\\n';
350
- else if (ch === '\r') out += '\\r';
351
- else if (ch === '\t') out += '\\t';
352
- else if (c >= 0x20 && c <= 0x7e) out += ch;
353
- else out += `\\u${c.toString(16).padStart(4, '0')}`;
354
- }
355
- return out + "'";
356
- }
357
-
358
- function toJs(v: unknown): string {
359
- if (v === null || v === undefined) return 'null';
360
- if (typeof v === 'boolean' || typeof v === 'number') return String(v);
361
- if (typeof v === 'string') return jsString(v);
362
- if (Array.isArray(v)) return `[${v.map(toJs).join(', ')}]`;
363
- if (typeof v === 'object') return `{ ${Object.entries(v as Record<string, unknown>).map(([k, x]) => `${jsString(k)}: ${toJs(x)}`).join(', ')} }`;
364
- return jsString(String(v));
365
- }
366
-
367
- function oneLine(s: string): string {
368
- return s.replace(/\s+/g, ' ').trim();
369
- }
370
-
371
- interface Case {
372
- exhibit: string;
373
- title: string;
374
- tc: TestCase;
375
- }
376
-
377
- function pythonFile(context: string, cases: Case[], idx: Index): string {
378
- const rows = cases
379
- .map(
380
- (c) =>
381
- ` # ${oneLine(c.title)}\n pytest.param(${pyString(c.tc.relation)}, ${toPy(c.tc.inputs)}, ${toPy(c.tc.relation === 'returns' ? c.tc.expected : null)}, ${pyString(oneLine(c.tc.note ?? c.title))}, id=${pyString(c.exhibit)}),`,
382
- )
383
- .join('\n');
384
- return `"""Edge-case tests for: ${oneLine(context).replace(/"""/g, "'''")}
385
-
386
- Generated by the Edge Case Museum (by Mojibake, creators of Pipeworx: https://pipeworx.io).
387
- Each case is one museum exhibit; edge_case_get({"id": "<case id>"}) explains the
388
- input, what goes wrong, and why. Collection as of ${idx.data_as_of}.
389
-
390
- Run: pytest ${'test_edge_cases.py'}
391
- """
392
- import pytest
393
-
394
-
395
- # TODO: point SUBJECT at the function you are testing, for example
396
- # from myapp.accounts import canonical_username as SUBJECT
397
- def SUBJECT(value):
398
- # TODO: replace this stub. It returns its input unchanged, so most cases
399
- # fail until SUBJECT is your real function. That is the point.
400
- return value
401
-
402
-
403
- CASES = [
404
- ${rows}
405
- ]
406
-
407
-
408
- @pytest.mark.parametrize("relation, inputs, expected, note", CASES)
409
- def test_edge_case(relation, inputs, expected, note):
410
- if relation == "equal":
411
- # Both inputs should end up the same after your function (e.g. the same canonical username).
412
- assert SUBJECT(inputs[0]) == SUBJECT(inputs[1]), note
413
- elif relation == "not_equal":
414
- # These must stay distinguishable after your function.
415
- assert SUBJECT(inputs[0]) != SUBJECT(inputs[1]), note
416
- elif relation == "returns":
417
- # TODO: if your function returns a different shape, adapt this comparison.
418
- assert SUBJECT(inputs[0]) == expected, note
419
- elif relation == "raises":
420
- # Your function should reject this input rather than accept it.
421
- with pytest.raises(Exception):
422
- SUBJECT(inputs[0])
423
- elif relation == "no_crash":
424
- for value in inputs:
425
- SUBJECT(value)
426
- else:
427
- pytest.fail(f"unknown relation {relation!r}")
428
- `;
429
- }
430
-
431
- function jsFile(context: string, cases: Case[], idx: Index): string {
432
- const rows = cases
433
- .map(
434
- (c) =>
435
- ` // ${oneLine(c.title)}\n { exhibit: ${jsString(c.exhibit)}, relation: ${jsString(c.tc.relation)}, inputs: ${toJs(c.tc.inputs)}, expected: ${toJs(c.tc.relation === 'returns' ? c.tc.expected : null)}, note: ${jsString(oneLine(c.tc.note ?? c.title))} },`,
436
- )
437
- .join('\n');
438
- return `// Edge-case tests for: ${oneLine(context)}
439
- //
440
- // Generated by the Edge Case Museum (by Mojibake, creators of Pipeworx: https://pipeworx.io).
441
- // Each case is one museum exhibit; edge_case_get({ id: "<exhibit>" }) explains the
442
- // input, what goes wrong, and why. Collection as of ${idx.data_as_of}.
443
- //
444
- // Run: node --test edge-cases.test.mjs
445
- import { test } from 'node:test';
446
- import assert from 'node:assert/strict';
447
-
448
- // TODO: import the function you are testing and use it as subject, for example
449
- // import { canonicalUsername as subject } from './accounts.js';
450
- function subject(value) {
451
- // TODO: replace this stub. It returns its input unchanged, so most cases
452
- // fail until subject is your real function. That is the point.
453
- return value;
454
- }
455
-
456
- const CASES = [
457
- ${rows}
458
- ];
459
-
460
- for (const c of CASES) {
461
- test(\`\${c.exhibit}: \${c.note}\`, () => {
462
- switch (c.relation) {
463
- case 'equal':
464
- // Both inputs should end up the same after your function.
465
- assert.deepStrictEqual(subject(c.inputs[0]), subject(c.inputs[1]), c.note);
466
- break;
467
- case 'not_equal':
468
- assert.notDeepStrictEqual(subject(c.inputs[0]), subject(c.inputs[1]), c.note);
469
- break;
470
- case 'returns':
471
- // TODO: if your function returns a different shape, adapt this comparison.
472
- assert.deepStrictEqual(subject(c.inputs[0]), c.expected, c.note);
473
- break;
474
- case 'raises':
475
- assert.throws(() => subject(c.inputs[0]), undefined, c.note);
476
- break;
477
- case 'no_crash':
478
- for (const v of c.inputs) subject(v);
479
- break;
480
- default:
481
- assert.fail(\`unknown relation \${c.relation}\`);
482
- }
483
- });
484
- }
485
- `;
486
- }
487
-
488
- // ───────────────────────── tools ─────────────────────────
489
-
490
- const tools: McpToolExport['tools'] = [
491
- {
492
- name: 'edge_cases_find',
493
- description:
494
- 'Edge Case Museum: describe what you are implementing in plain words ("username uniqueness", "parse a CSV upload", "store and add up money amounts", "schedule a reminder in the user\'s timezone", "sort customer names") and get the surprising inputs that break that kind of code. Each exhibit gives the assumption it breaks, the exact input with escapes, what goes wrong, the fix, and a repro in your language whose expected_output was captured by actually running it (Python 3 or Node.js). Returns a short form plus refine_tags; use edge_case_get for the full exhibit or edge_cases_test_file for a runnable test file. Curated collection, keyword and tag matching, no upstream calls.',
495
- inputSchema: {
496
- type: 'object' as const,
497
- properties: {
498
- context: { type: 'string', description: 'What you are implementing, in plain words, e.g. "username uniqueness" or "parse a CSV upload".' },
499
- language: { type: 'string', enum: ['python', 'javascript'], description: 'Language of the repro to include with each exhibit. "javascript" means Node.js. Omit for the first available repro.' },
500
- limit: { type: 'number', description: 'Maximum exhibits to return (default 8, max 25).' },
501
- },
502
- required: ['context'],
503
- },
504
- },
505
- {
506
- name: 'edge_case_get',
507
- description:
508
- 'Edge Case Museum: one exhibit in full by id (from edge_cases_find or edge_cases_browse): the assumption it breaks, the exact input, what goes wrong and why, every repro (executed Python/Node.js repros with their captured output and runtime version; other stacks labelled documented with a source), the fix, related exhibits, cited sources and test inputs.',
509
- inputSchema: {
510
- type: 'object' as const,
511
- properties: {
512
- id: { type: 'string', description: 'Exhibit id, e.g. "nfc-nfd-same-name-two-accounts".' },
513
- },
514
- required: ['id'],
515
- },
516
- },
517
- {
518
- name: 'edge_cases_browse',
519
- description:
520
- 'Edge Case Museum: browse the collection. With no arguments, lists the categories (unicode, datetime, float-money, sorting, filenames, parsing, integers, names-addresses) with counts and their most common tags. With category or tag, lists the exhibits in it: id, title, the assumption each breaks, tags and which languages have executed repros.',
521
- inputSchema: {
522
- type: 'object' as const,
523
- properties: {
524
- category: { type: 'string', enum: ['unicode', 'datetime', 'float-money', 'sorting', 'filenames', 'parsing', 'integers', 'names-addresses'], description: 'Category to list.' },
525
- tag: { type: 'string', description: 'Tag to list, e.g. "username", "csv", "timezone", "money".' },
526
- },
527
- },
528
- },
529
- {
530
- name: 'edge_cases_test_file',
531
- description:
532
- 'Edge Case Museum: one runnable test file (pytest for python, node:test for javascript) bundling the inputs from the exhibits relevant to what you are implementing, as parametrized cases you point at your own function. The file runs as generated against a stub, with TODO marks where your function goes; each case names its exhibit id so edge_case_get can explain a failure.',
533
- inputSchema: {
534
- type: 'object' as const,
535
- properties: {
536
- context: { type: 'string', description: 'What you are implementing, e.g. "parse a CSV upload".' },
537
- language: { type: 'string', enum: ['python', 'javascript'], description: 'python produces a pytest file; javascript produces a node:test file.' },
538
- },
539
- required: ['context', 'language'],
540
- },
541
- },
542
- ];
543
-
544
- function requireString(args: Record<string, unknown>, k: string): string {
545
- const v = args[k];
546
- if (typeof v !== 'string' || !v.trim()) throw new Error(`edge-case-museum: "${k}" is required (a non-empty string).`);
547
- return v.trim();
548
- }
549
-
550
- function find(args: Record<string, unknown>) {
551
- const idx = ensureIndex(args);
552
- const context = requireString(args, 'context');
553
- const lang = normLang(args.language);
554
- const limit = Math.max(1, Math.min(25, Math.floor(Number(args.limit ?? 8)) || 8));
555
- let ranked = score(idx, context);
556
- if (lang) {
557
- // An exhibit with no executed repro in the requested language still counts, ranked below those that have one.
558
- ranked = ranked.map((s) => ({ ...s, score: verifiedRepro(s.ex, lang) ? s.score : s.score * 0.6 })).sort((a, b) => b.score - a.score || a.ex.id.localeCompare(b.ex.id));
559
- }
560
- const top = ranked.slice(0, limit);
561
- const queryTags = new Set(top.flatMap((s) => s.matched));
562
- if (!top.length) {
563
- return {
564
- context,
565
- count: 0,
566
- exhibits: [],
567
- no_match: `No exhibit matched "${context}". Describe the code in terms of what it handles (usernames, money, CSV, timezones, filenames, sorting, names) or browse a category.`,
568
- categories: Object.keys(CATEGORY_LABELS),
569
- ...provenance(idx),
570
- };
571
- }
572
- return {
573
- context,
574
- language: lang,
575
- count: top.length,
576
- more_available: Math.max(0, ranked.length - top.length),
577
- exhibits: top.map((s) => summary(s.ex, lang, s.matched)),
578
- refine_tags: refineTags(top.map((s) => s.ex), queryTags),
579
- next: 'edge_case_get({id}) for the full exhibit and sources; edge_cases_test_file({context, language}) for a runnable test file of these inputs.',
580
- ...provenance(idx),
581
- };
582
- }
583
-
584
- function get(args: Record<string, unknown>) {
585
- const idx = ensureIndex(args);
586
- const id = requireString(args, 'id').toLowerCase();
587
- const ex = idx.byId.get(id);
588
- if (!ex) {
589
- const near = score(idx, id.replace(/-/g, ' ')).slice(0, 5).map((s) => s.ex.id);
590
- throw new Error(`edge-case-museum: no exhibit with id "${id}".${near.length ? ` Closest: ${near.join(', ')}.` : ''} edge_cases_browse lists every id.`);
591
- }
592
- return {
593
- ...ex,
594
- repros: ex.repros.map(reproOut),
595
- related: ex.related.map((r) => ({ id: r, title: idx.byId.get(r)?.title ?? null })),
596
- category_label: CATEGORY_LABELS[ex.category] ?? ex.category,
597
- ...provenance(idx),
598
- };
599
- }
600
-
601
- function browse(args: Record<string, unknown>) {
602
- const idx = ensureIndex(args);
603
- const category = typeof args.category === 'string' && args.category.trim() ? args.category.trim().toLowerCase() : null;
604
- const tag = typeof args.tag === 'string' && args.tag.trim() ? args.tag.trim().toLowerCase() : null;
605
- if (category && !CATEGORY_LABELS[category]) throw new Error(`edge-case-museum: unknown category "${category}". Categories: ${Object.keys(CATEGORY_LABELS).join(', ')}.`);
606
- if (!category && !tag) {
607
- return {
608
- categories: Object.entries(CATEGORY_LABELS).map(([c, label]) => {
609
- const list = idx.exhibits.filter((e) => e.category === c);
610
- return { category: c, label, count: list.length, top_tags: refineTags(list, new Set(), 8), example: list[0] ? { id: list[0].id, title: list[0].title } : null };
611
- }),
612
- ...provenance(idx),
613
- };
614
- }
615
- let list = idx.exhibits;
616
- if (category) list = list.filter((e) => e.category === category);
617
- if (tag) list = list.filter((e) => e.tags.some((t) => t.toLowerCase() === tag));
618
- if (!list.length) {
619
- const all = refineTags(category ? idx.exhibits.filter((e) => e.category === category) : idx.exhibits, new Set(), 40);
620
- throw new Error(`edge-case-museum: no exhibits with tag "${tag}"${category ? ` in ${category}` : ''}. Tags in use include: ${all.join(', ')}.`);
621
- }
622
- return {
623
- category,
624
- category_label: category ? CATEGORY_LABELS[category] : null,
625
- tag,
626
- count: list.length,
627
- exhibits: list.map((e) => ({
628
- id: e.id,
629
- title: e.title,
630
- category: e.category,
631
- assumption: e.assumption,
632
- tags: e.tags,
633
- languages: e.repros.map((r) => `${r.language}${r.status === 'documented' ? ' (documented)' : ''}`),
634
- })),
635
- ...provenance(idx),
636
- };
637
- }
638
-
639
- function testFile(args: Record<string, unknown>) {
640
- const idx = ensureIndex(args);
641
- const context = requireString(args, 'context');
642
- const lang = normLang(args.language);
643
- if (!lang) throw new Error('edge-case-museum: "language" is required: "python" (pytest) or "javascript" (node:test).');
644
- const all = score(idx, context);
645
- // One test file targets ONE function, so keep to the kind of code the context is about: the categories of
646
- // the strongest matches, and nothing scoring under half the best match (a CSV parser should not be handed
647
- // filename cases just because both mention "upload").
648
- const focus = new Set(all.slice(0, 3).map((s) => s.ex.category));
649
- const best = all[0]?.score ?? 0;
650
- const ranked = all.filter((s) => s.ex.test_cases.length && focus.has(s.ex.category) && s.score >= best * 0.5);
651
- const used = ranked.slice(0, 12);
652
- if (!used.length) {
653
- throw new Error(`edge-case-museum: no exhibit relevant to "${context}" carries test inputs yet. Try edge_cases_find to see the relevant exhibits and their repros.`);
654
- }
655
- const cases: Case[] = used.flatMap((s) => s.ex.test_cases.map((tc) => ({ exhibit: s.ex.id, title: s.ex.title, tc })));
656
- const content = lang === 'python' ? pythonFile(context, cases, idx) : jsFile(context, cases, idx);
657
- return {
658
- context,
659
- language: lang,
660
- framework: lang === 'python' ? 'pytest' : 'node:test',
661
- filename: lang === 'python' ? 'test_edge_cases.py' : 'edge-cases.test.mjs',
662
- run: lang === 'python' ? 'pytest test_edge_cases.py' : 'node --test edge-cases.test.mjs',
663
- cases: cases.length,
664
- exhibits: used.map((s) => ({ id: s.ex.id, title: s.ex.title })),
665
- how_to_use: 'Save the file, replace the stub (marked TODO) with your own function, and run it. Against the stub most cases fail by design; each failure names the exhibit id that explains it.',
666
- content,
667
- ...provenance(idx),
668
- };
669
- }
670
-
671
- async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
672
- switch (name) {
673
- case 'edge_cases_find':
674
- return find(args);
675
- case 'edge_case_get':
676
- return get(args);
677
- case 'edge_cases_browse':
678
- return browse(args);
679
- case 'edge_cases_test_file':
680
- return testFile(args);
681
- default:
682
- throw new Error(`Unknown tool: ${name}`);
683
- }
684
- }
685
-
686
- export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;