fix(audit): require integer phase0 summary row limits
Require non-negative integer maxSessions/maxChannels/maxSkipReasons in summary core and both phase0 summary/capture CLIs to prevent silent flooring of fractional values. Architecture/protocol diagrams reviewed; no flow or API shape changes required.
This commit is contained in:
@@ -493,6 +493,20 @@
|
|||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/audit/phase0BaselineArtifactRetention.test.ts + pnpm typecheck passing"
|
"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": {
|
"phase0-instrumentation-ticket-checklist": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-25",
|
"date": "2026-02-25",
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ function usage(): string {
|
|||||||
' --sample-out <path> Output JSONL sample path override',
|
' --sample-out <path> Output JSONL sample path override',
|
||||||
' --summary-json-out <path> Output summary JSON path override',
|
' --summary-json-out <path> Output summary JSON path override',
|
||||||
' --summary-md-out <path> Output summary markdown path override',
|
' --summary-md-out <path> Output summary markdown path override',
|
||||||
' --max-sessions <number> Limit session rows in output (default: 20)',
|
' --max-sessions <integer> Limit session rows in output (default: 20)',
|
||||||
' --max-channels <number> Limit channel rows in output (default: 20)',
|
' --max-channels <integer> Limit channel rows in output (default: 20)',
|
||||||
' --max-skip-reasons <number> Limit skip reason rows in output (default: 10)',
|
' --max-skip-reasons <integer> Limit skip reason rows in output (default: 10)',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +125,20 @@ function parseOptionalNumber(raw: string | undefined, flag: string): number | un
|
|||||||
return parsed;
|
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 {
|
function parseBackendTargets(raw: string | undefined): Phase0BackendTarget[] | undefined {
|
||||||
const values = parseCsv(raw);
|
const values = parseCsv(raw);
|
||||||
if (!values) {
|
if (!values) {
|
||||||
@@ -237,9 +251,9 @@ async function main(): Promise<void> {
|
|||||||
const summaryOptions: Phase0BaselineSummaryOptions = {
|
const summaryOptions: Phase0BaselineSummaryOptions = {
|
||||||
channels,
|
channels,
|
||||||
sources,
|
sources,
|
||||||
maxSessions: parseOptionalNumber(values['max-sessions'], '--max-sessions') ?? 20,
|
maxSessions: parseOptionalInteger(values['max-sessions'], '--max-sessions') ?? 20,
|
||||||
maxChannels: parseOptionalNumber(values['max-channels'], '--max-channels') ?? 20,
|
maxChannels: parseOptionalInteger(values['max-channels'], '--max-channels') ?? 20,
|
||||||
maxSkipReasons: parseOptionalNumber(values['max-skip-reasons'], '--max-skip-reasons') ?? 10,
|
maxSkipReasons: parseOptionalInteger(values['max-skip-reasons'], '--max-skip-reasons') ?? 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const backendRouteEvents = backendTargets && backendTargets.length > 0
|
const backendRouteEvents = backendTargets && backendTargets.length > 0
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ function usage(): string {
|
|||||||
' --channel <name[,name...]> Restrict to channels',
|
' --channel <name[,name...]> Restrict to channels',
|
||||||
' --sender <id[,id...]> Restrict to senders',
|
' --sender <id[,id...]> Restrict to senders',
|
||||||
' --source <gateway|channel[,..]> Restrict to sources',
|
' --source <gateway|channel[,..]> Restrict to sources',
|
||||||
' --max-sessions <number> Limit session rows in output (default: 20)',
|
' --max-sessions <integer> Limit session rows in output (default: 20)',
|
||||||
' --max-channels <number> Limit channel rows in output (default: 20)',
|
' --max-channels <integer> Limit channel rows in output (default: 20)',
|
||||||
' --max-skip-reasons <number> Limit skip reason rows in output (default: 10)',
|
' --max-skip-reasons <integer> Limit skip reason rows in output (default: 10)',
|
||||||
' --format <markdown|json> Output format (default: markdown)',
|
' --format <markdown|json> Output format (default: markdown)',
|
||||||
' --out <path> Write output to file instead of stdout',
|
' --out <path> Write output to file instead of stdout',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -70,6 +70,20 @@ function parseOptionalNumber(raw: string | undefined, flag: string): number | un
|
|||||||
return parsed;
|
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 {
|
function parseSources(raw: string | undefined): Array<'gateway' | 'channel'> | undefined {
|
||||||
const values = parseCsv(raw);
|
const values = parseCsv(raw);
|
||||||
if (!values) {
|
if (!values) {
|
||||||
@@ -126,9 +140,9 @@ async function main(): Promise<void> {
|
|||||||
channels: parseCsv(values.channel),
|
channels: parseCsv(values.channel),
|
||||||
senders: parseCsv(values.sender),
|
senders: parseCsv(values.sender),
|
||||||
sources: parseSources(values.source),
|
sources: parseSources(values.source),
|
||||||
maxSessions: parseOptionalNumber(values['max-sessions'], '--max-sessions') ?? 20,
|
maxSessions: parseOptionalInteger(values['max-sessions'], '--max-sessions') ?? 20,
|
||||||
maxChannels: parseOptionalNumber(values['max-channels'], '--max-channels') ?? 20,
|
maxChannels: parseOptionalInteger(values['max-channels'], '--max-channels') ?? 20,
|
||||||
maxSkipReasons: parseOptionalNumber(values['max-skip-reasons'], '--max-skip-reasons') ?? 10,
|
maxSkipReasons: parseOptionalInteger(values['max-skip-reasons'], '--max-skip-reasons') ?? 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTime = parseTime(values.since, '--since');
|
const startTime = parseTime(values.since, '--since');
|
||||||
|
|||||||
@@ -222,6 +222,14 @@ describe('summarizePhase0Baseline', () => {
|
|||||||
expect(() => summarizePhase0Baseline(events, { maxChannels: -1 })).toThrow('maxChannels');
|
expect(() => summarizePhase0Baseline(events, { maxChannels: -1 })).toThrow('maxChannels');
|
||||||
expect(() => summarizePhase0Baseline(events, { maxSkipReasons: -1 })).toThrow('maxSkipReasons');
|
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', () => {
|
describe('renderPhase0BaselineMarkdown', () => {
|
||||||
|
|||||||
@@ -126,10 +126,13 @@ function normalizeLimit(value: number | undefined, optionName: string): number |
|
|||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
throw new Error(`${optionName} must be a finite number.`);
|
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) {
|
if (value < 0) {
|
||||||
throw new Error(`${optionName} must be greater than or equal to 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 {
|
function percentile(sortedAscending: number[], pct: number): number {
|
||||||
@@ -197,7 +200,7 @@ function sortGroups(groups: Map<string, RunCounter>, limit?: number): RunOutcome
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (typeof limit === 'number' && Number.isFinite(limit)) {
|
if (typeof limit === 'number' && Number.isFinite(limit)) {
|
||||||
return rows.slice(0, Math.floor(limit));
|
return rows.slice(0, limit);
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -213,7 +216,7 @@ function sortSkipReasons(reasons: Map<string, number>, limit?: number): Reaction
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (typeof limit === 'number' && Number.isFinite(limit)) {
|
if (typeof limit === 'number' && Number.isFinite(limit)) {
|
||||||
return rows.slice(0, Math.floor(limit));
|
return rows.slice(0, limit);
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user