diff --git a/docs/plans/state.json b/docs/plans/state.json index dc5ddbf..29fb67b 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -493,6 +493,20 @@ ], "test_status": "pnpm test:run src/audit/phase0BaselineArtifactRetention.test.ts + pnpm typecheck passing" }, + "phase0-live-baseline-summary-limit-integer-validation": { + "status": "completed", + "date": "2026-02-27", + "updated": "2026-02-27", + "summary": "Hardened phase-0 summary limit handling by requiring non-negative integer values for `maxSessions`, `maxChannels`, and `maxSkipReasons` in both capture/summarize CLIs and summary core logic, eliminating silent flooring of fractional limits.", + "files_modified": [ + "src/audit/phase0BaselineSummary.ts", + "src/audit/phase0BaselineSummary.test.ts", + "scripts/capture-phase0-live-baseline.ts", + "scripts/summarize-phase0-baseline.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/audit/phase0BaselineSummary.test.ts + pnpm typecheck passing" + }, "phase0-instrumentation-ticket-checklist": { "status": "completed", "date": "2026-02-25", diff --git a/scripts/capture-phase0-live-baseline.ts b/scripts/capture-phase0-live-baseline.ts index ca10e17..ebda4b0 100644 --- a/scripts/capture-phase0-live-baseline.ts +++ b/scripts/capture-phase0-live-baseline.ts @@ -45,9 +45,9 @@ function usage(): string { ' --sample-out Output JSONL sample path override', ' --summary-json-out Output summary JSON path override', ' --summary-md-out Output summary markdown path override', - ' --max-sessions Limit session rows in output (default: 20)', - ' --max-channels Limit channel rows in output (default: 20)', - ' --max-skip-reasons Limit skip reason rows in output (default: 10)', + ' --max-sessions Limit session rows in output (default: 20)', + ' --max-channels Limit channel rows in output (default: 20)', + ' --max-skip-reasons Limit skip reason rows in output (default: 10)', ].join('\n'); } @@ -125,6 +125,20 @@ function parseOptionalNumber(raw: string | undefined, flag: string): number | un return parsed; } +function parseOptionalInteger(raw: string | undefined, flag: string): number | undefined { + const parsed = parseOptionalNumber(raw, flag); + if (parsed === undefined) { + return undefined; + } + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid ${flag} value "${raw}". Expected an integer.`); + } + if (parsed < 0) { + throw new Error(`${flag} must be greater than or equal to 0.`); + } + return parsed; +} + function parseBackendTargets(raw: string | undefined): Phase0BackendTarget[] | undefined { const values = parseCsv(raw); if (!values) { @@ -237,9 +251,9 @@ async function main(): Promise { const summaryOptions: Phase0BaselineSummaryOptions = { channels, sources, - maxSessions: parseOptionalNumber(values['max-sessions'], '--max-sessions') ?? 20, - maxChannels: parseOptionalNumber(values['max-channels'], '--max-channels') ?? 20, - maxSkipReasons: parseOptionalNumber(values['max-skip-reasons'], '--max-skip-reasons') ?? 10, + maxSessions: parseOptionalInteger(values['max-sessions'], '--max-sessions') ?? 20, + maxChannels: parseOptionalInteger(values['max-channels'], '--max-channels') ?? 20, + maxSkipReasons: parseOptionalInteger(values['max-skip-reasons'], '--max-skip-reasons') ?? 10, }; const backendRouteEvents = backendTargets && backendTargets.length > 0 diff --git a/scripts/summarize-phase0-baseline.ts b/scripts/summarize-phase0-baseline.ts index 9133e4e..ae8f602 100644 --- a/scripts/summarize-phase0-baseline.ts +++ b/scripts/summarize-phase0-baseline.ts @@ -23,9 +23,9 @@ function usage(): string { ' --channel Restrict to channels', ' --sender Restrict to senders', ' --source Restrict to sources', - ' --max-sessions Limit session rows in output (default: 20)', - ' --max-channels Limit channel rows in output (default: 20)', - ' --max-skip-reasons Limit skip reason rows in output (default: 10)', + ' --max-sessions Limit session rows in output (default: 20)', + ' --max-channels Limit channel rows in output (default: 20)', + ' --max-skip-reasons Limit skip reason rows in output (default: 10)', ' --format Output format (default: markdown)', ' --out Write output to file instead of stdout', ].join('\n'); @@ -70,6 +70,20 @@ function parseOptionalNumber(raw: string | undefined, flag: string): number | un return parsed; } +function parseOptionalInteger(raw: string | undefined, flag: string): number | undefined { + const parsed = parseOptionalNumber(raw, flag); + if (parsed === undefined) { + return undefined; + } + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid ${flag} value "${raw}". Expected an integer.`); + } + if (parsed < 0) { + throw new Error(`${flag} must be greater than or equal to 0.`); + } + return parsed; +} + function parseSources(raw: string | undefined): Array<'gateway' | 'channel'> | undefined { const values = parseCsv(raw); if (!values) { @@ -126,9 +140,9 @@ async function main(): Promise { channels: parseCsv(values.channel), senders: parseCsv(values.sender), sources: parseSources(values.source), - maxSessions: parseOptionalNumber(values['max-sessions'], '--max-sessions') ?? 20, - maxChannels: parseOptionalNumber(values['max-channels'], '--max-channels') ?? 20, - maxSkipReasons: parseOptionalNumber(values['max-skip-reasons'], '--max-skip-reasons') ?? 10, + maxSessions: parseOptionalInteger(values['max-sessions'], '--max-sessions') ?? 20, + maxChannels: parseOptionalInteger(values['max-channels'], '--max-channels') ?? 20, + maxSkipReasons: parseOptionalInteger(values['max-skip-reasons'], '--max-skip-reasons') ?? 10, }; const startTime = parseTime(values.since, '--since'); diff --git a/src/audit/phase0BaselineSummary.test.ts b/src/audit/phase0BaselineSummary.test.ts index 3675ec1..67fd796 100644 --- a/src/audit/phase0BaselineSummary.test.ts +++ b/src/audit/phase0BaselineSummary.test.ts @@ -222,6 +222,14 @@ describe('summarizePhase0Baseline', () => { expect(() => summarizePhase0Baseline(events, { maxChannels: -1 })).toThrow('maxChannels'); expect(() => summarizePhase0Baseline(events, { maxSkipReasons: -1 })).toThrow('maxSkipReasons'); }); + + it('rejects non-integer max limits', () => { + const events: AuditEvent[] = []; + + expect(() => summarizePhase0Baseline(events, { maxSessions: 1.5 })).toThrow('maxSessions'); + expect(() => summarizePhase0Baseline(events, { maxChannels: 1.5 })).toThrow('maxChannels'); + expect(() => summarizePhase0Baseline(events, { maxSkipReasons: 1.5 })).toThrow('maxSkipReasons'); + }); }); describe('renderPhase0BaselineMarkdown', () => { diff --git a/src/audit/phase0BaselineSummary.ts b/src/audit/phase0BaselineSummary.ts index 87168d8..ba91f03 100644 --- a/src/audit/phase0BaselineSummary.ts +++ b/src/audit/phase0BaselineSummary.ts @@ -126,10 +126,13 @@ function normalizeLimit(value: number | undefined, optionName: string): number | if (!Number.isFinite(value)) { throw new Error(`${optionName} must be a finite number.`); } + if (!Number.isInteger(value)) { + throw new Error(`${optionName} must be an integer greater than or equal to 0.`); + } if (value < 0) { throw new Error(`${optionName} must be greater than or equal to 0.`); } - return Math.floor(value); + return value; } function percentile(sortedAscending: number[], pct: number): number { @@ -197,7 +200,7 @@ function sortGroups(groups: Map, limit?: number): RunOutcome }); if (typeof limit === 'number' && Number.isFinite(limit)) { - return rows.slice(0, Math.floor(limit)); + return rows.slice(0, limit); } return rows; } @@ -213,7 +216,7 @@ function sortSkipReasons(reasons: Map, limit?: number): Reaction })); if (typeof limit === 'number' && Number.isFinite(limit)) { - return rows.slice(0, Math.floor(limit)); + return rows.slice(0, limit); } return rows; }