@cliwant/mcp-sam-gov 0.3.0 → 1.0.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 (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +226 -64
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +74 -1
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +419 -3
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +111 -6
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3889 -296
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1629 -201
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +5 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +512 -3
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +139 -7
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4551 -328
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +1946 -214
  228. package/src/usitc.ts +420 -0
package/src/cms.ts ADDED
@@ -0,0 +1,634 @@
1
+ /**
2
+ * CMS Open Payments — the healthcare industry→physician payment TRANSPARENCY lane
3
+ * on a NEW **DKAN 2.x** datastore adapter (keyless). Source #28 (ADR-0037).
4
+ *
5
+ * A third data-portal family after Socrata SODA (src/socrata.ts) and CKAN Action
6
+ * API (src/ckan.ts) — the DKAN DCAT-metastore + `/api/1/datastore/query` pair.
7
+ * Scoped this cycle to the single confirmed host `openpaymentsdata.cms.gov`, whose
8
+ * flagship datasets are the CMS Open Payments (Physician Payments Sunshine Act,
9
+ * Social Security Act §1128G / 42 CFR Part 403 subpart I) tables: every reported
10
+ * payment / other-transfer-of-value / ownership interest from drug & device
11
+ * manufacturers (and GPOs) to physicians, non-physician practitioners, and
12
+ * teaching hospitals. The B2G unlock: healthcare-COI / industry-financial-
13
+ * relationship vetting + healthcare market intelligence — the money/COI question
14
+ * NPPES (provider identity, src/nppes.ts) cannot answer.
15
+ *
16
+ * TWO tools (mirror the Socrata/CKAN discovery+query split):
17
+ * - cms_search_datasets — DKAN DCAT metastore discovery. `GET /api/1/metastore/
18
+ * schemas/dataset/items` returns the FULL catalog ARRAY in one shot (the server
19
+ * IGNORES limit/offset/page — M2). We fetch it ONCE and do ALL q-substring
20
+ * filtering + limit/offset slicing CLIENT-SIDE against the in-memory array, so
21
+ * totalAvailable is the EXACT post-q catalog size and pagination is honest
22
+ * against the KNOWN length (never a false-more, never a dead-end offset).
23
+ * - cms_query_dataset — DKAN datastore query by datasetId + distribution index,
24
+ * with server-side `conditions` filters, an EXACT `count`, offset/limit ≤ 500
25
+ * pagination, a `properties` projection, and `results:false` = the count/schema
26
+ * column-discovery mode. Rows pass through VERBATIM (values are text strings).
27
+ *
28
+ * HONESTY (writes ZERO fetch/coerce/error/meta code — REUSES getJson/throughGate/
29
+ * driftError + coerce.num/str + withMeta/buildMeta):
30
+ * P1 `count` is the EXACT grand total (num-guarded) → totalAvailable=count, real
31
+ * offset pagination; a PRESENT non-number count in results-mode ⇒ driftError.
32
+ * limit ≤ 500 is the HARD API cap (Zod .max(500); a higher limit ⇒ invalid_input
33
+ * client-side, so the API's own 400 is never reached).
34
+ * P2 empty (`{count:0, results:[]}`) ⇒ honest complete:true; a 400 (bad column /
35
+ * bad limit) / 404 (bad datasetId/index) / HTML (SPA/WAF) / 5xx / timeout /
36
+ * non-JSON ⇒ THROW (never a fake empty).
37
+ * P3 money/amounts (total_amount_of_payment_usdollars, …) are text STRINGS →
38
+ * surfaced verbatim; coerce.num is null-never-0 (a missing/""/"-" amount → null,
39
+ * NEVER 0 — the pricing.ts money-lie precedent).
40
+ * P4 `conditions` are server-side + self-policing: a bad column → the API 400s →
41
+ * invalid_input; every requested condition either applies or the call errors,
42
+ * so filtersDropped is ALWAYS empty (no silent-drop path).
43
+ * P-drift (M1) — the results-array drift guard is CONDITIONED on the effective
44
+ * `results` mode: results:true (default) REQUIRES Array.isArray(body.results) +
45
+ * the schema anchor; results:false EXPECTS results ABSENT (rows:[], no throw)
46
+ * and uses schema presence + a number-typed count as the drift anchor. The
47
+ * schema block (keyed by the DISTRIBUTION id) is the anchor — fields are read
48
+ * from Object.values(schema)[0].fields.
49
+ *
50
+ * SSRF (the CKAN/Socrata fixed-host idiom, COPIED not imported). The load-bearing
51
+ * risk: `datasetId` + `index` interpolate into the URL PATH
52
+ * (`/api/1/datastore/query/{datasetId}/{index}`), so URLSearchParams does NOT
53
+ * protect them. Validate datasetId against the strict 36-char LOWERCASE UUID
54
+ * grammar (the CKAN UUID_RE verbatim — rejects %2F, "..", uppercase, a trailing
55
+ * "\n") and index as a small non-negative int BEFORE interpolation; every OTHER
56
+ * param (conditions[i][*], properties[], limit, offset, count, results) rides the
57
+ * query string via URLSearchParams. Host is a compile-time constant; a
58
+ * post-construction hostname/protocol assert + redirect:"error" fail closed.
59
+ *
60
+ * PII / scope boundary (NPPES precedent): Open Payments NAMES individual physicians
61
+ * + payment amounts AND is a federal transparency-BY-LAW public dataset — IN-SCOPE
62
+ * per the NPPES/NSF-PI precedent, bounded to targeted vetting (per-query limit ≤ 500
63
+ * + an offset reach cap S3, no enrichment, NO covered_recipient_npi→NPPES auto-join,
64
+ * a mandatory not-a-COI-finding / cross-check-SAM+OFAC+LEIE caveat on every response).
65
+ */
66
+
67
+ import { ToolErrorCarrier } from "./errors.js";
68
+ import { getJson, throughGate, driftError } from "./datasource.js";
69
+ import { num, str } from "./coerce.js";
70
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
71
+
72
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
73
+ // ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
74
+ // `num` from this module (cms.num === coerce.num — a num regression fails together).
75
+ export { num };
76
+
77
+ // ─── Fixed host allowlist (SSRF core — compile-time CONSTANTS) ────
78
+ // Single-entry allowlist, structured for a later host-add exactly like CKAN_HOSTS.
79
+ // A future DKAN gov portal is a one-line edit + a live `?limit=1` verification.
80
+ export const CMS_HOSTS = ["openpaymentsdata.cms.gov"] as const;
81
+ export type CmsHost = (typeof CMS_HOSTS)[number];
82
+ const CMS_HOST: CmsHost = CMS_HOSTS[0];
83
+ const CMS_HOST_SET: ReadonlySet<string> = new Set(CMS_HOSTS);
84
+
85
+ // HOST-only label. Surfaces in ToolError.upstreamEndpoint; keyless → no token.
86
+ const CMS_LABEL = "cms:" + CMS_HOST;
87
+
88
+ // Modest self-throttle (courteous to a single public gov host; matches the
89
+ // NPPES/ECHO/CKAN defensive posture — no documented hard rate limit).
90
+ const CMS_GATE_MIN_INTERVAL_MS = 200;
91
+
92
+ // ─── Grammars + caps (SSRF + honesty guards) ─────────────────────
93
+ // A DKAN datasetId is EXACTLY a 36-char LOWERCASE hex UUID (the CKAN UUID_RE
94
+ // verbatim). m1: JS `$` does NOT admit a trailing "\n" (unlike Python) — the regex
95
+ // alone rejects a newline; `.length(36)` is belt-and-suspenders. m2: lowercase-only
96
+ // (no `i` flag) — every live DKAN id is a 36-char lowercase UUID.
97
+ const UUID_RE =
98
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
99
+
100
+ // A DKAN column name is snake_case lowercase alnum (verified). A bad one 400s
101
+ // upstream anyway, but we validate to keep the wire clean and the PATH unbreakable.
102
+ const COLUMN_RE = /^[a-z0-9_]+$/;
103
+ const MAX_COLUMN_LEN = 128;
104
+ const MAX_VALUE_LEN = 200;
105
+
106
+ // The `conditions[i][operator]` ENUM (DKAN 400s an unknown operator).
107
+ export const CMS_OPERATORS = ["=", "<>", "<", ">", "<=", ">=", "like", "in"] as const;
108
+ export type CmsOperator = (typeof CMS_OPERATORS)[number];
109
+ const CMS_OPERATOR_SET: ReadonlySet<string> = new Set(CMS_OPERATORS);
110
+
111
+ const CMS_MAX_LIMIT = 500; // the HARD DKAN cap (400s over it — no silent clamp).
112
+ const CMS_DEFAULT_LIMIT = 100;
113
+ const CMS_MAX_INDEX = 50; // distributions are single-digit.
114
+ const CMS_MAX_CONDITIONS = 10; // bound URL length.
115
+ // ★ S3 — our OWN server POLICY offset/reach cap (mirrors NPPES_MAX_SKIP). Open
116
+ // Payments names individual physicians + dollar amounts (MORE sensitive than
117
+ // NPPES), so we impose a deliberate targeted-lookup boundary. The EXACT count
118
+ // SIZES a harvest; it does not BOUND it — this cap does.
119
+ const CMS_MAX_OFFSET = 2000;
120
+
121
+ // ─── Disclosure constants (honesty obligations — verbatim, fault-asserted) ──
122
+
123
+ /**
124
+ * ★ The mandatory not-a-determination caveat carried on EVERY cms_query_dataset /
125
+ * cms_search_datasets response (mirrors NPPES_NOT_DETERMINATION_NOTE /
126
+ * OFAC_NOT_DETERMINATION_NOTE). Kept verbatim so the fault suite can assert it.
127
+ */
128
+ export const CMS_OPEN_PAYMENTS_NOT_DETERMINATION_NOTE =
129
+ "Public transparency-by-law data (CMS Open Payments, Physician Payments Sunshine Act). Reports reported industry payments / transfers of value / ownership interests ONLY — it is NOT a conflict-of-interest finding, a fitness/exclusion determination, or evidence of wrongdoing (many payments are routine and lawful). Individual records name physicians, teaching hospitals, and dollar amounts verbatim from the public dataset; this tool performs NO enrichment and NO cross-source join (e.g. it does NOT auto-join covered_recipient_npi to NPPES). Cross-check SAM exclusions + OFAC for debarment/sanctions and the OIG-LEIE for healthcare exclusions.";
130
+
131
+ /** ★ S3 — the per-query reach-cap POLICY disclosure carried on EVERY response. */
132
+ export const CMS_OPEN_PAYMENTS_REACH_CAP_NOTE =
133
+ "This vetting tool reaches at most the first ~2,500 rows per query (limit ≤ 500, offset ≤ 2,000) as a deliberate targeted-lookup boundary — Open Payments names individual physicians AND dollar amounts, so the reach is bounded like NPPES. The EXACT count sizes a result set but does not bound a harvest; this is a PER-QUERY cap only (cross-query iteration is inherent to any datastore API). Narrow your `conditions` (recipient_state / specialty / manufacturer) for a complete, targeted result set, or use the metastore distribution downloadURL for a bulk pull.";
134
+
135
+ const VALUE_TYPING_NOTE =
136
+ "Row value fields follow schema.fields[].type; every Open Payments column is text, so a numeric column (e.g. total_amount_of_payment_usdollars) arrives as a STRING verbatim — a missing amount is absent, never 0 (coerce with null-never-0 semantics).";
137
+
138
+ const SOURCE = "openpaymentsdata.cms.gov (CMS Open Payments, DKAN datastore, keyless)";
139
+
140
+ // ─── invalid_input helper ─────────────────────────────────────────
141
+ function invalidInput(message: string): ToolErrorCarrier {
142
+ return new ToolErrorCarrier({
143
+ kind: "invalid_input",
144
+ message,
145
+ retryable: false,
146
+ upstreamEndpoint: CMS_LABEL,
147
+ });
148
+ }
149
+
150
+ /** Strip C0 control chars + DEL and trim (URLSearchParams encodes the rest). */
151
+ function sanitizeText(v: string): string {
152
+ let out = "";
153
+ for (const ch of v) {
154
+ const code = ch.codePointAt(0) ?? 0;
155
+ if (code < 0x20 || code === 0x7f) continue;
156
+ out += ch;
157
+ }
158
+ return out.trim();
159
+ }
160
+
161
+ // ─── SSRF-guarded fetch layer ─────────────────────────────────────
162
+ /**
163
+ * GET a DKAN datastore query. SSRF: host ∈ allowlist (belt-and-suspenders behind
164
+ * the server's Zod), datasetId is a 36-char lowercase UUID + index is a small
165
+ * non-negative int (BOTH re-checked here BEFORE they interpolate into the PATH),
166
+ * and the CONSTRUCTED URL's hostname === host (https). redirect:"error" (off-host
167
+ * 3xx fails closed). Keyless — NO headers. Returns parsed JSON (caller validates).
168
+ */
169
+ async function getDatastore(
170
+ datasetId: string,
171
+ index: number,
172
+ params: URLSearchParams,
173
+ ): Promise<unknown> {
174
+ if (!CMS_HOST_SET.has(CMS_HOST)) {
175
+ throw invalidInput(
176
+ `CMS host ${JSON.stringify(CMS_HOST)} is not on the curated allowlist.`,
177
+ );
178
+ }
179
+ // ★ PATH-interpolation guards (load-bearing — datasetId + index ride the PATH).
180
+ if (datasetId.length !== 36 || !UUID_RE.test(datasetId)) {
181
+ throw invalidInput(
182
+ `Invalid CMS datasetId ${JSON.stringify(datasetId)} — expected a 36-char lowercase UUID ([0-9a-f]{8}-{4}-{4}-{4}-{12}); it interpolates into the URL path, so a %2F/../uppercase/newline id is refused (SSRF safety).`,
183
+ );
184
+ }
185
+ if (!Number.isInteger(index) || index < 0 || index > CMS_MAX_INDEX) {
186
+ throw invalidInput(
187
+ `Invalid CMS index ${JSON.stringify(index)} — expected a non-negative integer 0..${CMS_MAX_INDEX} (the distribution index; it interpolates into the URL path).`,
188
+ );
189
+ }
190
+ const url = `https://${CMS_HOST}/api/1/datastore/query/${datasetId}/${index}?${params.toString()}`;
191
+ const built = new URL(url);
192
+ if (built.hostname !== CMS_HOST || built.protocol !== "https:") {
193
+ throw invalidInput(
194
+ `Constructed CMS URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match ${CMS_HOST} over https — refusing to fetch (SSRF safety).`,
195
+ );
196
+ }
197
+ // ★ W3-2 — the SyntaxError→schema_drift catch-ladder (fema.ts:262-275 shape).
198
+ // getJson's r.json() runs OUTSIDE fetchWithRetry, so a 200 non-JSON body (a DKAN
199
+ // SPA/WAF/maintenance HTML masquerade) throws a raw SyntaxError; toToolError has
200
+ // NO schema_drift branch → it would degrade to kind:"unknown". Preserve the
201
+ // fetchWithRetry taxonomy (429/404/5xx/400/timeout ToolErrorCarrier) FIRST (a
202
+ // broader catch would reclassify a 429 to schema_drift), reclassify the SyntaxError
203
+ // SECOND, bare-rethrow LAST.
204
+ try {
205
+ return await throughGate(CMS_HOST, CMS_GATE_MIN_INTERVAL_MS, () =>
206
+ getJson(url, { label: CMS_LABEL, redirect: "error" }),
207
+ );
208
+ } catch (e) {
209
+ if (e instanceof ToolErrorCarrier) throw e;
210
+ if (e instanceof SyntaxError)
211
+ throw driftError(
212
+ CMS_LABEL,
213
+ "CMS DKAN datastore returned a non-JSON body at HTTP 200 — schema drift.",
214
+ );
215
+ throw e;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * GET the DKAN DCAT metastore catalog (fixed path; no id interpolation).
221
+ *
222
+ * ★ D1 — `?show-reference-ids` is REQUIRED (not optional). The FLAT default endpoint
223
+ * (`…/items` with no params) returns each distribution as a top-level bag of keys
224
+ * (downloadURL/format/mediaType/title) with NO distribution `identifier` and NO
225
+ * nested `data` object — so mapDataset's `{identifier, data:{downloadURL,…}}` reader
226
+ * would resolve distId + downloadURL + mediaType + title to NULL for every row
227
+ * (dropping the real advertised bulk-download URL the reach-cap note points callers
228
+ * to). ONLY `?show-reference-ids` carries BOTH the distribution `identifier` AND the
229
+ * nested `data:{downloadURL,mediaType,title}` block. It is an ORTHOGONAL
230
+ * reference-expansion flag — the endpoint STILL ignores limit/offset/page and ships
231
+ * the full catalog in one shot, so the M2 client-side-slice behavior is unchanged.
232
+ */
233
+ async function getMetastore(): Promise<unknown> {
234
+ const url = `https://${CMS_HOST}/api/1/metastore/schemas/dataset/items?show-reference-ids`;
235
+ const built = new URL(url);
236
+ if (built.hostname !== CMS_HOST || built.protocol !== "https:") {
237
+ throw invalidInput(
238
+ `Constructed CMS metastore URL host ${JSON.stringify(built.hostname)} does not match ${CMS_HOST} over https — refusing to fetch (SSRF safety).`,
239
+ );
240
+ }
241
+ // ★ W3-2 — the IDENTICAL SyntaxError→schema_drift catch-ladder as getDatastore
242
+ // (fema.ts:262-275 shape). A 200 non-JSON metastore body (HTML/WAF) throws a raw
243
+ // SyntaxError from r.json() → without this it degrades to kind:"unknown". Preserve
244
+ // the ToolErrorCarrier taxonomy FIRST, reclassify SECOND, bare-rethrow LAST.
245
+ try {
246
+ return await throughGate(CMS_HOST, CMS_GATE_MIN_INTERVAL_MS, () =>
247
+ getJson(url, { label: CMS_LABEL, redirect: "error" }),
248
+ );
249
+ } catch (e) {
250
+ if (e instanceof ToolErrorCarrier) throw e;
251
+ if (e instanceof SyntaxError)
252
+ throw driftError(
253
+ CMS_LABEL,
254
+ "CMS DKAN metastore returned a non-JSON body at HTTP 200 — schema drift.",
255
+ );
256
+ throw e;
257
+ }
258
+ }
259
+
260
+ // ─── Datastore schema/field mapping ───────────────────────────────
261
+ export type CmsField = {
262
+ name: string;
263
+ type: string | null;
264
+ mysqlType: string | null;
265
+ description: string | null;
266
+ };
267
+
268
+ function rec(x: unknown): Record<string, unknown> {
269
+ return x !== null && typeof x === "object" && !Array.isArray(x)
270
+ ? (x as Record<string, unknown>)
271
+ : {};
272
+ }
273
+
274
+ /**
275
+ * Read the fields block from the DKAN schema (keyed by the DISTRIBUTION id, distinct
276
+ * from the datasetId — §fact 8). `Object.values(schema)[0].fields` is the drift
277
+ * anchor. Returns the mapped fields, or `null` when the anchor is missing/malformed
278
+ * (the caller turns a null anchor into driftError — never a fake empty).
279
+ */
280
+ function readFields(schema: unknown): CmsField[] | null {
281
+ if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
282
+ return null;
283
+ }
284
+ const distributions = Object.values(schema as Record<string, unknown>);
285
+ if (distributions.length === 0) return null;
286
+ const first = rec(distributions[0]);
287
+ const fields = first.fields;
288
+ if (fields === null || typeof fields !== "object" || Array.isArray(fields)) {
289
+ return null;
290
+ }
291
+ const out: CmsField[] = [];
292
+ for (const [name, def] of Object.entries(fields as Record<string, unknown>)) {
293
+ const d = rec(def);
294
+ out.push({
295
+ name,
296
+ type: str(d.type),
297
+ mysqlType: str(d.mysql_type),
298
+ description: str(d.description),
299
+ });
300
+ }
301
+ return out;
302
+ }
303
+
304
+ // ─── Tool 1: cms_query_dataset ────────────────────────────────────
305
+ export type CmsCondition = {
306
+ property: string;
307
+ value: string | number;
308
+ operator?: string;
309
+ };
310
+
311
+ export type CmsQueryArgs = {
312
+ datasetId: string;
313
+ index?: number;
314
+ conditions?: CmsCondition[];
315
+ properties?: string[];
316
+ limit?: number;
317
+ offset?: number;
318
+ results?: boolean;
319
+ };
320
+
321
+ /**
322
+ * Query a DKAN datastore distribution by datasetId + index. Server-side `conditions`
323
+ * filters (self-policing — a bad column 400s → invalid_input, so filtersDropped is
324
+ * always empty), an EXACT `count` (real offset pagination), a `properties`
325
+ * projection, and `results:false` = the count/schema column-discovery mode (M1/S1:
326
+ * rows omitted, pagination disabled — no livelock). count=true is ALWAYS on the wire
327
+ * (S2 — never a caller toggle). Rows pass through VERBATIM (strings). A 400 (bad
328
+ * column/limit) / 404 (bad datasetId/index) / HTML / 5xx / timeout ⇒ THROW.
329
+ */
330
+ export async function queryDataset(args: CmsQueryArgs): Promise<MetaBundle> {
331
+ // NO .trim() — the CKAN/Socrata precedent: a trailing "\n" must reach the
332
+ // length(36)+UUID_RE guard (trimming would strip it and admit a newline-suffixed id).
333
+ const datasetId = args.datasetId === undefined || args.datasetId === null ? "" : String(args.datasetId);
334
+ const index = args.index ?? 0;
335
+ const limit = args.limit ?? CMS_DEFAULT_LIMIT;
336
+ const offset = args.offset ?? 0;
337
+ const results = args.results ?? true;
338
+
339
+ // ── Client-side caps (belt-and-suspenders behind Zod; SSRF: no fetch on reject). ──
340
+ if (!Number.isInteger(limit) || limit < 1 || limit > CMS_MAX_LIMIT) {
341
+ throw invalidInput(
342
+ `limit ${JSON.stringify(args.limit)} out of range — DKAN caps a datastore page at ${CMS_MAX_LIMIT} (the API 400s over it; this tool rejects it loudly). Use 1..${CMS_MAX_LIMIT}.`,
343
+ );
344
+ }
345
+ if (!Number.isInteger(offset) || offset < 0 || offset > CMS_MAX_OFFSET) {
346
+ throw invalidInput(
347
+ `offset ${JSON.stringify(args.offset)} out of range — this vetting tool caps reach at offset ≤ ${CMS_MAX_OFFSET} (a deliberate targeted-lookup POLICY boundary; Open Payments names physicians + amounts). Narrow your conditions rather than paging deeper.`,
348
+ );
349
+ }
350
+
351
+ // ── Build + validate the wire query (SSRF: key-by-key from typed args). ──
352
+ const params = new URLSearchParams();
353
+ // S2 — ALWAYS emit count=true (never a caller toggle; mirror the ckan.ts
354
+ // always-exact-total doctrine).
355
+ params.set("count", "true");
356
+ params.set("results", results ? "true" : "false");
357
+ params.set("limit", String(limit));
358
+ params.set("offset", String(offset));
359
+
360
+ const filtersApplied: string[] = [];
361
+
362
+ // conditions (server-side, self-policing — a bad column 400s, never a silent drop).
363
+ const conditions = args.conditions ?? [];
364
+ if (conditions.length > CMS_MAX_CONDITIONS) {
365
+ throw invalidInput(
366
+ `Too many conditions (${conditions.length}) — cap is ${CMS_MAX_CONDITIONS} to bound URL length.`,
367
+ );
368
+ }
369
+ conditions.forEach((c, i) => {
370
+ const property = sanitizeText(String(c.property ?? ""));
371
+ if (property.length === 0 || property.length > MAX_COLUMN_LEN || !COLUMN_RE.test(property)) {
372
+ throw invalidInput(
373
+ `Invalid condition[${i}].property ${JSON.stringify(c.property)} — a DKAN column is snake_case lowercase alnum (^[a-z0-9_]+$).`,
374
+ );
375
+ }
376
+ const operator = c.operator === undefined ? "=" : String(c.operator);
377
+ if (!CMS_OPERATOR_SET.has(operator)) {
378
+ throw invalidInput(
379
+ `Invalid condition[${i}].operator ${JSON.stringify(c.operator)} — expected one of ${CMS_OPERATORS.join(" ")}.`,
380
+ );
381
+ }
382
+ const rawValue = typeof c.value === "number" ? String(c.value) : sanitizeText(String(c.value ?? ""));
383
+ if (rawValue.length > MAX_VALUE_LEN) {
384
+ throw invalidInput(`condition[${i}].value is too long (max ${MAX_VALUE_LEN} chars).`);
385
+ }
386
+ params.set(`conditions[${i}][property]`, property);
387
+ params.set(`conditions[${i}][value]`, rawValue);
388
+ params.set(`conditions[${i}][operator]`, operator);
389
+ filtersApplied.push(`${property} ${operator} ${rawValue}`);
390
+ });
391
+
392
+ // properties projection (column subset — same snake_case grammar).
393
+ const properties = args.properties ?? [];
394
+ properties.forEach((p, i) => {
395
+ const col = sanitizeText(String(p ?? ""));
396
+ if (col.length === 0 || col.length > MAX_COLUMN_LEN || !COLUMN_RE.test(col)) {
397
+ throw invalidInput(
398
+ `Invalid properties[${i}] ${JSON.stringify(p)} — a DKAN column is snake_case lowercase alnum (^[a-z0-9_]+$).`,
399
+ );
400
+ }
401
+ params.append("properties[]", col);
402
+ });
403
+ if (properties.length > 0) filtersApplied.push("properties");
404
+
405
+ const body = await getDatastore(datasetId, index, params);
406
+ const b = rec(body);
407
+
408
+ // ── Schema anchor (the drift anchor, keyed by the distribution id). ──
409
+ const fields = readFields(b.schema);
410
+ if (fields === null) {
411
+ throw driftError(
412
+ CMS_LABEL,
413
+ `${CMS_LABEL} datastore query returned a body without a usable schema anchor (Object.values(schema)[0].fields missing) — treating as schema drift, never a fake empty.`,
414
+ );
415
+ }
416
+
417
+ const notes: string[] = [VALUE_TYPING_NOTE];
418
+ let rows: Record<string, unknown>[];
419
+ let totalAvailable: number | null;
420
+ let hasMore: boolean;
421
+ let returned: number;
422
+
423
+ // ── count (P1/S2): a PRESENT non-number count in either mode ⇒ driftError; an
424
+ // ABSENT count in results:true ⇒ hedge (never fabricate a total). ──
425
+ const rawCount = b.count;
426
+ const countPresent = rawCount !== undefined;
427
+ const countIsNumber = typeof rawCount === "number" && Number.isFinite(rawCount);
428
+ if (countPresent && !countIsNumber) {
429
+ throw driftError(
430
+ CMS_LABEL,
431
+ `${CMS_LABEL} datastore query returned a non-number \`count\` — treating as schema drift (typeof-checked BEFORE num()).`,
432
+ );
433
+ }
434
+
435
+ if (results) {
436
+ // ★ M1 — results:true (default): REQUIRE Array.isArray(body.results) else drift.
437
+ if (!Array.isArray(b.results)) {
438
+ throw driftError(
439
+ CMS_LABEL,
440
+ `${CMS_LABEL} datastore query (results:true) returned a body whose \`results\` is missing or not an array — treating as schema drift, never a fake empty.`,
441
+ );
442
+ }
443
+ rows = (b.results as unknown[]).map(rec);
444
+ returned = rows.length;
445
+ if (countIsNumber) {
446
+ totalAvailable = num(rawCount);
447
+ hasMore = totalAvailable !== null ? offset + returned < totalAvailable : returned >= limit;
448
+ } else {
449
+ // S2 belt-and-suspenders — count absent from a results-mode body: hedge with
450
+ // page-fullness, never fabricate a total.
451
+ totalAvailable = null;
452
+ hasMore = returned >= limit;
453
+ notes.push(
454
+ "The upstream did not report `count` on this results:true response; totalAvailable is withheld (null) and completeness is inferred from page-fullness (a short page means the result set is exhausted) — never a fabricated total.",
455
+ );
456
+ }
457
+ } else {
458
+ // ★ M1/S1 — results:false: EXPECT `results` ABSENT (rows omitted, NOT results:[]).
459
+ // Drift anchor = schema presence (already checked) + a number-typed count. Do
460
+ // NOT throw on an absent `results`. Pagination is DISABLED (no livelock — S1).
461
+ if (!countIsNumber) {
462
+ throw driftError(
463
+ CMS_LABEL,
464
+ `${CMS_LABEL} datastore query (results:false) returned a body without a number-typed \`count\` — the count/schema-discovery anchor is absent; treating as schema drift.`,
465
+ );
466
+ }
467
+ rows = [];
468
+ returned = 0;
469
+ totalAvailable = num(rawCount);
470
+ hasMore = false; // S1 — results:false is a count/schema mode; no rows to page.
471
+ notes.push(
472
+ "results:false is a COUNT/SCHEMA-discovery mode: no rows are returned and pagination is disabled (hasMore:false). To page rows, set results:true with limit/offset. totalAvailable is the EXACT match count and `fields` describes every column.",
473
+ );
474
+ }
475
+
476
+ // W3-6 (dogfood): the raw next offset (`offset + returned`) can exceed
477
+ // CMS_MAX_OFFSET (the deliberate reach cap validated at the top of this fn). If
478
+ // it does, a follow-up call with that offset would be HARD-REJECTED as
479
+ // invalid_input — so advertising it as `nextOffset` is a false promise. Mirror
480
+ // the edgar-FTS / regulations-dockets ceiling doctrine: keep `hasMore` honest
481
+ // (more matches DO exist upstream) but set `nextOffset` to null (not
482
+ // page-reachable via this tool) + a disclosing note. A genuine non-ceiling
483
+ // next page stays reachable.
484
+ const rawNextOffset = offset + returned;
485
+ const nextReachable = rawNextOffset <= CMS_MAX_OFFSET;
486
+ const nextOffset = hasMore && nextReachable ? rawNextOffset : null;
487
+ if (hasMore && !nextReachable) {
488
+ notes.push(
489
+ `More matches exist upstream, but the next page (offset ${rawNextOffset}) exceeds this vetting tool's offset ≤ ${CMS_MAX_OFFSET} reach cap — nextOffset is null (not page-reachable here; a follow-up at that offset would be rejected as invalid_input). Narrow your conditions to reach deeper matches.`,
490
+ );
491
+ }
492
+
493
+ notes.push(CMS_OPEN_PAYMENTS_NOT_DETERMINATION_NOTE, CMS_OPEN_PAYMENTS_REACH_CAP_NOTE);
494
+
495
+ const meta: Partial<ResponseMeta> = {
496
+ source: SOURCE,
497
+ keylessMode: true,
498
+ returned,
499
+ totalAvailable,
500
+ filtersApplied,
501
+ // P4 — a bad column 400s upstream (→ invalid_input); a filter is never silently
502
+ // dropped, so filtersDropped is provably always empty for this source.
503
+ filtersDropped: [],
504
+ fieldsUnavailable: [],
505
+ pagination: { offset, limit, hasMore, nextOffset },
506
+ notes,
507
+ };
508
+
509
+ return withMeta(
510
+ { datasetId, index, results, fields, rows },
511
+ meta,
512
+ );
513
+ }
514
+
515
+ // ─── Tool 2: cms_search_datasets ──────────────────────────────────
516
+ export type CmsDistribution = {
517
+ index: number;
518
+ distId: string | null;
519
+ title: string | null;
520
+ mediaType: string | null;
521
+ downloadURL: string | null;
522
+ };
523
+
524
+ export type CmsDataset = {
525
+ datasetId: string | null;
526
+ title: string | null;
527
+ description: string | null;
528
+ distributions: CmsDistribution[];
529
+ keyword: string[];
530
+ modified: string | null;
531
+ };
532
+
533
+ /** Map ONE DCAT metastore item → a curated discovery row. */
534
+ function mapDataset(raw: unknown): CmsDataset {
535
+ const r = rec(raw);
536
+ const rawDist = Array.isArray(r.distribution) ? r.distribution : [];
537
+ const distributions: CmsDistribution[] = rawDist.map((d, i) => {
538
+ const dd = rec(d);
539
+ const data = rec(dd.data);
540
+ return {
541
+ index: i,
542
+ distId: str(dd.identifier),
543
+ title: str(data.title),
544
+ mediaType: str(data.mediaType),
545
+ downloadURL: str(data.downloadURL),
546
+ };
547
+ });
548
+ const keyword = Array.isArray(r.keyword)
549
+ ? (r.keyword.map(str).filter((k): k is string => k !== null))
550
+ : [];
551
+ return {
552
+ datasetId: str(r.identifier),
553
+ title: str(r.title),
554
+ description: str(r.description),
555
+ distributions,
556
+ keyword,
557
+ modified: str(r.modified),
558
+ };
559
+ }
560
+
561
+ /** Case-insensitive substring match over title + description. */
562
+ function matchesQ(d: CmsDataset, q: string): boolean {
563
+ const needle = q.toLowerCase();
564
+ return (
565
+ (d.title !== null && d.title.toLowerCase().includes(needle)) ||
566
+ (d.description !== null && d.description.toLowerCase().includes(needle))
567
+ );
568
+ }
569
+
570
+ export type CmsSearchArgs = {
571
+ q?: string;
572
+ limit?: number;
573
+ offset?: number;
574
+ };
575
+
576
+ /**
577
+ * Discover DKAN datasets via the DCAT metastore. ★ M2 — the metastore IGNORES
578
+ * limit/offset/page and always ships the ENTIRE catalog array in one response, so we
579
+ * fetch it ONCE and apply ALL q-substring filtering + limit/offset slicing
580
+ * CLIENT-SIDE against the in-memory array: totalAvailable = the EXACT post-q catalog
581
+ * size (never null), hasMore = offset + returned < filteredLength, nextOffset
582
+ * against the KNOWN length (never a server offset — no false-more, no dead-end).
583
+ */
584
+ export async function searchDatasets(args: CmsSearchArgs): Promise<MetaBundle> {
585
+ const q = args.q !== undefined && args.q !== null ? sanitizeText(String(args.q)) : "";
586
+ const limit = args.limit ?? 20;
587
+ const offset = args.offset ?? 0;
588
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
589
+ throw invalidInput(`limit ${JSON.stringify(args.limit)} out of range — use 1..100.`);
590
+ }
591
+ if (!Number.isInteger(offset) || offset < 0) {
592
+ throw invalidInput(`offset ${JSON.stringify(args.offset)} out of range — use a non-negative integer.`);
593
+ }
594
+
595
+ const body = await getMetastore();
596
+ // ★ M2 — the metastore is a BARE ARRAY (no envelope, no total). A non-array body
597
+ // is drift, never a fake empty.
598
+ if (!Array.isArray(body)) {
599
+ throw driftError(
600
+ CMS_LABEL,
601
+ `${CMS_LABEL} metastore returned an unexpected shape (GET /api/1/metastore/schemas/dataset/items must be a JSON array of DCAT datasets).`,
602
+ );
603
+ }
604
+
605
+ const all = (body as unknown[]).map(mapDataset);
606
+ const filtered = q === "" ? all : all.filter((d) => matchesQ(d, q));
607
+ const filteredLength = filtered.length; // EXACT post-q catalog size (M2).
608
+ const page = filtered.slice(offset, offset + limit);
609
+ const returned = page.length;
610
+ const hasMore = offset + returned < filteredLength;
611
+ const nextOffset = hasMore ? offset + returned : null;
612
+
613
+ const filtersApplied: string[] = q === "" ? [] : ["q"];
614
+ const notes: string[] = [
615
+ "The DKAN metastore returns the entire dataset catalog in one response; q/limit/offset are applied CLIENT-SIDE and totalAvailable is the exact catalog size (post-q).",
616
+ "Feed a result's datasetId + a distribution index to cms_query_dataset (use results:false there for the column schema before pulling rows).",
617
+ CMS_OPEN_PAYMENTS_NOT_DETERMINATION_NOTE,
618
+ ];
619
+
620
+ return withMeta(
621
+ { query: q === "" ? null : q, results: page },
622
+ {
623
+ source: `${CMS_HOST} DKAN metastore (CMS Open Payments, keyless)`,
624
+ keylessMode: true,
625
+ returned,
626
+ totalAvailable: filteredLength,
627
+ filtersApplied,
628
+ filtersDropped: [],
629
+ fieldsUnavailable: [],
630
+ pagination: { offset, limit, hasMore, nextOffset },
631
+ notes,
632
+ } satisfies Partial<ResponseMeta>,
633
+ );
634
+ }
package/src/coerce.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * coerce.ts — the honesty-critical value coercions shared across the keyless
3
+ * DataSources (ADR-0005 v2 FIX-C: map-layer primitives, split out of the
4
+ * fetch-envelope port so each module stays single-concern).
5
+ *
6
+ * `num`/`str` return **null (NEVER 0 / never a fabricated string)** for absent
7
+ * values — a missing amount is an honest "unknown", never a data-absence-as-zero
8
+ * masquerade (the project's forbidden failure class). This is the single most
9
+ * duplicated honesty primitive across the sources; hoisting ONE audited copy
10
+ * removes the 3-way drift risk (a `num` regression now fails Treasury AND Socrata
11
+ * suites at once instead of silently in one).
12
+ *
13
+ * Byte-identical to Treasury's + Socrata's prior LOCAL copies:
14
+ * - `num` is identical in both (and, per ADR-0005 v2, output-equivalent to
15
+ * EDGAR's — "NULL" etc. reach null via Number(...)=NaN either way).
16
+ * - `str` adopts the Treasury/Socrata convention (null for ""/"null"). It is
17
+ * output-identical to BOTH over their real input domains. EDGAR's `str` does
18
+ * NOT null the literal "null" (ADR-0005 v2 FIX-A), so EDGAR keeps its own
19
+ * local `str` and is NOT migrated here (EDGAR is deferred anyway per FIX-B).
20
+ */
21
+
22
+ /**
23
+ * Coerce an inconsistently-typed value field to `number | null`.
24
+ *
25
+ * Returns **null (NEVER 0)** for absent values: `null`/`undefined`, the literal
26
+ * string `"null"`, `""`/whitespace (CRITICAL — `Number("")` is 0, so this MUST
27
+ * be caught explicitly), and the `"(-)"`/`"-"` placeholders. Numeric strings
28
+ * parse; numbers pass through (a non-finite number → null).
29
+ */
30
+ export function num(x: unknown): number | null {
31
+ if (x === null || x === undefined) return null;
32
+ if (typeof x === "number") return Number.isFinite(x) ? x : null;
33
+ if (typeof x === "string") {
34
+ const s = x.trim();
35
+ if (s === "" || s === "null" || s === "(-)" || s === "-") return null;
36
+ const n = Number(s);
37
+ return Number.isFinite(n) ? n : null;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** null for absent (null/undefined/""/whitespace/"null"), else the trimmed string. */
43
+ export function str(x: unknown): string | null {
44
+ if (x === null || x === undefined) return null;
45
+ const s = String(x).trim();
46
+ return s === "" || s === "null" ? null : s;
47
+ }