@cliwant/mcp-sam-gov 1.10.0 → 1.12.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.ja.md +5 -5
- package/README.ko.md +5 -5
- package/README.md +23 -7
- package/dist/bls.d.ts.map +1 -1
- package/dist/bls.js +6 -1
- package/dist/bls.js.map +1 -1
- package/dist/bonfire.d.ts.map +1 -1
- package/dist/bonfire.js +17 -2
- package/dist/bonfire.js.map +1 -1
- package/dist/cbp-border.d.ts +9 -5
- package/dist/cbp-border.d.ts.map +1 -1
- package/dist/cbp-border.js +31 -11
- package/dist/cbp-border.js.map +1 -1
- package/dist/census-economic.d.ts.map +1 -1
- package/dist/census-economic.js +10 -1
- package/dist/census-economic.js.map +1 -1
- package/dist/ckan.d.ts.map +1 -1
- package/dist/ckan.js +17 -3
- package/dist/ckan.js.map +1 -1
- package/dist/clinicaltrials.d.ts.map +1 -1
- package/dist/clinicaltrials.js +23 -6
- package/dist/clinicaltrials.js.map +1 -1
- package/dist/fdic.js +7 -7
- package/dist/fdic.js.map +1 -1
- package/dist/fema.d.ts.map +1 -1
- package/dist/fema.js +13 -1
- package/dist/fema.js.map +1 -1
- package/dist/gao.d.ts.map +1 -1
- package/dist/gao.js +25 -0
- package/dist/gao.js.map +1 -1
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +14 -4
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +9 -0
- package/dist/gsa-csv.d.ts.map +1 -1
- package/dist/gsa-csv.js +37 -8
- package/dist/gsa-csv.js.map +1 -1
- package/dist/gsa-perdiem.d.ts +16 -2
- package/dist/gsa-perdiem.d.ts.map +1 -1
- package/dist/gsa-perdiem.js +25 -4
- package/dist/gsa-perdiem.js.map +1 -1
- package/dist/integrity.d.ts.map +1 -1
- package/dist/integrity.js +18 -3
- package/dist/integrity.js.map +1 -1
- package/dist/lda.d.ts.map +1 -1
- package/dist/lda.js +17 -4
- package/dist/lda.js.map +1 -1
- package/dist/nhtsa.d.ts +9 -5
- package/dist/nhtsa.d.ts.map +1 -1
- package/dist/nhtsa.js +90 -26
- package/dist/nhtsa.js.map +1 -1
- package/dist/nist-controls.d.ts +3 -1
- package/dist/nist-controls.d.ts.map +1 -1
- package/dist/nist-controls.js +34 -12
- package/dist/nist-controls.js.map +1 -1
- package/dist/nppes.d.ts.map +1 -1
- package/dist/nppes.js +17 -4
- package/dist/nppes.js.map +1 -1
- package/dist/nsf.d.ts.map +1 -1
- package/dist/nsf.js +9 -0
- package/dist/nsf.js.map +1 -1
- package/dist/ofac.d.ts.map +1 -1
- package/dist/ofac.js +10 -1
- package/dist/ofac.js.map +1 -1
- package/dist/openfda-device.d.ts.map +1 -1
- package/dist/openfda-device.js +8 -5
- package/dist/openfda-device.js.map +1 -1
- package/dist/openfda-drugsfda.d.ts.map +1 -1
- package/dist/openfda-drugsfda.js +8 -5
- package/dist/openfda-drugsfda.js.map +1 -1
- package/dist/openfda.d.ts +21 -0
- package/dist/openfda.d.ts.map +1 -1
- package/dist/openfda.js +38 -4
- package/dist/openfda.js.map +1 -1
- package/dist/opengov.d.ts.map +1 -1
- package/dist/opengov.js +8 -4
- package/dist/opengov.js.map +1 -1
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +13 -4
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/server.d.ts +11 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +79 -10
- package/dist/server.js.map +1 -1
- package/dist/socrata.d.ts +1 -1
- package/dist/socrata.d.ts.map +1 -1
- package/dist/socrata.js +44 -7
- package/dist/socrata.js.map +1 -1
- package/dist/usaspending.d.ts +2 -2
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +21 -10
- package/dist/usaspending.js.map +1 -1
- package/package.json +11 -3
- package/src/bls.ts +8 -1
- package/src/bonfire.ts +17 -2
- package/src/cbp-border.ts +30 -9
- package/src/census-economic.ts +13 -2
- package/src/ckan.ts +19 -3
- package/src/clinicaltrials.ts +25 -8
- package/src/fdic.ts +7 -7
- package/src/fema.ts +15 -1
- package/src/gao.ts +27 -0
- package/src/grants.ts +16 -3
- package/src/gsa-csv.ts +38 -7
- package/src/gsa-perdiem.ts +28 -4
- package/src/integrity.ts +18 -3
- package/src/lda.ts +21 -5
- package/src/nhtsa.ts +92 -27
- package/src/nist-controls.ts +58 -9
- package/src/nppes.ts +18 -4
- package/src/nsf.ts +10 -0
- package/src/ofac.ts +12 -1
- package/src/openfda-device.ts +10 -4
- package/src/openfda-drugsfda.ts +10 -4
- package/src/openfda.ts +47 -5
- package/src/opengov.ts +8 -4
- package/src/sam-gov/client.ts +13 -4
- package/src/server.ts +86 -10
- package/src/socrata.ts +44 -7
- package/src/usaspending.ts +21 -10
package/src/bonfire.ts
CHANGED
|
@@ -153,7 +153,18 @@ async function getBonfireRss(org: string): Promise<string> {
|
|
|
153
153
|
upstreamEndpoint: bonfireLabel(org),
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
|
-
|
|
156
|
+
// Single-fetch path (retry:false) so a redirect is classified as a NON-retryable
|
|
157
|
+
// schema_drift, NOT a retryable upstream_unavailable. A drifted seed slug whose
|
|
158
|
+
// Bonfire portal has moved returns a 3xx (live-observed 307 on sanantonio /
|
|
159
|
+
// kingcounty / rutgers); routed through fetchWithRetry it would masquerade as a
|
|
160
|
+
// transient outage ("retry in 30s") that never recovers. A 5xx/429/404/timeout
|
|
161
|
+
// still THROWS honestly on the single attempt (never a fake empty).
|
|
162
|
+
return getText(url, {
|
|
163
|
+
label: bonfireLabel(org),
|
|
164
|
+
redirect: "error",
|
|
165
|
+
retry: false,
|
|
166
|
+
redirectMessage: `Bonfire org ${JSON.stringify(org)} redirected off ${host} (an HTTP 3xx) — this seed slug's portal has moved or been retired, so the entry is drifted (retrying will not help). Re-discover the current slug via bonfire_list_organizations or probe {slug}.bonfirehub.com/opportunities/rss.`,
|
|
167
|
+
});
|
|
157
168
|
}
|
|
158
169
|
|
|
159
170
|
// ─── Tool 1: bonfire_list_organizations (curated seed directory) ──
|
|
@@ -218,7 +229,11 @@ export async function searchOpportunities(args: BonfireSearchArgs): Promise<Meta
|
|
|
218
229
|
throw driftError(bonfireLabel(org), "Bonfire returned a non-RSS body at HTTP 200 — schema drift (expected an <rss><channel> feed).");
|
|
219
230
|
}
|
|
220
231
|
|
|
221
|
-
|
|
232
|
+
// Tolerate attributes on the opening <item …> tag (consistent with the <channel[\s>]
|
|
233
|
+
// drift guard and the inner tag() matcher). RSS 2.0 <item> has no standard attributes,
|
|
234
|
+
// but a namespaced/extended feed could add them — a bare-<item> regex would silently
|
|
235
|
+
// DROP such items and undercount totalAvailable (a latent P1 risk caught in dogfooding).
|
|
236
|
+
const items = body.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi) ?? [];
|
|
222
237
|
const all = items.map(mapItem);
|
|
223
238
|
// P1: the RSS is the COMPLETE open set ⇒ totalAvailable = item count (true total).
|
|
224
239
|
const totalAvailable = all.length;
|
package/src/cbp-border.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* cbp-border.ts — CBP Border Wait Times (bwt.cbp.gov, KEYLESS) — the FREIGHT /
|
|
3
|
-
* LOGISTICS lane. Live
|
|
3
|
+
* LOGISTICS lane. Live COMMERCIAL-VEHICLE (freight-truck) wait times at every US
|
|
4
4
|
* land border port (Canadian + Mexican): per-port lane delays, operational status,
|
|
5
|
-
* and open-lane counts.
|
|
5
|
+
* and open-lane counts. (The raw feed also carries passenger/pedestrian lanes, but
|
|
6
|
+
* this tool surfaces ONLY the commercial-vehicle lanes — the freight lane.)
|
|
7
|
+
* Answers "what's the current commercial-truck delay at port
|
|
6
8
|
* X" — real-time freight-crossing situational awareness for logistics/trade vendors.
|
|
7
9
|
*
|
|
8
10
|
* SOURCE: CBP's official Border Wait Times API (bwt.cbp.gov/api/bwtnew) — a .gov host,
|
|
@@ -127,9 +129,11 @@ async function loadPorts(): Promise<CbpPort[]> {
|
|
|
127
129
|
|
|
128
130
|
// ─── Tool: cbp_border_wait_times ──────────────────────────────────
|
|
129
131
|
/**
|
|
130
|
-
* List CBP land-border-port
|
|
131
|
-
* filtered by border (Canadian/Mexican) and/or port name (substring
|
|
132
|
-
*
|
|
132
|
+
* List CBP land-border-port COMMERCIAL-VEHICLE (freight-truck) wait times, optionally
|
|
133
|
+
* filtered by border (Canadian/Mexican) and/or port name (substring, applied
|
|
134
|
+
* CLIENT-SIDE over the full fetched set and disclosed as such). Passenger/pedestrian
|
|
135
|
+
* lanes are NOT surfaced (freight lane only). Honest `_meta` (exact match total +
|
|
136
|
+
* real-time freshness).
|
|
133
137
|
*/
|
|
134
138
|
export async function borderWaitTimes(args: {
|
|
135
139
|
border?: string;
|
|
@@ -142,10 +146,20 @@ export async function borderWaitTimes(args: {
|
|
|
142
146
|
const all = await loadPorts();
|
|
143
147
|
|
|
144
148
|
const filtersApplied: string[] = [];
|
|
149
|
+
const filtersDropped: string[] = [];
|
|
145
150
|
const borderQ = args.border?.trim().toLowerCase();
|
|
146
151
|
const portQ = args.portName?.trim().toLowerCase();
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
// [filter honesty] Only claim a filter APPLIED when its query is non-empty — a
|
|
153
|
+
// border:"" / portName:"" (or whitespace) narrows nothing, so reporting it as
|
|
154
|
+
// applied while returning every port would be a false filtersApplied.
|
|
155
|
+
if (args.border !== undefined) {
|
|
156
|
+
if (borderQ) filtersApplied.push("border");
|
|
157
|
+
else filtersDropped.push("border(empty)");
|
|
158
|
+
}
|
|
159
|
+
if (args.portName !== undefined) {
|
|
160
|
+
if (portQ) filtersApplied.push("portName");
|
|
161
|
+
else filtersDropped.push("portName(empty)");
|
|
162
|
+
}
|
|
149
163
|
|
|
150
164
|
const matched = all.filter((p) => {
|
|
151
165
|
if (borderQ && !(p.border ?? "").toLowerCase().includes(borderQ)) return false;
|
|
@@ -159,6 +173,13 @@ export async function borderWaitTimes(args: {
|
|
|
159
173
|
const hasMore = offset + returned < totalAvailable;
|
|
160
174
|
const nextOffset = hasMore ? offset + returned : null;
|
|
161
175
|
|
|
176
|
+
const notes: string[] = [PROVENANCE_NOTE, FRESHNESS_NOTE];
|
|
177
|
+
if (filtersApplied.length > 0) {
|
|
178
|
+
notes.push(
|
|
179
|
+
"border / portName are applied CLIENT-SIDE over the full live port set (fetched in ONE request — the CBP feed has no server-side filter); totalAvailable is the EXACT matched count over that full set.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
162
183
|
return withMeta(
|
|
163
184
|
{ ports: page },
|
|
164
185
|
{
|
|
@@ -168,10 +189,10 @@ export async function borderWaitTimes(args: {
|
|
|
168
189
|
totalAvailable,
|
|
169
190
|
truncated: hasMore,
|
|
170
191
|
filtersApplied,
|
|
171
|
-
filtersDropped
|
|
192
|
+
filtersDropped,
|
|
172
193
|
fieldsUnavailable: [],
|
|
173
194
|
pagination: { offset, limit, hasMore, nextOffset },
|
|
174
|
-
notes
|
|
195
|
+
notes,
|
|
175
196
|
} satisfies Partial<ResponseMeta>,
|
|
176
197
|
);
|
|
177
198
|
}
|
package/src/census-economic.ts
CHANGED
|
@@ -94,7 +94,12 @@ const GEOGRAPHIES = new Set(["us", "state", "county"]);
|
|
|
94
94
|
// (no CENSUS_API_KEY was available at build time). See SUPPRESSED_NOTE.
|
|
95
95
|
const CENSUS_SENTINEL_FLOOR = -100000000;
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
// Latest PUBLISHED CBP vintage (live-verified 2026-07-20:
|
|
98
|
+
// api.census.gov/data/2023/cbp/variables.json → 200; /data/2024 → 404). CBP is
|
|
99
|
+
// released with a ~2-year lag and irregularly, so a dynamic "current year − N" is
|
|
100
|
+
// unsafe (it would query an unpublished vintage) — this is a hard-coded latest;
|
|
101
|
+
// bump it (and re-verify) when a newer /data/{year}/cbp appears. ADR-0047.
|
|
102
|
+
const DEFAULT_YEAR = "2023";
|
|
98
103
|
|
|
99
104
|
// ─── Honesty notes (ADR-0047 required set) ────────────────────────
|
|
100
105
|
const KEY_REQUIRED_NOTE =
|
|
@@ -145,7 +150,7 @@ export type CensusBusinessPatternsArgs = {
|
|
|
145
150
|
naics?: string;
|
|
146
151
|
geography?: string; // us | state | county (default us)
|
|
147
152
|
state?: string; // 2-digit FIPS (required for county; optional filter for state)
|
|
148
|
-
year?: string; // ^\d{4}$ (default
|
|
153
|
+
year?: string; // ^\d{4}$ (default 2023)
|
|
149
154
|
limit?: number; // OPTIONAL client-side top-N slice (CBP has no server pagination)
|
|
150
155
|
};
|
|
151
156
|
|
|
@@ -172,6 +177,7 @@ export async function businessPatterns(
|
|
|
172
177
|
|
|
173
178
|
// ── Validate + default the inputs (belt-and-suspenders behind the server Zod;
|
|
174
179
|
// a DIRECT handler call bypasses Zod). ──
|
|
180
|
+
const yearWasDefaulted = args.year === undefined;
|
|
175
181
|
const year = args.year ?? DEFAULT_YEAR;
|
|
176
182
|
if (!YEAR_RE.test(year)) {
|
|
177
183
|
throw new ToolErrorCarrier({
|
|
@@ -395,6 +401,11 @@ export async function businessPatterns(
|
|
|
395
401
|
SUPPRESSED_NOTE,
|
|
396
402
|
NO_PAGINATION_NOTE,
|
|
397
403
|
];
|
|
404
|
+
if (yearWasDefaulted) {
|
|
405
|
+
notes.push(
|
|
406
|
+
`No \`year\` was supplied, so it defaulted to ${DEFAULT_YEAR} — the latest PUBLISHED CBP vintage as of the last verification (CBP is released with a ~2-year lag). Pass an explicit \`year\` for a different vintage.`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
398
409
|
|
|
399
410
|
let rows = allRows;
|
|
400
411
|
if (
|
package/src/ckan.ts
CHANGED
|
@@ -441,7 +441,7 @@ export async function discoverDatasets(args: {
|
|
|
441
441
|
params.set("rows", String(limit));
|
|
442
442
|
|
|
443
443
|
const key = `ckan:package_search:${args.host}:${args.q}:${limit}`;
|
|
444
|
-
const { totalAvailable, results } = await memoize(
|
|
444
|
+
const { totalAvailable, results, packagesReturned } = await memoize(
|
|
445
445
|
key,
|
|
446
446
|
async () => {
|
|
447
447
|
const body = await getCkanPackageSearch(args.host, params);
|
|
@@ -468,16 +468,31 @@ export async function discoverDatasets(args: {
|
|
|
468
468
|
const total = num(b.result.count);
|
|
469
469
|
const packages = Array.isArray(b.result.results) ? b.result.results : [];
|
|
470
470
|
const rows = packages.flatMap(mapPackageResources);
|
|
471
|
-
|
|
471
|
+
// packagesReturned = the number of DATASETS on this page. Truncation is a
|
|
472
|
+
// DATASET question (totalAvailable is a dataset count), NOT a resource-row
|
|
473
|
+
// one — the two units differ because a dataset can expose several resources.
|
|
474
|
+
return { totalAvailable: total, results: rows, packagesReturned: packages.length };
|
|
472
475
|
},
|
|
473
476
|
10 * 60 * 1000,
|
|
474
477
|
);
|
|
475
478
|
|
|
476
479
|
const returned = results.length;
|
|
480
|
+
// [truncation honesty] complete/truncated is a DATASET question — compare the
|
|
481
|
+
// DATASETS returned (packagesReturned) against the dataset total, NOT the
|
|
482
|
+
// per-resource row count (which can EXCEED the dataset total, e.g. 2 datasets →
|
|
483
|
+
// 14 resource rows vs count 10, and would spoof complete:true when only 2 of 10
|
|
484
|
+
// datasets were shown). This tool returns the first `limit` datasets and has no
|
|
485
|
+
// offset param, so nextOffset is null (raise `limit` to see more).
|
|
486
|
+
const hasMore = totalAvailable !== null && packagesReturned < totalAvailable;
|
|
477
487
|
const notes: string[] = [
|
|
478
488
|
`package_search over ${args.host} (rows=${limit}). Feed a datastoreActive:true result's resourceId to ckan_query; a datastoreActive:false resource is a raw file blob (CSV/PDF/…) NOT in the datastore and is NOT queryable.`,
|
|
479
|
-
"totalAvailable is the count of matching DATASETS (packages); the rows are per-RESOURCE (a dataset may expose several resources), so returned may differ from totalAvailable.",
|
|
489
|
+
"totalAvailable is the count of matching DATASETS (packages); the rows are per-RESOURCE (a dataset may expose several resources), so returned (resource rows) may differ from totalAvailable (datasets). complete/truncated tracks DATASETS shown vs matched.",
|
|
480
490
|
];
|
|
491
|
+
if (hasMore) {
|
|
492
|
+
notes.push(
|
|
493
|
+
`Only the first ${packagesReturned} of ${totalAvailable} matching datasets are shown (this tool returns the first \`limit\` datasets and does not page); raise \`limit\` to retrieve more.`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
481
496
|
|
|
482
497
|
return withMeta(
|
|
483
498
|
{ host: args.host, query: args.q, results },
|
|
@@ -489,6 +504,7 @@ export async function discoverDatasets(args: {
|
|
|
489
504
|
filtersApplied: ["q"],
|
|
490
505
|
filtersDropped: [],
|
|
491
506
|
fieldsUnavailable: [],
|
|
507
|
+
pagination: { offset: 0, limit, hasMore, nextOffset: null },
|
|
492
508
|
notes,
|
|
493
509
|
} satisfies Partial<ResponseMeta>,
|
|
494
510
|
);
|
package/src/clinicaltrials.ts
CHANGED
|
@@ -543,19 +543,31 @@ export async function searchStudies(args: CtSearchArgs): Promise<MetaBundle> {
|
|
|
543
543
|
"clinicaltrials shape drift — /studies response.studies must be an array.",
|
|
544
544
|
);
|
|
545
545
|
}
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
546
|
+
// §Honesty #1: totalCount is returned ONLY on the FIRST page of a cursor sequence —
|
|
547
|
+
// ClinicalTrials.gov v2 OMITS it on every pageToken CONTINUATION, even with
|
|
548
|
+
// countTotal=true (live-verified: page 1 carries totalCount, every subsequent
|
|
549
|
+
// pageToken call omits it). So its absence is drift ONLY on the first page; on a
|
|
550
|
+
// continuation an absent total is EXPECTED (handled at the totalAvailable step below).
|
|
551
|
+
const isContinuation = args.pageToken !== undefined;
|
|
552
|
+
|
|
553
|
+
const studies = (b.studies as unknown[]).map((s) => mapStudy(s, false));
|
|
554
|
+
const returned = studies.length;
|
|
555
|
+
// First page: totalCount MUST be a finite number (typeof-checked BEFORE num() so a
|
|
556
|
+
// non-number can't silently parse; NEVER studies.length) — its absence is drift.
|
|
557
|
+
// Continuation page: totalCount is legitimately omitted ⇒ totalAvailable:null (the
|
|
558
|
+
// first page's total still describes the whole set; disclosed in a note).
|
|
559
|
+
let totalAvailable: number | null;
|
|
560
|
+
if (typeof b.totalCount === "number" && Number.isFinite(b.totalCount)) {
|
|
561
|
+
totalAvailable = num(b.totalCount) as number; // EXACT (genuine 0 → 0)
|
|
562
|
+
} else if (isContinuation) {
|
|
563
|
+
totalAvailable = null;
|
|
564
|
+
} else {
|
|
549
565
|
throw driftError(
|
|
550
566
|
CT_LABEL,
|
|
551
|
-
"clinicaltrials shape drift — /studies totalCount missing/non-number on a countTotal=true request (typeof-checked BEFORE num()
|
|
567
|
+
"clinicaltrials shape drift — /studies totalCount missing/non-number on a FIRST-page countTotal=true request (typeof-checked BEFORE num(); NEVER fall back to studies.length).",
|
|
552
568
|
);
|
|
553
569
|
}
|
|
554
570
|
|
|
555
|
-
const studies = (b.studies as unknown[]).map((s) => mapStudy(s, false));
|
|
556
|
-
const returned = studies.length;
|
|
557
|
-
const totalAvailable = num(b.totalCount) as number; // EXACT (genuine 0 → 0)
|
|
558
|
-
|
|
559
571
|
// ── Opaque-cursor honesty (§Honesty #2). Terminal = token ABSENT. The token is
|
|
560
572
|
// surfaced VERBATIM (never fabricated/derived). Phantom-empty guard: 0
|
|
561
573
|
// studies WITH a token ⇒ terminal (never advertise a continuation into an
|
|
@@ -576,6 +588,11 @@ export async function searchStudies(args: CtSearchArgs): Promise<MetaBundle> {
|
|
|
576
588
|
// ── Notes: the mandatory caveat + cursor + data-currency always; the
|
|
577
589
|
// conditional facet/tokenization disclosures; the unscoped recommendation. ──
|
|
578
590
|
const notes: string[] = [CT_TRIAL_CAVEAT, CT_CURSOR_NOTE];
|
|
591
|
+
if (isContinuation && totalAvailable === null) {
|
|
592
|
+
notes.push(
|
|
593
|
+
"totalAvailable is null on this continuation page — ClinicalTrials.gov reports the study total ONLY on the first page of a cursor sequence (it is omitted on every pageToken continuation, even with countTotal=true). The total from the first page still describes the whole result set.",
|
|
594
|
+
);
|
|
595
|
+
}
|
|
579
596
|
notes.push(...andNotes);
|
|
580
597
|
// Emit the sponsor-broadening note ONLY for a SINGLE-token sponsor. For a
|
|
581
598
|
// MULTI-token sponsor CT AND-split and NARROWED (the andNotes AND-note fired),
|
package/src/fdic.ts
CHANGED
|
@@ -523,7 +523,7 @@ export async function searchInstitutions(args: {
|
|
|
523
523
|
const records = env.records.map(mapInstitution);
|
|
524
524
|
const returned = records.length;
|
|
525
525
|
const totalAvailable = env.totalAvailable;
|
|
526
|
-
const hasMore = offset + returned < totalAvailable;
|
|
526
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
527
527
|
const nextOffset = hasMore ? offset + returned : null;
|
|
528
528
|
|
|
529
529
|
const filtersApplied: string[] = [];
|
|
@@ -618,7 +618,7 @@ export async function institutionFinancials(args: {
|
|
|
618
618
|
const records = env.records.map(mapFinancials);
|
|
619
619
|
const returned = records.length;
|
|
620
620
|
const totalAvailable = env.totalAvailable;
|
|
621
|
-
const hasMore = offset + returned < totalAvailable;
|
|
621
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
622
622
|
const nextOffset = hasMore ? offset + returned : null;
|
|
623
623
|
|
|
624
624
|
const notes: string[] = [freshnessNote(env.indexName, env.indexCreated), ASSET_NOTE];
|
|
@@ -862,7 +862,7 @@ export async function bankFailures(args: {
|
|
|
862
862
|
const records = env.records.map(mapFailure);
|
|
863
863
|
const returned = records.length;
|
|
864
864
|
const totalAvailable = env.totalAvailable;
|
|
865
|
-
const hasMore = offset + returned < totalAvailable;
|
|
865
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
866
866
|
const nextOffset = hasMore ? offset + returned : null;
|
|
867
867
|
|
|
868
868
|
const filtersApplied: string[] = [];
|
|
@@ -1184,7 +1184,7 @@ export async function institutionHistory(args: {
|
|
|
1184
1184
|
const records = env.records.map(mapHistory);
|
|
1185
1185
|
const returned = records.length;
|
|
1186
1186
|
const totalAvailable = env.totalAvailable;
|
|
1187
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1187
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1188
1188
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1189
1189
|
|
|
1190
1190
|
const filtersApplied: string[] = [];
|
|
@@ -1524,7 +1524,7 @@ export async function industrySummary(args: {
|
|
|
1524
1524
|
const records = env.records.map(mapSummary);
|
|
1525
1525
|
const returned = records.length;
|
|
1526
1526
|
const totalAvailable = env.totalAvailable;
|
|
1527
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1527
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1528
1528
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1529
1529
|
|
|
1530
1530
|
const filtersApplied: string[] = [];
|
|
@@ -1820,7 +1820,7 @@ export async function riskRatios(args: {
|
|
|
1820
1820
|
const records = env.records.map(mapRiskRatios);
|
|
1821
1821
|
const returned = records.length;
|
|
1822
1822
|
const totalAvailable = env.totalAvailable;
|
|
1823
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1823
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1824
1824
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1825
1825
|
|
|
1826
1826
|
const filtersApplied: string[] = ["cert"];
|
|
@@ -2013,7 +2013,7 @@ export async function branchDeposits(args: {
|
|
|
2013
2013
|
const records = env.records.map(mapBranchDeposit);
|
|
2014
2014
|
const returned = records.length;
|
|
2015
2015
|
const totalAvailable = env.totalAvailable;
|
|
2016
|
-
const hasMore = offset + returned < totalAvailable;
|
|
2016
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
2017
2017
|
const nextOffset = hasMore ? offset + returned : null;
|
|
2018
2018
|
|
|
2019
2019
|
const filtersApplied: string[] = [];
|
package/src/fema.ts
CHANGED
|
@@ -407,8 +407,14 @@ function shapeResponse(args: {
|
|
|
407
407
|
|
|
408
408
|
const rows = coerceAmounts(rawRows as FemaRow[], def.amountFields);
|
|
409
409
|
const returned = rows.length;
|
|
410
|
+
// `returned > 0` guard (matches nvd/usaspending): an EMPTY page MUST terminate the
|
|
411
|
+
// walk. OpenFEMA's metadata.count can EXCEED the rows it will actually serve via
|
|
412
|
+
// $skip/$top (live: the ~822k-row public_assistance set stops serving rows well
|
|
413
|
+
// before its count), so without this guard a near-end offset yields returned:0 while
|
|
414
|
+
// offset < count ⇒ hasMore:true, nextOffset === offset — a non-advancing cursor an
|
|
415
|
+
// agent following nextOffset re-requests forever (empty page, re-scan, repeat).
|
|
410
416
|
const hasMore =
|
|
411
|
-
totalAvailable !== null && args.offset + returned < totalAvailable;
|
|
417
|
+
returned > 0 && totalAvailable !== null && args.offset + returned < totalAvailable;
|
|
412
418
|
const nextOffset = hasMore ? args.offset + returned : null;
|
|
413
419
|
|
|
414
420
|
const notes: string[] = [SHAPE_NOTE];
|
|
@@ -421,6 +427,14 @@ function shapeResponse(args: {
|
|
|
421
427
|
"This page returned fewer rows than the requested limit while more remain (OpenFEMA byte-truncates a wide page below $top); metadata.count is authoritative — page with a larger offset ($skip).",
|
|
422
428
|
);
|
|
423
429
|
}
|
|
430
|
+
// Phantom tail: OpenFEMA returned 0 rows though metadata.count is higher — its count
|
|
431
|
+
// can exceed the rows it will serve via pagination, so the walk is COMPLETE here even
|
|
432
|
+
// though returned:0 < count. Disclose it so the count is not read as a reachable target.
|
|
433
|
+
if (returned === 0 && totalAvailable !== null && args.offset > 0 && args.offset < totalAvailable) {
|
|
434
|
+
notes.push(
|
|
435
|
+
`OpenFEMA served 0 rows at offset ${args.offset} although metadata.count is ${totalAvailable} — its count can exceed the rows actually pageable via $skip/$top, so pagination is COMPLETE here (do not treat count as a reachable row target; narrow the filter for an exact set).`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
424
438
|
// Deep-offset caveat on the ~800k PA set (ADR-0016 OQ2).
|
|
425
439
|
if (args.offset > 100000) {
|
|
426
440
|
notes.push(
|
package/src/gao.ts
CHANGED
|
@@ -593,6 +593,21 @@ export async function gaoProtestLookup(args: {
|
|
|
593
593
|
|
|
594
594
|
// ── Feed path ──────────────────────────────────────────────────────
|
|
595
595
|
const xml = await getText(RSS_URL, "gao:rss");
|
|
596
|
+
// [P2/P4] A 200 body that isn't the RSS feed (GAO's edge is Cloudflare/WAF — a
|
|
597
|
+
// challenge or maintenance interstitial returns 200 HTML, NOT the feed) must
|
|
598
|
+
// NOT be parsed into an empty decision list: parseFeed's <item> regex yields []
|
|
599
|
+
// on any non-RSS body, which would read as "no recent bid protests" — an
|
|
600
|
+
// outage-as-empty lie. A real feed always carries <rss>/<channel>/<item> (a
|
|
601
|
+
// genuinely EMPTY feed still has <channel>, so a 0-item feed passes). Guard the
|
|
602
|
+
// shape and surface a retryable outage instead of a fabricated empty result.
|
|
603
|
+
if (!/<(rss|feed|channel|item)\b/i.test(xml)) {
|
|
604
|
+
throw new ToolErrorCarrier({
|
|
605
|
+
kind: "upstream_unavailable",
|
|
606
|
+
retryable: true,
|
|
607
|
+
message: `GAO RSS returned a 200 body with no <rss>/<channel>/<item> markup (${xml.length} bytes) — GAO's Cloudflare/WAF edge most likely served a challenge or maintenance interstitial instead of the feed. Surfaced as a retryable outage rather than a fabricated empty decision list (which would falsely read as "no recent bid protests"). Retry shortly.`,
|
|
608
|
+
upstreamEndpoint: "gao:rss",
|
|
609
|
+
});
|
|
610
|
+
}
|
|
596
611
|
const rawItems = parseFeed(xml);
|
|
597
612
|
const protests = rawItems.filter(isBidProtest);
|
|
598
613
|
|
|
@@ -653,9 +668,16 @@ export async function gaoProtestLookup(args: {
|
|
|
653
668
|
|
|
654
669
|
// Apply the outcome filter now that we (may) have parsed outcomes, then cap.
|
|
655
670
|
const filtersDropped: string[] = [];
|
|
671
|
+
let outcomeUndetermined = 0;
|
|
656
672
|
if (outcomeFilter) {
|
|
657
673
|
if (enrich) {
|
|
658
674
|
filtersApplied.push("outcome(from decision page)");
|
|
675
|
+
// [filter honesty] A decision whose page could not be enriched has
|
|
676
|
+
// outcome=null — UNDETERMINED, not confirmed "≠ outcomeFilter". Dropping
|
|
677
|
+
// those silently makes returned:0 read as "no <outcome> protests exist"
|
|
678
|
+
// when the truth is "no outcome could be read" (GAO per-decision pages are
|
|
679
|
+
// intermittently WAF-blocked). Count them so it is disclosed, not swallowed.
|
|
680
|
+
outcomeUndetermined = decisions.filter((d) => d.outcome === null).length;
|
|
659
681
|
decisions = decisions.filter((d) => d.outcome === outcomeFilter);
|
|
660
682
|
} else {
|
|
661
683
|
// Can't determine outcome without enrichment → don't silently pretend to.
|
|
@@ -672,6 +694,11 @@ export async function gaoProtestLookup(args: {
|
|
|
672
694
|
`${enrichFailures} decision(s) returned feed-level fields only; per-decision page enrichment failed (agency/outcome/solicitation may be null for those).`,
|
|
673
695
|
);
|
|
674
696
|
}
|
|
697
|
+
if (outcomeFilter && enrich && outcomeUndetermined > 0) {
|
|
698
|
+
notes.push(
|
|
699
|
+
`${outcomeUndetermined} recent decision(s) were EXCLUDED from the outcome='${outcomeFilter}' filter because their decision page could not be read (enrichment failed) — their outcome is UNDETERMINED, not confirmed to differ. Treat a 0 or low result here as "outcome could not be determined for those", NOT "none exist"; retry (GAO per-decision pages are intermittently WAF-blocked) or drop the outcome filter to list all recent decisions.`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
675
702
|
if (!enrich) {
|
|
676
703
|
notes.push(
|
|
677
704
|
"enrich=false: only RSS feed-level fields were returned (protester/title/date/summary). Agency, outcome, solicitation number, and the decision PDF require the per-decision page — call again with enrich=true or a specific bNumber.",
|
package/src/grants.ts
CHANGED
|
@@ -45,13 +45,17 @@ export async function searchGrants(args: {
|
|
|
45
45
|
oppStatuses?: GrantStatus[];
|
|
46
46
|
rows?: number;
|
|
47
47
|
}) {
|
|
48
|
+
// The tool ALWAYS sends oppStatuses — default forecasted|posted when the caller
|
|
49
|
+
// omits it — so it is ALWAYS an applied server-side filter (disclosed below).
|
|
50
|
+
const effOppStatuses = args.oppStatuses ?? ["forecasted", "posted"];
|
|
51
|
+
const statusDefaulted = args.oppStatuses == null;
|
|
48
52
|
const body: Record<string, unknown> = {
|
|
49
53
|
rows: args.rows ?? 10,
|
|
50
54
|
keyword: args.keyword ?? "",
|
|
51
55
|
cfda: args.cfda ?? "",
|
|
52
56
|
agencies: args.agency ?? "",
|
|
53
57
|
oppNum: args.oppNum ?? "",
|
|
54
|
-
oppStatuses:
|
|
58
|
+
oppStatuses: effOppStatuses.join("|"),
|
|
55
59
|
};
|
|
56
60
|
type Resp = {
|
|
57
61
|
errorcode?: number;
|
|
@@ -135,11 +139,20 @@ export async function searchGrants(args: {
|
|
|
135
139
|
if (args.cfda) sent.push("cfda");
|
|
136
140
|
if (args.agency) sent.push("agency");
|
|
137
141
|
if (args.oppNum) sent.push("oppNum");
|
|
138
|
-
|
|
142
|
+
// oppStatuses is ALWAYS sent (default or caller-supplied), so ALWAYS disclose the
|
|
143
|
+
// EFFECTIVE set — omitting it when defaulted made totalAvailable (a status-filtered
|
|
144
|
+
// subset) read as unfiltered (live: keyword=cybersecurity → 235 forecasted|posted vs
|
|
145
|
+
// 2008 all-statuses, a 1773-record closed/archived bucket hidden).
|
|
146
|
+
sent.push(`oppStatuses(${effOppStatuses.join("|") || "all-statuses"})`);
|
|
139
147
|
const notes: string[] = [];
|
|
148
|
+
if (statusDefaulted) {
|
|
149
|
+
notes.push(
|
|
150
|
+
"No oppStatuses supplied — DEFAULTED to forecasted|posted, so CLOSED and ARCHIVED opportunities are EXCLUDED from both the results AND totalAvailable (this total is the open/forecasted subset, not all statuses). To count or return every status, pass oppStatuses including 'closed'/'archived'.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
140
153
|
if (args.agency || args.cfda) {
|
|
141
154
|
notes.push(
|
|
142
|
-
"Grants.gov
|
|
155
|
+
"Grants.gov applies the agency/CFDA filter server-side (live-verified 2026-07-20): a bogus or misspelled value returns 0 results, NOT an error and NOT the unfiltered set — so an unexpectedly EMPTY filtered search most often means the agency/CFDA value is invalid, not that no grants exist. `filtersApplied` reflects that the filter was sent. Verify the agency code / CFDA number if a filtered result is surprisingly empty.",
|
|
143
156
|
);
|
|
144
157
|
}
|
|
145
158
|
// VQ-1 (C82 dogfooding): Grants.gov OR-tokenizes multi-word keywords (matches ANY
|
package/src/gsa-csv.ts
CHANGED
|
@@ -57,10 +57,12 @@
|
|
|
57
57
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
58
58
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
59
59
|
import { createInterface } from "node:readline";
|
|
60
|
+
import { once } from "node:events";
|
|
60
61
|
import { pipeline } from "node:stream/promises";
|
|
61
62
|
import * as os from "node:os";
|
|
62
63
|
import * as path from "node:path";
|
|
63
64
|
import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
|
|
65
|
+
import { driftError } from "./datasource.js";
|
|
64
66
|
import { withMeta } from "./meta.js";
|
|
65
67
|
|
|
66
68
|
// ─── Source + config ─────────────────────────────────────────────
|
|
@@ -309,20 +311,34 @@ function fieldsFromRecord(rec: string[]): NoticeFields {
|
|
|
309
311
|
* loads the whole file into memory — readline yields one physical line at a
|
|
310
312
|
* time and the assembler holds at most one in-progress (quote-spanning) record.
|
|
311
313
|
*/
|
|
312
|
-
async function buildIndexFromFile(
|
|
314
|
+
export async function buildIndexFromFile(
|
|
313
315
|
csvPath: string,
|
|
314
316
|
): Promise<{ notices: Record<string, NoticeFields>; rowCount: number }> {
|
|
315
317
|
const notices: Record<string, NoticeFields> = Object.create(null);
|
|
316
318
|
let headerSeen = false;
|
|
317
319
|
let rowCount = 0;
|
|
318
320
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
crlfDelay: Infinity,
|
|
322
|
-
});
|
|
321
|
+
const stream = createReadStream(csvPath, { encoding: "utf8" });
|
|
322
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
323
323
|
const asm = makeRecordAssembler((rec) => {
|
|
324
324
|
if (!headerSeen) {
|
|
325
325
|
headerSeen = true; // first logical record is the 47-column header
|
|
326
|
+
// [P4] The column contract is POSITIONAL (fixed COL indices). If GSA
|
|
327
|
+
// reorders / inserts / renames a column, trusting the old positions would
|
|
328
|
+
// serve WRONG-POSITION data as authoritative (or, if NoticeId shifts, skip
|
|
329
|
+
// every row → a false "not in the current CSV snapshot"). Neither throws
|
|
330
|
+
// today. Assert the expected header NAME at each COL index before indexing
|
|
331
|
+
// any data row — a mismatch is schema drift, never a fake-empty (mirrors the
|
|
332
|
+
// census-economic.ts header guard). Each COL key IS its expected header name.
|
|
333
|
+
for (const [expectedName, idx] of Object.entries(COL)) {
|
|
334
|
+
const got = (rec[idx] ?? "").trim();
|
|
335
|
+
if (got !== expectedName) {
|
|
336
|
+
throw driftError(
|
|
337
|
+
"gsa:csv",
|
|
338
|
+
`GSA Contract-Opportunities CSV header drift — column ${idx} is ${JSON.stringify(got)}, expected ${JSON.stringify(expectedName)}. The positional column contract changed; refusing to index (a silent column shift would serve wrong-position data as authoritative, or skip every row as a false "not in the current snapshot").`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
326
342
|
return;
|
|
327
343
|
}
|
|
328
344
|
rowCount++;
|
|
@@ -333,8 +349,23 @@ async function buildIndexFromFile(
|
|
|
333
349
|
notices[noticeId.toLowerCase()] = fieldsFromRecord(rec);
|
|
334
350
|
});
|
|
335
351
|
|
|
336
|
-
|
|
337
|
-
|
|
352
|
+
try {
|
|
353
|
+
for await (const line of rl) asm.push(line);
|
|
354
|
+
asm.flush();
|
|
355
|
+
} finally {
|
|
356
|
+
// Release the OS file handle on ALL exit paths (including the header-drift
|
|
357
|
+
// throw, which aborts mid-stream). destroy() is ASYNC — the fd is not freed
|
|
358
|
+
// until the 'close' event — so AWAIT it; otherwise a caller deleting the file
|
|
359
|
+
// immediately after (Windows holds a lock on an open read stream) hits
|
|
360
|
+
// ENOTEMPTY/EBUSY. On the normal EOF path the stream auto-destroys, so guard
|
|
361
|
+
// on `.destroyed` and set the listener BEFORE destroy() to catch its 'close'.
|
|
362
|
+
rl.close();
|
|
363
|
+
if (!stream.destroyed) {
|
|
364
|
+
const closed = once(stream, "close");
|
|
365
|
+
stream.destroy();
|
|
366
|
+
await closed.catch(() => {});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
338
369
|
|
|
339
370
|
return { notices, rowCount };
|
|
340
371
|
}
|
package/src/gsa-perdiem.ts
CHANGED
|
@@ -57,8 +57,26 @@ const GSA_PERDIEM_LABEL = "gsa-perdiem:/travel/perdiem/v2/rates";
|
|
|
57
57
|
const GSA_PERDIEM_SOURCE = (mode: string) =>
|
|
58
58
|
`${GSA_PERDIEM_HOST} via GSA Federal Travel Per-Diem API (${mode})`;
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The US federal fiscal year for a date (ADR-0050). The FY begins Oct 1, so
|
|
62
|
+
* Oct–Dec belong to the NEXT calendar year's FY (e.g. 2025-11 → FY2026) while
|
|
63
|
+
* Jan–Sep stay in the current (e.g. 2026-07 → FY2026). Pure + UTC-based so it is
|
|
64
|
+
* deterministic and timezone-independent (the intra-day rollover instant is
|
|
65
|
+
* immaterial — per-diem rates do not change within a day).
|
|
66
|
+
*/
|
|
67
|
+
export function federalFiscalYear(d: Date): number {
|
|
68
|
+
return d.getUTCMonth() >= 9 ? d.getUTCFullYear() + 1 : d.getUTCFullYear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The default per-diem year when the caller omits `year`: the CURRENT federal
|
|
73
|
+
* fiscal year. GSA publishes rates per FY; a hard-coded default silently serves
|
|
74
|
+
* an EXPIRED vintage once the FY rolls over (the drift this replaces — a no-year
|
|
75
|
+
* lookup must track the live FY, not a frozen year).
|
|
76
|
+
*/
|
|
77
|
+
export function defaultPerdiemYear(): string {
|
|
78
|
+
return String(federalFiscalYear(new Date()));
|
|
79
|
+
}
|
|
62
80
|
|
|
63
81
|
// ─── Validation charclasses (SSRF + "verify the input" honesty) ───
|
|
64
82
|
// Each rides in a single PATH segment (encodeURIComponent-escaped), so these are
|
|
@@ -158,7 +176,7 @@ async function getGsaPerdiem(path: string): Promise<unknown> {
|
|
|
158
176
|
|
|
159
177
|
/**
|
|
160
178
|
* Look up GSA Federal Travel per-diem rates by EITHER (city + state) OR zip, for a
|
|
161
|
-
* given `year` (default
|
|
179
|
+
* given `year` (default: the current U.S. federal fiscal year). Returns flattened rate rows (each outer state/year
|
|
162
180
|
* group × inner city/rate) + honest `_meta`: totalAvailable = the row count (no
|
|
163
181
|
* pagination — P1), lodging/meals as null-never-0 dollars (P3), standardRate/isOconus
|
|
164
182
|
* as real booleans, the months array preserved as-is. The DEMO_KEY rate disclosure
|
|
@@ -168,7 +186,8 @@ export async function perdiemRates(
|
|
|
168
186
|
args: GsaPerdiemRatesArgs,
|
|
169
187
|
): Promise<MetaBundle> {
|
|
170
188
|
const label = GSA_PERDIEM_LABEL;
|
|
171
|
-
const
|
|
189
|
+
const yearWasDefaulted = args.year === undefined;
|
|
190
|
+
const year = args.year ?? defaultPerdiemYear();
|
|
172
191
|
|
|
173
192
|
// ── [INPUT] EITHER (city + state) OR zip — never both, never neither. This is a
|
|
174
193
|
// caller-shape check (0 fetch): an ambiguous or empty lookup is invalid_input,
|
|
@@ -341,6 +360,11 @@ export async function perdiemRates(
|
|
|
341
360
|
|
|
342
361
|
const returned = rows.length;
|
|
343
362
|
const notes: string[] = [RATE_MEANING_NOTE, STANDARD_RATE_NOTE, NO_PAGINATION_NOTE];
|
|
363
|
+
if (yearWasDefaulted) {
|
|
364
|
+
notes.push(
|
|
365
|
+
`No \`year\` was supplied, so it defaulted to the CURRENT U.S. federal fiscal year (FY${year}). GSA per-diem ceilings are set per fiscal year (Oct 1–Sep 30); pass an explicit \`year\` for a prior or upcoming FY.`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
344
368
|
pushKeyNote(notes);
|
|
345
369
|
|
|
346
370
|
return withMeta(
|
package/src/integrity.ts
CHANGED
|
@@ -294,7 +294,22 @@ export async function checkExclusions(args: {
|
|
|
294
294
|
const serverTruncated =
|
|
295
295
|
totalElements !== null && rawResults.length < totalElements;
|
|
296
296
|
const hitCap = totalElements !== null && totalElements > SGS_MAX_RECORDS;
|
|
297
|
-
|
|
297
|
+
// totalAvailable HONESTY: the raw free-text `totalElements` counts every record
|
|
298
|
+
// that merely shares a WORD with the query — NOT the name-gated matches this
|
|
299
|
+
// tool reports. Surfacing it read as "0 of 252 matches, incomplete" for a firm
|
|
300
|
+
// with ZERO real matches (a vetting tool must not overstate match availability).
|
|
301
|
+
// When the result is name-gated (the normal case — a selector is always
|
|
302
|
+
// required), the true count of name-MATCHING exclusions is genuinely unknown
|
|
303
|
+
// from one page of text hits, so report null (never the free-text total); the
|
|
304
|
+
// per-page match count stays in data.matchCount.
|
|
305
|
+
const totalAvailable = postFiltered ? null : totalElements;
|
|
306
|
+
// truncated only when there is genuinely more to see: more server pages, the
|
|
307
|
+
// deep-paging cap, or this page dropped loose text hits (so a name VARIANT
|
|
308
|
+
// could match on a later page). The old `|| postFiltered` forced truncated:true
|
|
309
|
+
// even over a genuinely empty result set (0 text hits) — asserting incompleteness
|
|
310
|
+
// over an empty set, a contradiction. A fully-consumed name-gated result is a
|
|
311
|
+
// complete, honest empty.
|
|
312
|
+
const truncated = serverTruncated || hitCap || (postFiltered && looseTextHits > 0);
|
|
298
313
|
|
|
299
314
|
const notes: string[] = [NOT_PROOF_NOTE];
|
|
300
315
|
notes.push(
|
|
@@ -307,7 +322,7 @@ export async function checkExclusions(args: {
|
|
|
307
322
|
}
|
|
308
323
|
if (postFiltered) {
|
|
309
324
|
notes.push(
|
|
310
|
-
"A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true
|
|
325
|
+
"A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true name-matched total is unknown from one page, so `totalAvailable` is null (the raw free-text hit count is NOT the match count). `matchCount` is this page's name-gated matches; narrow with a more specific `query` or raise `size`.",
|
|
311
326
|
);
|
|
312
327
|
}
|
|
313
328
|
if (hitCap) {
|
|
@@ -333,7 +348,7 @@ export async function checkExclusions(args: {
|
|
|
333
348
|
source: EXCLUSIONS_SOURCE,
|
|
334
349
|
keylessMode: true,
|
|
335
350
|
returned: records.length,
|
|
336
|
-
totalAvailable
|
|
351
|
+
totalAvailable,
|
|
337
352
|
truncated,
|
|
338
353
|
pagination: {
|
|
339
354
|
offset: page * size,
|