diff --git a/src/cli/groups/query.test.ts b/src/cli/groups/query.test.ts index e5e58816..f56d1ce4 100644 --- a/src/cli/groups/query.test.ts +++ b/src/cli/groups/query.test.ts @@ -18,4 +18,20 @@ describe("query CIK options", () => { ); } }); + + it("rejects an explicitly empty --sic on entities and --portal on crowdfunding", () => { + const program = new Command("sec"); + addQueryCommands(program); + const query = program.commands.find((command) => command.name() === "query"); + const cases: Array<[string, string]> = [ + ["entities", "--sic"], + ["crowdfunding", "--portal"], + ]; + for (const [commandName, flag] of cases) { + const command = query!.commands.find((candidate) => candidate.name() === commandName); + const option = command?.options.find((o) => o.long === flag); + expect(option?.parseArg).toBeFunction(); + expect(() => option!.parseArg!("", undefined)).toThrow('"" is not a valid number'); + } + }); }); diff --git a/src/cli/groups/query.ts b/src/cli/groups/query.ts index 91b46bb5..b10a3cbe 100644 --- a/src/cli/groups/query.ts +++ b/src/cli/groups/query.ts @@ -102,7 +102,7 @@ export function addQueryCommands(program: Command): void { .command("entities [search]") .description("Search entities in the database") .option("--cik ", "Filter by CIK", parseIntOption) - .option("--sic ", "Filter by SIC code") + .option("--sic ", "Filter by SIC code", parseIntOption) .option("--state ", "Filter by state") .option("--limit ", "Limit results", parseIntOption, 25) .option("--offset ", "Offset results", parseIntOption, 0) @@ -118,7 +118,7 @@ export function addQueryCommands(program: Command): void { defaults: { search, cik: options.cik as number | undefined, - sic: options.sic ? parseInt(options.sic as string, 10) : undefined, + sic: options.sic as number | undefined, state: options.state as string | undefined, limit, offset, @@ -222,7 +222,7 @@ export function addQueryCommands(program: Command): void { .command("crowdfunding [search]") .description("Search crowdfunding offerings") .option("--cik ", "Filter by CIK", parseIntOption) - .option("--portal ", "Filter by portal") + .option("--portal ", "Filter by portal", parseIntOption) .option("--after ", "Filter after date") .option("--before ", "Filter before date") .option("--limit ", "Limit results", parseIntOption, 25) @@ -238,7 +238,7 @@ export function addQueryCommands(program: Command): void { defaults: { search, cik: options.cik as number | undefined, - portal: options.portal ? parseInt(options.portal as string, 10) : undefined, + portal: options.portal as number | undefined, after: options.after as string | undefined, before: options.before as string | undefined, limit, @@ -309,7 +309,7 @@ export function addQueryCommands(program: Command): void { const format = validateFormat(options.format as string); const summary = await runWorkflowCli([ new QueryRegASummaryTask({ - defaults: { cik: cik ? parseIntOption(cik) : undefined }, + defaults: { cik: cik !== undefined ? parseIntOption(cik) : undefined }, }), ]); @@ -358,10 +358,17 @@ export function addQueryCommands(program: Command): void { const limit = options.limit as number; const offset = options.offset as number; const format = validateFormat(options.format as string); + // Require an all-digit string; parseInt would silently accept + // "123abc" as 123 and produce a plausible-but-wrong CIK. Mirrors + // the same guard on the `xbrl --cik` option below. + const trimmed = cik.trim(); + if (!/^\d+$/.test(trimmed)) { + throw new Error(`Invalid CIK "${cik}". Must be a positive integer.`); + } const result = await runWorkflowCli>([ new QueryFactsTask({ defaults: { - cik: parseInt(cik, 10), + cik: Number(trimmed), name: options.name as string | undefined, taxonomy: options.taxonomy as string | undefined, year: options.year as number | undefined, diff --git a/src/commands/accreditedPortal.ts b/src/commands/accreditedPortal.ts index 5bc5e9fa..850d8ed4 100644 --- a/src/commands/accreditedPortal.ts +++ b/src/commands/accreditedPortal.ts @@ -303,9 +303,10 @@ export function registerAccreditedPortalCommands(program: Command): void { portalId = portal.portal_id; } const result = await backfillFormDAttribution({ portalId }); + const scope = portalId ? `portal ${portalId}` : "all portals"; console.log( `attributed ${result.attributions} filings across ${result.filings} Form D accessions` + - (portalId ? ` (portal ${portalId}, cleared ${result.cleared} prior rows)` : "") + ` (${scope}, cleared ${result.cleared} prior rows)` ); }); diff --git a/src/config/registerModels.ts b/src/config/registerModels.ts index 92376984..1a481d81 100644 --- a/src/config/registerModels.ts +++ b/src/config/registerModels.ts @@ -8,6 +8,7 @@ import { isAbsolute, join } from "node:path"; import type { ModelRecord, ServiceRegistry } from "workglow"; import { getGlobalModelRepository, globalServiceRegistry } from "workglow"; import { SecHftModelDefault, SecModelDefault } from "./Constants"; +import { SecCliConfigurationError } from "./EnvToDI"; /** * Provider discriminators. Mirror the constants the provider packages register @@ -341,7 +342,18 @@ function ggufCacheFileName(uri: string): string { */ function ggufPathConfig(rawPath: string): Record { if (!isRemoteGgufUri(rawPath)) { - return { model_path: isAbsolute(rawPath) ? rawPath : join(ggufModelsDir(), rawPath) }; + const resolved = isAbsolute(rawPath) ? rawPath : join(ggufModelsDir(), rawPath); + // A `gguf:` id must point at a `.gguf` weights file. Absent the guard, a + // typo (or the id shape as a whole) can silently coerce node-llama-cpp + // into loading an arbitrary file off disk — the provider takes what the + // record's `model_path` says. The remote-URI branch stays unchecked: the + // download harness always caches under a synthesized `.gguf` filename. + if (!/\.gguf$/i.test(resolved)) { + throw new SecCliConfigurationError( + `gguf:${rawPath} must resolve to a .gguf file (got ${resolved})` + ); + } + return { model_path: resolved }; } const dir = ggufModelsDir(); return { diff --git a/src/resolver/backfillFormDAttribution.ts b/src/resolver/backfillFormDAttribution.ts index 9a9795c5..f7208e2a 100644 --- a/src/resolver/backfillFormDAttribution.ts +++ b/src/resolver/backfillFormDAttribution.ts @@ -59,12 +59,10 @@ export async function backfillFormDAttribution(options: { const attributionRepo = new FormDPortalAttributionRepo(); const signalRepo = new AccreditedPortalSignalRepo(); - let cleared = 0; - if (options.portalId !== undefined) { - cleared = await attributionRepo.clearPortal(options.portalId); - } else { - await attributionRepo.clearAll(); - } + const cleared = + options.portalId !== undefined + ? await attributionRepo.clearPortal(options.portalId) + : await attributionRepo.clearAll(); const signals = options.portalId !== undefined diff --git a/src/storage/accredited-portal/FormDPortalAttributionRepo.ts b/src/storage/accredited-portal/FormDPortalAttributionRepo.ts index 99e3658f..76600b59 100644 --- a/src/storage/accredited-portal/FormDPortalAttributionRepo.ts +++ b/src/storage/accredited-portal/FormDPortalAttributionRepo.ts @@ -75,8 +75,12 @@ export class FormDPortalAttributionRepo implements FormDPortalAttributionRepoOpt return count; } - /** Clears the whole table ahead of an unscoped recompute. */ - async clearAll(): Promise { - await this.attributionRepository.deleteAll(); + /** Clears the whole table ahead of an unscoped recompute. Returns rows cleared. */ + async clearAll(): Promise { + const count = await this.attributionRepository.count(); + if (count > 0) { + await this.attributionRepository.deleteAll(); + } + return count; } }