@secureport/core 2.3.0 → 2.3.1

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.
@@ -5,6 +5,7 @@ import type { Snapshot, SnapshotIssue } from '../snapshot.js';
5
5
  import {
6
6
  buildReportModel,
7
7
  type EvidenceVerbosity,
8
+ timelineFor,
8
9
  VERDICT_LABELS,
9
10
  type ReportModel,
10
11
  type ReportOptions,
@@ -166,6 +167,20 @@ export function issueRow(entry: SnapshotIssue): string {
166
167
  return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | \`${cell(location)}\` | ${entry.change.replace('_', ' ')} | ${entry.daysOpen} | ${entry.slaStatus.replace('_', ' ')} |`;
167
168
  }
168
169
 
170
+ /**
171
+ * An issue's remediation chronology, as a single table cell.
172
+ *
173
+ * Exported for the same reason {@link issueRow} is: the streaming renderer
174
+ * formats a row the moment it sees it, and needs the identical rendering of
175
+ * {@link timelineFor}'s events — a second join-and-escape is a second one
176
+ * free to drift.
177
+ */
178
+ export function timelineCell(entry: SnapshotIssue): string {
179
+ return timelineFor(entry)
180
+ .map((event) => cell(event.statement))
181
+ .join(' · ');
182
+ }
183
+
169
184
  /** The Retest body: the difference between two runs, and nothing else. */
170
185
  function retestBody(model: ReportModel): string[] {
171
186
  // `buildReportModel` refuses to build a retest without one.
@@ -228,13 +243,13 @@ function retestBody(model: ReportModel): string[] {
228
243
  lines.push(
229
244
  '## Issue by issue',
230
245
  '',
231
- '| Issue | Severity | Verdict | Location | Open for |',
246
+ '| Issue | Severity | Verdict | Location | Timeline |',
232
247
  '| --- | --- | --- | --- | --- |',
233
248
  ...retest.entries.map((entry) => {
234
249
  const { issue } = entry.issue;
235
250
  const location =
236
251
  issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
237
- return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[entry.verdict]} | \`${cell(location)}\` | ${entry.issue.daysOpen} days |`;
252
+ return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[entry.verdict]} | \`${cell(location)}\` | ${timelineCell(entry.issue)} |`;
238
253
  }),
239
254
  '',
240
255
  );
@@ -343,6 +358,27 @@ function executiveBody(model: ReportModel): string[] {
343
358
  return lines;
344
359
  }
345
360
 
361
+ /**
362
+ * "What this report does not establish" — every kind but the Attestation
363
+ * Letter, which states its limits inline as part of the formal statement
364
+ * itself (`attestationBody`) rather than as a separate section (B109).
365
+ *
366
+ * Placed immediately before the suppressed appendix in every caller, which is
367
+ * also where the streaming Markdown renderer (`stream.ts`) is forced to put
368
+ * it: `describeLimitations` needs the suppressed count and the omitted-below-
369
+ * floor count, both fold-dependent and only known once the issue cursor is
370
+ * drained. The array renderer has no such constraint but uses the same
371
+ * position, so the two cannot drift apart on placement.
372
+ */
373
+ function limitationsSection(model: ReportModel): string[] {
374
+ return [
375
+ '## What this report does not establish',
376
+ '',
377
+ ...model.limitations.map((l) => `- ${l}`),
378
+ '',
379
+ ];
380
+ }
381
+
346
382
  /** The Attestation Letter body: a formal statement, with its limits stated. */
347
383
  function attestationBody(model: ReportModel): string[] {
348
384
  const { snapshot } = model;
@@ -482,12 +518,22 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
482
518
  lines.push(...changeSummary(model), '');
483
519
 
484
520
  if (model.kind === 'exec') {
485
- lines.push(...executiveBody(model), ...suppressedAppendix(snapshot), '');
521
+ lines.push(
522
+ ...executiveBody(model),
523
+ ...limitationsSection(model),
524
+ ...suppressedAppendix(snapshot),
525
+ '',
526
+ );
486
527
  return withContents(lines, model);
487
528
  }
488
529
 
489
530
  if (model.kind === 'retest') {
490
- lines.push(...retestBody(model), ...suppressedAppendix(snapshot), '');
531
+ lines.push(
532
+ ...retestBody(model),
533
+ ...limitationsSection(model),
534
+ ...suppressedAppendix(snapshot),
535
+ '',
536
+ );
491
537
  return withContents(lines, model);
492
538
  }
493
539
 
@@ -524,10 +570,12 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
524
570
  lines.push('');
525
571
  }
526
572
 
527
- // Printed here, beside the section it is about, rather than left to the
528
- // limitations list — which only the Attestation Letter renders (B109). A
529
- // floor that removed issues from this section and said so nowhere the reader
530
- // will look is the quiet form of the over-claim §7 exists to stop.
573
+ // Printed here too, beside the section it is about, even though it also
574
+ // appears in `limitationsSection` below (B109). A reader looking at a short
575
+ // Findings section learns why it is short at the point of confusion rather
576
+ // than having to find the register at the end; the register entry is what
577
+ // keeps the disclosure surviving there once the reader has moved past this
578
+ // section. Two honest statements, not a duplicate to trim.
531
579
  if (model.omitted !== undefined) lines.push(`_${model.omitted.statement}_`, '');
532
580
 
533
581
  if (model.resolved.length > 0) {
@@ -546,6 +594,6 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
546
594
  );
547
595
  }
548
596
 
549
- lines.push(...suppressedAppendix(snapshot), '');
597
+ lines.push(...limitationsSection(model), ...suppressedAppendix(snapshot), '');
550
598
  return withContents(lines, model);
551
599
  }
@@ -277,6 +277,10 @@ export interface TestingBasis {
277
277
  * The issues are still open, still counted and still the target's problem;
278
278
  * only the pages describing them are gone. That distinction is the reason this
279
279
  * is reported at all rather than silently applied.
280
+ *
281
+ * **Never set for a `retest` report.** Its own per-issue table lists every
282
+ * carried issue regardless of the floor (7.9a) — a floor changes nothing a
283
+ * reader sees, so there is nothing to disclose.
280
284
  */
281
285
  export interface OmittedIssues {
282
286
  /** How many outstanding issues were left out. Never `0` — the field is absent instead. */
@@ -545,6 +549,120 @@ export function verdictFor(entry: SnapshotIssue): RetestVerdict {
545
549
  return entry.findings.length > 0 ? 'still_present' : 'not_retested';
546
550
  }
547
551
 
552
+ // What kind of moment a `TimelineEvent` records. Four of the five are things
553
+ // that already happened, in the order they happened. `deadline` is not: it is
554
+ // a standing obligation that can be in the future, and `timelineFor` never
555
+ // sorts it in among the other four — see that function's own doc comment for
556
+ // why. Not exported: nothing outside this module names the union, only the
557
+ // `kind` field it types.
558
+ type TimelineEventKind = 'opened' | 'resolved' | 'returned' | 'last_seen' | 'deadline';
559
+
560
+ /** One moment in an issue's remediation history, worded ready to print. */
561
+ export interface TimelineEvent {
562
+ /** What kind of moment this is. */
563
+ readonly kind: TimelineEventKind;
564
+
565
+ /** When it happened, or — for `deadline` alone — when it is due. */
566
+ readonly at: Date;
567
+
568
+ /**
569
+ * One sentence, ready to print, so no two renderers word it differently.
570
+ *
571
+ * `deadline`'s wording follows {@link SnapshotIssue.slaStatus}, never a
572
+ * second comparison of `at` against `now` — `slaStatus` is already the
573
+ * derived answer to "is this overdue", and a fresh comparison here would be
574
+ * a second one free to disagree.
575
+ */
576
+ readonly statement: string;
577
+ }
578
+
579
+ /**
580
+ * An issue's remediation chronology, derived only from dates already on
581
+ * {@link Issue} — no `issue_events` read, no migration.
582
+ *
583
+ * Exported for the same reason {@link verdictFor} is: it is per-issue and
584
+ * needs nothing but the one entry, which is exactly what the streaming
585
+ * Markdown renderer can offer it a row at a time.
586
+ *
587
+ * **`deadline` is appended last, unconditionally — never sorted in among the
588
+ * other four.** Those four are things that already happened; a remediation
589
+ * deadline is a standing obligation, not an event, and it can be in the past
590
+ * (breached) or the future (within the window). Sorting it by `at` would let
591
+ * a breached deadline land in the middle of the chronology, worded as though
592
+ * it were a fact that occurred between two sightings — this function's
593
+ * version of the mistake `not_retested` exists to stop: reporting something
594
+ * that has not happened as though it had.
595
+ *
596
+ * **No `deadline` event for a resolved issue**, even one whose `slaDueAt` has
597
+ * passed. A deadline is a claim about outstanding remediation; printing
598
+ * "overdue since …" beside a `fixed` verdict would report finished work as
599
+ * work still owed — the same dishonesty from the other direction. None
600
+ * either when {@link Issue.slaDueAt} is `null` (an `advisory` under
601
+ * {@link DEFAULT_SLA_POLICY}), since there is no deadline to state.
602
+ */
603
+ export function timelineFor(entry: SnapshotIssue): readonly TimelineEvent[] {
604
+ const { issue } = entry;
605
+ const events: TimelineEvent[] = [
606
+ {
607
+ kind: 'opened',
608
+ at: issue.firstSeen,
609
+ statement: `opened ${formatIsoDate(issue.firstSeen)} (${entry.daysOpen} days)`,
610
+ },
611
+ ];
612
+
613
+ if (issue.resolvedAt !== undefined) {
614
+ events.push({
615
+ kind: 'resolved',
616
+ at: issue.resolvedAt,
617
+ statement: `resolved ${formatIsoDate(issue.resolvedAt)}`,
618
+ });
619
+ }
620
+ if (issue.reopenedAt !== undefined) {
621
+ events.push({
622
+ kind: 'returned',
623
+ at: issue.reopenedAt,
624
+ statement: `returned ${formatIsoDate(issue.reopenedAt)}`,
625
+ });
626
+ }
627
+
628
+ // Deduplicated by exact timestamp: `reconcile()` sets `firstSeen` and
629
+ // `lastSeen` to the same instant when it creates an issue, and an issue
630
+ // that regressed this run has `reopenedAt === lastSeen` — without this
631
+ // check the same moment would print twice under two different names.
632
+ if (!events.some((e) => e.at.getTime() === issue.lastSeen.getTime())) {
633
+ events.push({
634
+ kind: 'last_seen',
635
+ at: issue.lastSeen,
636
+ statement: `last seen ${formatIsoDate(issue.lastSeen)}`,
637
+ });
638
+ }
639
+
640
+ // Sorted, not pushed in field-declaration order: `lastSeen` is the last
641
+ // time the issue was *detected*, which for a resolved issue predates
642
+ // `resolvedAt` — the date reconciliation stopped seeing it and started
643
+ // counting misses, not the date it stopped existing. Pushing in the order
644
+ // above would print "resolved … last seen …" backwards.
645
+ events.sort((a, b) => a.at.getTime() - b.at.getTime());
646
+
647
+ if (issue.slaDueAt !== null && issue.status !== 'resolved') {
648
+ const due = formatIsoDate(issue.slaDueAt);
649
+ const statement =
650
+ entry.slaStatus === 'breached'
651
+ ? `overdue since ${due}`
652
+ : entry.slaStatus === 'due_soon'
653
+ ? `due ${due} (due soon)`
654
+ : `due ${due}`;
655
+ events.push({ kind: 'deadline', at: issue.slaDueAt, statement });
656
+ }
657
+
658
+ return events;
659
+ }
660
+
661
+ /** A date, printed the same way everywhere in this module. */
662
+ function formatIsoDate(date: Date): string {
663
+ return date.toISOString().slice(0, 10);
664
+ }
665
+
548
666
  /**
549
667
  * Works out what a report cannot establish, from the run rather than a
550
668
  * template.
@@ -784,7 +902,11 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
784
902
  }
785
903
 
786
904
  if (SEVERITY_ORDER.indexOf(severity) > floorAt) {
787
- omittedBelowFloor++;
905
+ // B208: `retest`'s own table lists every carried issue regardless of
906
+ // the floor (7.9a) — nothing is actually left out of the document for
907
+ // this kind, so counting it here would make `limitations` state an
908
+ // omission the very next section contradicts.
909
+ if (options.kind !== 'retest') omittedBelowFloor++;
788
910
  continue;
789
911
  }
790
912
 
@@ -2,7 +2,7 @@ import type { RunSummary } from '../run.js';
2
2
  import { SEVERITY_ORDER } from '../severity.js';
3
3
  import type { Severity } from '../severity.js';
4
4
  import type { SnapshotIssue, SuppressedIssue, Target } from '../snapshot.js';
5
- import { cell, formatDate, issueDetail, issueRow } from './markdown.js';
5
+ import { cell, formatDate, issueDetail, issueRow, timelineCell } from './markdown.js';
6
6
  import {
7
7
  brandingProblem,
8
8
  describeBasis,
@@ -263,8 +263,9 @@ export async function* renderJsonStream(
263
263
  * (title, scope, the New/Still open/Regressed/Resolved/Suppressed table,
264
264
  * which all read straight off `RunSummary`) and moves everything that needs
265
265
  * a full pass — the basis statement, the remediation-deadline count, what a
266
- * severity floor omitted, the retest verdict counts — into a closing
267
- * `## Summary` section, written once both cursors are exhausted.
266
+ * severity floor omitted, the retest verdict counts, and what the report
267
+ * does not establish (B109) — into a closing `## Summary` section, written
268
+ * once both cursors are exhausted.
268
269
  *
269
270
  * **`sections` grouped by severity do not exist here**, for the reason given
270
271
  * in {@link renderJsonStream}: `issues` and `suppressed` are trusted to
@@ -284,6 +285,16 @@ export async function* renderJsonStream(
284
285
  * that one list (the accepted-risk register, ordinarily a minority of the
285
286
  * total) is the one deliberate exception to "never hold more than a row".
286
287
  *
288
+ * **"What this report does not establish" (B109) lands in the closing
289
+ * `## Summary` section, after the Suppressed findings table rather than
290
+ * before it as in `renderMarkdown`.** `describeLimitations` needs
291
+ * `state.suppressedCount`, complete only once the `suppressed` cursor is
292
+ * drained — and that drain, and the table it produces, happen earlier in
293
+ * this stream than the summary does. The array renderer has no such
294
+ * ordering constraint and keeps limitations immediately before the
295
+ * appendix; forcing the same document order here would mean buffering the
296
+ * suppressed rows a second time for no reason `renderJsonStream` needs to.
297
+ *
287
298
  * @param header - The run, its baseline, and the target. Never scales with
288
299
  * issue count.
289
300
  * @param issues - Every issue in scope, most urgent first: open/regressed
@@ -464,7 +475,7 @@ export async function* renderMarkdownStream(
464
475
  yield [
465
476
  '## Issue by issue',
466
477
  '',
467
- '| Issue | Severity | Verdict | Location | Open for |',
478
+ '| Issue | Severity | Verdict | Location | Timeline |',
468
479
  '| --- | --- | --- | --- | --- |',
469
480
  ].join('\n') + '\n';
470
481
  openedFindings = true;
@@ -472,7 +483,7 @@ export async function* renderMarkdownStream(
472
483
  const { issue } = entry;
473
484
  const location =
474
485
  issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
475
- yield `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[verdict as RetestVerdict]} | \`${cell(location)}\` | ${entry.daysOpen} days |\n`;
486
+ yield `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[verdict as RetestVerdict]} | \`${cell(location)}\` | ${timelineCell(entry)} |\n`;
476
487
  continue;
477
488
  }
478
489
 
@@ -615,6 +626,20 @@ export async function* renderMarkdownStream(
615
626
  );
616
627
  }
617
628
  }
629
+
630
+ summary.push(
631
+ '## What this report does not establish',
632
+ '',
633
+ ...describeLimitations(
634
+ header.run.coverage.paths,
635
+ header.run.createdAt,
636
+ basis,
637
+ state.suppressedCount,
638
+ omitted,
639
+ ).map((l) => `- ${l}`),
640
+ '',
641
+ );
642
+
618
643
  yield summary.join('\n') + '\n';
619
644
  }
620
645