@descent-vtt/spec-guard 0.11.0 → 0.12.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.
Files changed (157) hide show
  1. package/CHANGELOG.md +443 -31
  2. package/README.md +403 -41
  3. package/dist/cites.d.ts +192 -0
  4. package/dist/cites.d.ts.map +1 -0
  5. package/dist/cites.js +599 -0
  6. package/dist/cites.js.map +1 -0
  7. package/dist/cli.d.ts +14 -6
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +315 -22
  10. package/dist/cli.js.map +1 -1
  11. package/dist/comments.d.ts +1 -1
  12. package/dist/comments.js +1 -1
  13. package/dist/config.d.ts +3 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +46 -5
  16. package/dist/config.js.map +1 -1
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +38 -14
  19. package/dist/engine.js.map +1 -1
  20. package/dist/glob.d.ts +128 -24
  21. package/dist/glob.d.ts.map +1 -1
  22. package/dist/glob.js +304 -61
  23. package/dist/glob.js.map +1 -1
  24. package/dist/impact.d.ts +179 -0
  25. package/dist/impact.d.ts.map +1 -0
  26. package/dist/impact.js +360 -0
  27. package/dist/impact.js.map +1 -0
  28. package/dist/index.d.ts +15 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +8 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/mcp.d.ts +22 -75
  33. package/dist/mcp.d.ts.map +1 -1
  34. package/dist/mcp.js +206 -370
  35. package/dist/mcp.js.map +1 -1
  36. package/dist/overlay.d.ts +39 -0
  37. package/dist/overlay.d.ts.map +1 -0
  38. package/dist/overlay.js +133 -0
  39. package/dist/overlay.js.map +1 -0
  40. package/dist/parser.d.ts +41 -24
  41. package/dist/parser.d.ts.map +1 -1
  42. package/dist/parser.js +217 -160
  43. package/dist/parser.js.map +1 -1
  44. package/dist/prove.d.ts +46 -0
  45. package/dist/prove.d.ts.map +1 -0
  46. package/dist/prove.js +549 -0
  47. package/dist/prove.js.map +1 -0
  48. package/dist/query.d.ts +18 -1
  49. package/dist/query.d.ts.map +1 -1
  50. package/dist/query.js +9 -3
  51. package/dist/query.js.map +1 -1
  52. package/dist/reporter.d.ts +139 -1
  53. package/dist/reporter.d.ts.map +1 -1
  54. package/dist/reporter.js +593 -21
  55. package/dist/reporter.js.map +1 -1
  56. package/dist/rules.js +3 -3
  57. package/dist/rules.js.map +1 -1
  58. package/dist/runner.d.ts +31 -2
  59. package/dist/runner.d.ts.map +1 -1
  60. package/dist/runner.js +85 -21
  61. package/dist/runner.js.map +1 -1
  62. package/dist/specs.d.ts +27 -1
  63. package/dist/specs.d.ts.map +1 -1
  64. package/dist/specs.js +45 -10
  65. package/dist/specs.js.map +1 -1
  66. package/dist/structure.d.ts.map +1 -1
  67. package/dist/structure.js +7 -6
  68. package/dist/structure.js.map +1 -1
  69. package/dist/types.d.ts +193 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/vendor/spec-core/jsonrpc/index.d.ts +3 -0
  72. package/dist/vendor/spec-core/jsonrpc/index.d.ts.map +1 -0
  73. package/dist/vendor/spec-core/jsonrpc/index.js +2 -0
  74. package/dist/vendor/spec-core/jsonrpc/index.js.map +1 -0
  75. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts +158 -0
  76. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts.map +1 -0
  77. package/dist/vendor/spec-core/jsonrpc/mcp.js +353 -0
  78. package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -0
  79. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +104 -0
  80. package/dist/vendor/spec-core/markdown/frontmatter.d.ts.map +1 -0
  81. package/dist/vendor/spec-core/markdown/frontmatter.js +459 -0
  82. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -0
  83. package/dist/vendor/spec-core/markdown/headings.d.ts +46 -0
  84. package/dist/vendor/spec-core/markdown/headings.d.ts.map +1 -0
  85. package/dist/vendor/spec-core/markdown/headings.js +199 -0
  86. package/dist/vendor/spec-core/markdown/headings.js.map +1 -0
  87. package/dist/vendor/spec-core/markdown/index.d.ts +6 -0
  88. package/dist/vendor/spec-core/markdown/index.d.ts.map +1 -0
  89. package/dist/vendor/spec-core/markdown/index.js +4 -0
  90. package/dist/vendor/spec-core/markdown/index.js.map +1 -0
  91. package/dist/vendor/spec-core/markdown/layout.d.ts +40 -0
  92. package/dist/vendor/spec-core/markdown/layout.d.ts.map +1 -0
  93. package/dist/vendor/spec-core/markdown/layout.js +28 -0
  94. package/dist/vendor/spec-core/markdown/layout.js.map +1 -0
  95. package/dist/vendor/spec-core/markdown/links.d.ts +26 -0
  96. package/dist/vendor/spec-core/markdown/links.d.ts.map +1 -0
  97. package/dist/vendor/spec-core/markdown/links.js +394 -0
  98. package/dist/vendor/spec-core/markdown/links.js.map +1 -0
  99. package/dist/vendor/spec-core/markdown/lists.d.ts +23 -0
  100. package/dist/vendor/spec-core/markdown/lists.d.ts.map +1 -0
  101. package/dist/vendor/spec-core/markdown/lists.js +127 -0
  102. package/dist/vendor/spec-core/markdown/lists.js.map +1 -0
  103. package/dist/vendor/spec-core/markdown/scan.d.ts +37 -0
  104. package/dist/vendor/spec-core/markdown/scan.d.ts.map +1 -0
  105. package/dist/vendor/spec-core/markdown/scan.js +463 -0
  106. package/dist/vendor/spec-core/markdown/scan.js.map +1 -0
  107. package/dist/vendor/spec-core/markdown/syntax.d.ts +76 -0
  108. package/dist/vendor/spec-core/markdown/syntax.d.ts.map +1 -0
  109. package/dist/vendor/spec-core/markdown/syntax.js +181 -0
  110. package/dist/vendor/spec-core/markdown/syntax.js.map +1 -0
  111. package/dist/vendor/spec-core/markdown/tables.d.ts +19 -0
  112. package/dist/vendor/spec-core/markdown/tables.d.ts.map +1 -0
  113. package/dist/vendor/spec-core/markdown/tables.js +86 -0
  114. package/dist/vendor/spec-core/markdown/tables.js.map +1 -0
  115. package/dist/vendor/spec-core/markdown/types.d.ts +291 -0
  116. package/dist/vendor/spec-core/markdown/types.d.ts.map +1 -0
  117. package/dist/vendor/spec-core/markdown/types.js +9 -0
  118. package/dist/vendor/spec-core/markdown/types.js.map +1 -0
  119. package/dist/vendor/spec-core/path/index.d.ts +2 -0
  120. package/dist/vendor/spec-core/path/index.d.ts.map +1 -0
  121. package/dist/vendor/spec-core/path/index.js +2 -0
  122. package/dist/vendor/spec-core/path/index.js.map +1 -0
  123. package/dist/vendor/spec-core/path/posix.d.ts +84 -0
  124. package/dist/vendor/spec-core/path/posix.d.ts.map +1 -0
  125. package/dist/vendor/spec-core/path/posix.js +150 -0
  126. package/dist/vendor/spec-core/path/posix.js.map +1 -0
  127. package/dist/vendor/spec-core/pattern/automaton.d.ts +130 -0
  128. package/dist/vendor/spec-core/pattern/automaton.d.ts.map +1 -0
  129. package/dist/vendor/spec-core/pattern/automaton.js +340 -0
  130. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -0
  131. package/dist/vendor/spec-core/pattern/charset.d.ts +59 -0
  132. package/dist/vendor/spec-core/pattern/charset.d.ts.map +1 -0
  133. package/dist/vendor/spec-core/pattern/charset.js +116 -0
  134. package/dist/vendor/spec-core/pattern/charset.js.map +1 -0
  135. package/dist/vendor/spec-core/pattern/glob.d.ts +122 -0
  136. package/dist/vendor/spec-core/pattern/glob.d.ts.map +1 -0
  137. package/dist/vendor/spec-core/pattern/glob.js +481 -0
  138. package/dist/vendor/spec-core/pattern/glob.js.map +1 -0
  139. package/dist/vendor/spec-core/pattern/index.d.ts +7 -0
  140. package/dist/vendor/spec-core/pattern/index.d.ts.map +1 -0
  141. package/dist/vendor/spec-core/pattern/index.js +4 -0
  142. package/dist/vendor/spec-core/pattern/index.js.map +1 -0
  143. package/dist/vendor/spec-core/pattern/regex.d.ts +68 -0
  144. package/dist/vendor/spec-core/pattern/regex.d.ts.map +1 -0
  145. package/dist/vendor/spec-core/pattern/regex.js +813 -0
  146. package/dist/vendor/spec-core/pattern/regex.js.map +1 -0
  147. package/dist/vendor/spec-core/text/index.d.ts +3 -0
  148. package/dist/vendor/spec-core/text/index.d.ts.map +1 -0
  149. package/dist/vendor/spec-core/text/index.js +2 -0
  150. package/dist/vendor/spec-core/text/index.js.map +1 -0
  151. package/dist/vendor/spec-core/text/lines.d.ts +73 -0
  152. package/dist/vendor/spec-core/text/lines.d.ts.map +1 -0
  153. package/dist/vendor/spec-core/text/lines.js +156 -0
  154. package/dist/vendor/spec-core/text/lines.js.map +1 -0
  155. package/package.json +9 -2
  156. package/src/vendor/spec-core/LICENSE +21 -0
  157. package/src/vendor/spec-core/README.md +8 -0
package/dist/reporter.js CHANGED
@@ -158,6 +158,47 @@ function formatError(error, paint, glyphs) {
158
158
  lines.push(` ${paint(error.raw.split('\n')[0], 'dim')}`);
159
159
  return lines;
160
160
  }
161
+ /**
162
+ * What changed how a document was read, on the line it happened. Yellow, as a
163
+ * note is: nothing failed, but a rule may not have run where it was written.
164
+ */
165
+ function formatSpecWarnings(warnings, paint, glyphs) {
166
+ const lines = (warnings ?? []).map((warning) => `${paint(glyphs.warn, 'yellow')} ${paint(`${warning.location.relativeFile}:${warning.location.line} ${warning.message}`, 'yellow')}`);
167
+ return lines.length === 0 ? [] : [...lines, ''];
168
+ }
169
+ /** How many locations the line about masked directives names before it counts the rest. */
170
+ const MASKED_SHOWN = 5;
171
+ /**
172
+ * The one line that counts the directive-shaped comments no rule was read
173
+ * from, and says where: dim, as a withheld document is, since most are
174
+ * examples shown on purpose. The first few are named, and every one under
175
+ * `--verbose`; JSON names them all.
176
+ */
177
+ function formatMasked(masked, paint, glyphs, verbose) {
178
+ const all = masked ?? [];
179
+ if (all.length === 0)
180
+ return [];
181
+ const shown = verbose ? all : all.slice(0, MASKED_SHOWN);
182
+ const places = shown.map(({ location }) => `${location.relativeFile}:${location.line}`).join(', ');
183
+ const rest = all.length - shown.length;
184
+ const was = all.length === 1 ? 'was' : 'were';
185
+ return [
186
+ paint(`${glyphs.skip} ${countLabel(all.length, 'directive-shaped comment')} in code, raw HTML or front matter ${was} not run: ${places}${rest > 0 ? ` and ${rest} more` : ''}`, 'dim'),
187
+ '',
188
+ ];
189
+ }
190
+ /** Directive-shaped comments as a script reads them, each with what hid it. */
191
+ function maskedJson(masked) {
192
+ return (masked ?? []).map(({ location, inside }) => ({ spec: { file: location.relativeFile, line: location.line, column: location.column }, inside }));
193
+ }
194
+ /** A document's warnings as a script reads them, placed as errors are. */
195
+ function specWarningsJson(warnings) {
196
+ return (warnings ?? []).map((warning) => ({
197
+ spec: { file: warning.location.relativeFile, line: warning.location.line, column: warning.location.column },
198
+ kind: warning.kind,
199
+ message: warning.message,
200
+ }));
201
+ }
161
202
  /**
162
203
  * The line that says which options came from a project's configuration.
163
204
  *
@@ -221,6 +262,7 @@ export function formatReport(report, options, maxSnippets = 5) {
221
262
  }
222
263
  if (report.warnings.length > 0)
223
264
  lines.push('');
265
+ lines.push(...formatSpecWarnings(report.specWarnings, paint, glyphs));
224
266
  // A passing assertion's own warnings - references that could not be resolved,
225
267
  // files no layer constrains, a target that is not there - print whether or not
226
268
  // --verbose was passed. Nobody passes --verbose to a green run, which is when
@@ -264,6 +306,7 @@ export function formatReport(report, options, maxSnippets = 5) {
264
306
  }
265
307
  if (report.inactiveSpecs.length > 0)
266
308
  lines.push('');
309
+ lines.push(...formatMasked(report.maskedDirectives, paint, glyphs, options.verbose));
267
310
  const optionLines = formatOptionLines(report.config, report.exclude);
268
311
  if (optionLines.length > 0)
269
312
  lines.push(...optionLines, '');
@@ -320,9 +363,18 @@ export function formatBaselines(report) {
320
363
  return '# nothing to baseline: no failing assertion had a match to exempt';
321
364
  return lines.join('\n').trimEnd();
322
365
  }
366
+ /**
367
+ * The version of a run's `--json` document. A field removed or renamed moves
368
+ * it; a field added does not. The family contract (spec-core's ADR-0005) asks
369
+ * it of every machine-readable output. The run's document went without one
370
+ * for eleven releases, and got it at 1, since nothing had been removed or
371
+ * renamed in them.
372
+ */
373
+ export const RUN_FORMAT_VERSION = 1;
323
374
  /** Machine-readable output for CI consumers. */
324
375
  export function formatJson(report) {
325
376
  return JSON.stringify({
377
+ formatVersion: RUN_FORMAT_VERSION,
326
378
  ok: report.ok,
327
379
  root: report.root,
328
380
  engine: report.engine,
@@ -367,6 +419,8 @@ export function formatJson(report) {
367
419
  raw: error.raw,
368
420
  })),
369
421
  warnings: report.warnings,
422
+ specWarnings: specWarningsJson(report.specWarnings),
423
+ maskedDirectives: maskedJson(report.maskedDirectives),
370
424
  inactiveSpecs: report.inactiveSpecs,
371
425
  // Always present, as every other list is: an audit records "no project
372
426
  // exclusions" as surely as it records which.
@@ -386,6 +440,24 @@ export function formatJson(report) {
386
440
  function fingerprint(parts) {
387
441
  return createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 32);
388
442
  }
443
+ /**
444
+ * What an assertion is, whatever it found: its document, its kind, what it is
445
+ * about and where it looks. Never a count or a line, so an alert stays one
446
+ * alert while the code it counts changes and the document it sits in grows.
447
+ */
448
+ function assertionIdentity(result) {
449
+ return [
450
+ result.location.relativeFile,
451
+ result.kind,
452
+ // The description only where there is nothing else to tell two
453
+ // assertions apart. A cycle or layer rule names no symbol and no
454
+ // file, so two on one target - `types="ignore"` beside the default -
455
+ // would share an identity and be merged into one alert. Every kind
456
+ // that has a symbol or a file list keeps the fingerprint it had.
457
+ result.symbol ?? (result.files.length > 0 ? result.files.join(',') : result.description),
458
+ result.targets.join(','),
459
+ ];
460
+ }
389
461
  /** SARIF severity for anything spec-guard reports. */
390
462
  const SARIF_LEVEL = 'error';
391
463
  /** One rule per directive kind, plus the one for a directive that will not parse. */
@@ -410,13 +482,21 @@ const SARIF_RULES = [
410
482
  * which GitHub surfaces as run information rather than as an alert.
411
483
  */
412
484
  function inactiveNotifications(report) {
413
- return report.inactiveSpecs.map((spec) => ({
414
- level: 'note',
415
- message: {
416
- text: `${spec.file} is ${spec.label}, so ` +
417
- `${spec.directives === 1 ? 'its 1 assertion was' : `its ${spec.directives} assertions were`} not executed.`,
418
- },
419
- }));
485
+ return [
486
+ ...report.inactiveSpecs.map((spec) => ({
487
+ level: 'note',
488
+ message: {
489
+ text: `${spec.file} is ${spec.label}, so ` +
490
+ `${spec.directives === 1 ? 'its 1 assertion was' : `its ${spec.directives} assertions were`} not executed.`,
491
+ },
492
+ })),
493
+ // A document read differently from how it was written runs different
494
+ // rules, and the page a reviewer reads would otherwise not say so.
495
+ ...(report.specWarnings ?? []).map((warning) => ({
496
+ level: 'warning',
497
+ message: { text: `${warning.location.relativeFile}:${warning.location.line} ${warning.message}` },
498
+ })),
499
+ ];
420
500
  }
421
501
  function sarifLocation(uri, line, column, text) {
422
502
  const location = {
@@ -466,19 +546,7 @@ export function formatSarif(report, options = {}) {
466
546
  // baseline - is anchored on the directive, which is where its fix goes.
467
547
  locations: [matches[0] ?? spec],
468
548
  relatedLocations: matches[0] ? [...matches.slice(1), spec] : [],
469
- partialFingerprints: {
470
- specGuardAssertion: fingerprint([
471
- result.location.relativeFile,
472
- result.kind,
473
- // The description only where there is nothing else to tell two
474
- // assertions apart. A cycle or layer rule names no symbol and no
475
- // file, so two on one target - `types="ignore"` beside the default -
476
- // would share an identity and be merged into one alert. Every kind
477
- // that has a symbol or a file list keeps the fingerprint it had.
478
- result.symbol ?? (result.files.length > 0 ? result.files.join(',') : result.description),
479
- result.targets.join(','),
480
- ]),
481
- },
549
+ partialFingerprints: { specGuardAssertion: fingerprint(assertionIdentity(result)) },
482
550
  });
483
551
  }
484
552
  for (const error of report.errors) {
@@ -502,7 +570,7 @@ export function formatSarif(report, options = {}) {
502
570
  {
503
571
  // Present only when there is something to say. An empty invocations
504
572
  // block is noise in every consumer that renders one.
505
- ...(report.inactiveSpecs.length > 0
573
+ ...(report.inactiveSpecs.length + (report.specWarnings?.length ?? 0) > 0
506
574
  ? {
507
575
  invocations: [
508
576
  { executionSuccessful: report.ok, toolExecutionNotifications: inactiveNotifications(report) },
@@ -526,4 +594,508 @@ export function formatSarif(report, options = {}) {
526
594
  ],
527
595
  }, null, 2);
528
596
  }
597
+ /* -------------------------------------------------------------------- prove */
598
+ /** What each outcome is called where a person reads it. */
599
+ const PROVE_WORDS = {
600
+ killed: 'seen to fail',
601
+ survived: 'passed with a violation in place',
602
+ unprovable: 'no violation could be made',
603
+ };
604
+ /** The claim a probe crossed, as a report names it. */
605
+ const CLAIM_WORDS = { max: 'maximum', min: 'minimum', present: 'presence' };
606
+ function formatProveResult(result, paint, glyphs) {
607
+ const glyph = result.outcome === 'killed'
608
+ ? paint(glyphs.pass, 'green')
609
+ : result.outcome === 'survived'
610
+ ? paint(glyphs.fail, 'red', 'bold')
611
+ : paint(glyphs.skip, 'yellow');
612
+ const lines = [
613
+ `${glyph} ${paint(formatLocation(result), 'bold')} ${paint(`@${result.kind}`, 'magenta')} ${PROVE_WORDS[result.outcome]}`,
614
+ ` ${result.description}`,
615
+ ];
616
+ for (const probe of result.probes) {
617
+ const verdict = probe.outcome === 'killed' ? paint('and it failed', 'green') : paint('and it still passed', 'red', 'bold');
618
+ lines.push(` ${CLAIM_WORDS[probe.claim]}: ${probe.violation}, ${verdict}: ${paint(probe.message, 'dim')}`);
619
+ }
620
+ if (result.unprovable !== undefined)
621
+ lines.push(` ${paint(result.unprovable, 'yellow')}`);
622
+ if (result.reason !== undefined)
623
+ lines.push(` ${paint(`reason: ${result.reason}`, 'dim')}`);
624
+ return lines;
625
+ }
626
+ /**
627
+ * Renders what `spec-guard prove` found, for a person.
628
+ *
629
+ * A rule that survived is the finding, and is printed whatever the options;
630
+ * so is one no violation could be made for, since a rule nobody can show
631
+ * failing is not yet one anybody should trust. A rule seen to fail is printed
632
+ * under `--verbose`, with the violation that failed it. Survivors come first.
633
+ */
634
+ export function formatProve(report, options) {
635
+ const paint = createPainter(options.color);
636
+ const glyphs = symbols(options.ascii ?? false);
637
+ const { summary } = report;
638
+ const lines = [
639
+ `${paint('spec-guard prove', 'bold', 'blue')} ${paint(`${countLabel(summary.specs, 'spec')} · ${countLabel(summary.total, 'rule')}`, 'dim')}`,
640
+ '',
641
+ ];
642
+ for (const outcome of ['survived', 'unprovable', 'killed']) {
643
+ if (outcome === 'killed' && !options.verbose)
644
+ continue;
645
+ for (const result of report.results.filter((entry) => entry.outcome === outcome)) {
646
+ lines.push(...formatProveResult(result, paint, glyphs), '');
647
+ }
648
+ }
649
+ for (const error of report.errors)
650
+ lines.push(...formatError(error, paint, glyphs), '');
651
+ lines.push(...formatSpecWarnings(report.specWarnings, paint, glyphs));
652
+ for (const spec of report.inactiveSpecs) {
653
+ lines.push(paint(`${glyphs.skip} ${spec.file} is ${spec.label} - ${countLabel(spec.directives, 'rule')} not proved`, 'dim'));
654
+ }
655
+ if (report.inactiveSpecs.length > 0)
656
+ lines.push('');
657
+ lines.push(...formatMasked(report.maskedDirectives, paint, glyphs, options.verbose));
658
+ const optionLines = formatOptionLines(report.config, report.exclude);
659
+ if (optionLines.length > 0)
660
+ lines.push(...optionLines, '');
661
+ const parts = [
662
+ paint(`${summary.killed} seen to fail`, 'green'),
663
+ summary.survived > 0 ? paint(`${summary.survived} survived`, 'red', 'bold') : null,
664
+ summary.unprovable > 0 ? paint(`${summary.unprovable} unprovable`, 'yellow') : null,
665
+ report.errors.length > 0 ? paint(`${report.errors.length} invalid`, 'yellow') : null,
666
+ summary.inactive > 0 ? paint(`${summary.inactive} not in force`, 'dim') : null,
667
+ paint(formatDuration(report.durationMs), 'dim'),
668
+ ].filter((part) => part !== null);
669
+ lines.push(parts.join(paint(' · ', 'dim')));
670
+ if (summary.survived > 0) {
671
+ const them = summary.survived === 1 ? 'itself' : 'themselves';
672
+ lines.push(paint(`${glyphs.fail} ${countLabel(summary.survived, 'rule')} passed with a violation of ${them} in place`, 'red', 'bold'));
673
+ }
674
+ else if (summary.total === 0) {
675
+ lines.push(paint(`${glyphs.warn} no rule was proved, so nothing was shown`, 'yellow'));
676
+ }
677
+ else if (summary.killed === summary.total) {
678
+ lines.push(paint(`${glyphs.pass} every rule in force was seen to fail`, 'green'));
679
+ }
680
+ return lines.join('\n');
681
+ }
682
+ /**
683
+ * The version of `spec-guard prove --json`'s document. A field removed or
684
+ * renamed moves it; a field added does not.
685
+ */
686
+ export const PROVE_FORMAT_VERSION = 1;
687
+ /** What `spec-guard prove` found, for a script. */
688
+ export function formatProveJson(report) {
689
+ return JSON.stringify({
690
+ formatVersion: PROVE_FORMAT_VERSION,
691
+ ok: report.ok,
692
+ root: report.root,
693
+ durationMs: Math.round(report.durationMs * 1000) / 1000,
694
+ summary: report.summary,
695
+ specFiles: report.specFiles,
696
+ results: report.results.map((result) => ({
697
+ outcome: result.outcome,
698
+ kind: result.kind,
699
+ spec: { file: result.location.relativeFile, line: result.location.line, column: result.location.column },
700
+ description: result.description,
701
+ reason: result.reason,
702
+ unprovable: result.unprovable,
703
+ probes: result.probes,
704
+ durationMs: Math.round(result.durationMs * 1000) / 1000,
705
+ })),
706
+ errors: report.errors.map((error) => ({
707
+ spec: { file: error.location.relativeFile, line: error.location.line, column: error.location.column },
708
+ message: error.message,
709
+ raw: error.raw,
710
+ })),
711
+ specWarnings: specWarningsJson(report.specWarnings),
712
+ maskedDirectives: maskedJson(report.maskedDirectives),
713
+ inactiveSpecs: report.inactiveSpecs,
714
+ exclude: report.exclude,
715
+ config: report.config,
716
+ }, null, 2);
717
+ }
718
+ /** One rule per finding `spec-guard prove` makes. */
719
+ const PROVE_RULES = [
720
+ { id: 'rule-cannot-fail', text: 'A rule that passed with a violation of itself in place.' },
721
+ { id: 'rule-unprovable', text: 'A rule no violation could be made for.' },
722
+ { id: 'invalid-directive', text: 'A directive that could not be parsed, so nothing was checked.' },
723
+ ];
724
+ /**
725
+ * What `spec-guard prove` found, for code scanning.
726
+ *
727
+ * A rule that survived is an error on its directive, which is where the fix
728
+ * goes: a target that reaches the code, a glob that names its kind, a bound
729
+ * that can be crossed. A rule no violation could be made for is a note there,
730
+ * which GitHub shows without failing anything. A rule seen to fail is not a
731
+ * finding and is not a result.
732
+ */
733
+ export function formatProveSarif(report, options = {}) {
734
+ const results = report.results
735
+ .filter((result) => result.outcome !== 'killed')
736
+ .map((result) => {
737
+ const survived = result.outcome === 'survived';
738
+ const detail = survived
739
+ ? result.probes
740
+ .filter((probe) => probe.outcome === 'survived')
741
+ .map((probe) => `${probe.violation}, and it still passed: ${probe.message}`)
742
+ .join('; ')
743
+ : result.unprovable;
744
+ return {
745
+ ruleId: survived ? 'rule-cannot-fail' : 'rule-unprovable',
746
+ level: survived ? SARIF_LEVEL : 'note',
747
+ message: { text: `${result.description}: ${detail}` },
748
+ locations: [sarifLocation(result.location.relativeFile, result.location.line, result.location.column)],
749
+ partialFingerprints: {
750
+ specGuardAssertion: fingerprint([result.location.relativeFile, result.kind, result.description]),
751
+ },
752
+ };
753
+ });
754
+ for (const error of report.errors) {
755
+ results.push({
756
+ ruleId: 'invalid-directive',
757
+ level: SARIF_LEVEL,
758
+ message: { text: error.message },
759
+ locations: [sarifLocation(error.location.relativeFile, error.location.line, error.location.column)],
760
+ partialFingerprints: { specGuardAssertion: fingerprint([error.location.relativeFile, 'invalid', error.message]) },
761
+ });
762
+ }
763
+ return JSON.stringify({
764
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
765
+ version: '2.1.0',
766
+ runs: [
767
+ {
768
+ tool: {
769
+ driver: {
770
+ name: 'spec-guard prove',
771
+ informationUri: 'https://github.com/DescentVTT/spec-guard',
772
+ version: options.version ?? '0.0.0',
773
+ rules: PROVE_RULES.map((rule) => ({ id: rule.id, name: rule.id, shortDescription: { text: rule.text } })),
774
+ },
775
+ },
776
+ results,
777
+ },
778
+ ],
779
+ }, null, 2);
780
+ }
781
+ /**
782
+ * A finding as GitLab Code Quality reads it.
783
+ *
784
+ * The fingerprint is what GitLab compares between a merge request and its
785
+ * target to tell a finding that is new from one that was already there. It is
786
+ * the SHA-256 of the annotation's identity - the rule, the document or file,
787
+ * the subject - and never of its message: a message holds the count of
788
+ * matches and the directive's line, so one more match, or a line added above
789
+ * the directive, made every open issue look fixed and a new one appear. Two
790
+ * findings with one identity, one ghost cited on two lines, are told apart by
791
+ * the order they come in, each a finding of its own.
792
+ */
793
+ export function formatGitlab(annotations) {
794
+ const seen = new Map();
795
+ const issues = [];
796
+ for (const annotation of annotations) {
797
+ const identity = annotation.identity.join('\u0000');
798
+ const repeat = seen.get(identity) ?? 0;
799
+ seen.set(identity, repeat + 1);
800
+ const fingerprint = createHash('sha256')
801
+ .update(repeat === 0 ? identity : `${identity}\u0000${repeat}`)
802
+ .digest('hex');
803
+ issues.push({
804
+ description: annotation.message,
805
+ check_name: annotation.rule,
806
+ fingerprint,
807
+ severity: annotation.severity,
808
+ location: { path: annotation.file, lines: { begin: Math.max(annotation.line, 1) } },
809
+ });
810
+ }
811
+ return JSON.stringify(issues, null, 2);
812
+ }
813
+ /** A workflow command's message: `%`, and the line breaks that would end the command. */
814
+ function escapeData(text) {
815
+ return text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
816
+ }
817
+ /** A workflow command's property: as a message, and the `:` and `,` that would end the property. */
818
+ function escapeProperty(text) {
819
+ return escapeData(text).replace(/:/g, '%3A').replace(/,/g, '%2C');
820
+ }
821
+ /**
822
+ * Findings as GitHub Actions workflow commands, one line each, which a job's log
823
+ * turns into annotations on the pull request's diff without an upload step.
824
+ * Nothing at all when there is nothing to annotate.
825
+ */
826
+ export function formatGithub(annotations) {
827
+ return annotations
828
+ .map((annotation) => `::${annotation.level} file=${escapeProperty(annotation.file)},line=${Math.max(annotation.line, 1)},title=${escapeProperty(annotation.rule)}::${escapeData(annotation.message)}`)
829
+ .join('\n');
830
+ }
831
+ /** Where a document that is not in force is named, in a format with no room for a note beside the findings. */
832
+ function inactiveAnnotations(inactive, what, done) {
833
+ return inactive.map((spec) => ({
834
+ rule: 'not-in-force',
835
+ identity: ['not-in-force', spec.file],
836
+ level: 'notice',
837
+ severity: 'info',
838
+ file: spec.file,
839
+ line: 1,
840
+ message: `${spec.file} is ${spec.label}, so ${spec.directives === 1 ? `its 1 ${what} was` : `its ${spec.directives} ${what}s were`} not ${done}.`,
841
+ }));
842
+ }
843
+ /** What changed how a document was read, on the line it happened: a warning, which fails nothing. */
844
+ function warningAnnotations(warnings) {
845
+ return (warnings ?? []).map((warning) => ({
846
+ rule: 'spec-warning',
847
+ identity: ['spec-warning', warning.location.relativeFile, warning.kind],
848
+ level: 'warning',
849
+ severity: 'minor',
850
+ file: warning.location.relativeFile,
851
+ line: warning.location.line,
852
+ message: warning.message,
853
+ }));
854
+ }
855
+ /** A directive that could not be read: nothing it states was checked. */
856
+ function errorAnnotations(errors) {
857
+ return errors.map((error) => ({
858
+ rule: 'invalid-directive',
859
+ identity: ['invalid-directive', error.location.relativeFile, error.message],
860
+ level: 'error',
861
+ severity: 'major',
862
+ file: error.location.relativeFile,
863
+ line: error.location.line,
864
+ message: error.message,
865
+ }));
866
+ }
867
+ /**
868
+ * A run's findings, placed as SARIF places them.
869
+ *
870
+ * Each failing assertion is one finding, on its first offending line, since the
871
+ * thing that broke is the rule; one with no match - a missing target, an empty
872
+ * scope, a directory missing an entry - sits on its directive. `critical`,
873
+ * because it fails the run. A directive that could not be read fails the run
874
+ * too, and is `major`: it is the rule, not the code, that needs the fix. A
875
+ * document not in force is `info`, named rather than left out, for ADR-0010's
876
+ * reason.
877
+ */
878
+ export function runAnnotations(report) {
879
+ const failed = report.results
880
+ .filter((result) => !result.ok)
881
+ .map((result) => {
882
+ const match = result.claim === 'required' ? undefined : result.matches[0];
883
+ return {
884
+ rule: result.kind,
885
+ identity: assertionIdentity(result),
886
+ level: 'error',
887
+ severity: 'critical',
888
+ file: match?.file ?? result.location.relativeFile,
889
+ line: match?.line ?? result.location.line,
890
+ message: `${result.description}: ${result.message} (${result.location.relativeFile}:${result.location.line})`,
891
+ };
892
+ });
893
+ return [
894
+ ...failed,
895
+ ...errorAnnotations(report.errors),
896
+ ...warningAnnotations(report.specWarnings),
897
+ ...inactiveAnnotations(report.inactiveSpecs, 'assertion', 'executed'),
898
+ ];
899
+ }
900
+ /**
901
+ * What `spec-guard prove` found, placed on each rule's directive.
902
+ *
903
+ * A rule that survived is `critical`: it passed with a violation of itself in
904
+ * place, and fails the proof. One no violation could be made for is `minor`, a
905
+ * notice, as it is a note in SARIF. A rule seen to fail is not a finding.
906
+ */
907
+ export function proveAnnotations(report) {
908
+ const results = report.results
909
+ .filter((result) => result.outcome !== 'killed')
910
+ .map((result) => {
911
+ const survived = result.outcome === 'survived';
912
+ const detail = survived
913
+ ? result.probes
914
+ .filter((probe) => probe.outcome === 'survived')
915
+ .map((probe) => `${probe.violation}, and it still passed: ${probe.message}`)
916
+ .join('; ')
917
+ : result.unprovable;
918
+ return {
919
+ rule: survived ? 'rule-cannot-fail' : 'rule-unprovable',
920
+ identity: [survived ? 'rule-cannot-fail' : 'rule-unprovable', result.location.relativeFile, result.kind, result.description],
921
+ level: survived ? 'error' : 'notice',
922
+ severity: survived ? 'critical' : 'minor',
923
+ file: result.location.relativeFile,
924
+ line: result.location.line,
925
+ message: `${result.description}: ${detail}`,
926
+ };
927
+ });
928
+ return [
929
+ ...results,
930
+ ...errorAnnotations(report.errors),
931
+ ...warningAnnotations(report.specWarnings),
932
+ ...inactiveAnnotations(report.inactiveSpecs, 'rule', 'proved'),
933
+ ];
934
+ }
935
+ /* -------------------------------------------------------------------- cites */
936
+ /** How a family is introduced: its id, its documents, and where it came from. */
937
+ function familyLine(family) {
938
+ const from = family.source === 'derived' ? ", read off the specs' titles" : '';
939
+ return `${family.id} (${countLabel(family.documents, 'document')} matching ${family.files}${from})`;
940
+ }
941
+ /**
942
+ * Renders what `spec-guard cites` found, for a person.
943
+ *
944
+ * Each ghost and each stale citation is printed with the hint that says what
945
+ * to do. So are the files whose comments could not all be read, and the files
946
+ * no comment syntax is known for, counted by extension: a citation in either
947
+ * was not looked at, and a report that stayed quiet about them would read as
948
+ * though it had been.
949
+ */
950
+ export function formatCites(report, options) {
951
+ const paint = createPainter(options.color);
952
+ const glyphs = symbols(options.ascii ?? false);
953
+ const { summary } = report;
954
+ const families = report.families.length === 0 ? 'no families' : report.families.map(familyLine).join('; ');
955
+ const lines = [`${paint('spec-guard cites', 'bold', 'blue')} ${paint(families, 'dim')}`, ''];
956
+ for (const finding of report.findings) {
957
+ const glyph = finding.severity === 'error' ? paint(glyphs.fail, 'red', 'bold') : paint(glyphs.warn, 'yellow', 'bold');
958
+ lines.push(`${glyph} ${finding.message} ${paint(finding.rule, 'dim')}`, ` ${paint(`hint: ${finding.hint}`, 'dim')}`);
959
+ }
960
+ if (report.findings.length > 0)
961
+ lines.push('');
962
+ if (report.gaps.length > 0) {
963
+ const them = report.gaps.length === 1 ? 'it' : 'them';
964
+ lines.push(paint(`${glyphs.skip} ${countLabel(report.gaps.length, 'file')} could not be read in full, so a citation in ${them} may have been missed:`, 'yellow'));
965
+ for (const gap of report.gaps)
966
+ lines.push(` ${gap.file} ${gap.detail}`);
967
+ lines.push('');
968
+ }
969
+ if (summary.unclassified > 0) {
970
+ const kinds = report.unclassified.map(({ extension, files }) => `${extension} ${files}`).join(', ');
971
+ lines.push(paint(`${glyphs.skip} ${countLabel(summary.unclassified, 'file')} in no language spec-guard knows the comments of, and not read: ${kinds}`, 'dim'), '');
972
+ }
973
+ if (summary.qualified > 0) {
974
+ const verb = summary.qualified === 1 ? 'names' : 'name';
975
+ const were = summary.qualified === 1 ? 'was' : 'were';
976
+ lines.push(paint(`${glyphs.skip} ${countLabel(summary.qualified, 'id')} ${verb} another project's document, as spec-core's ADR-0005 does, and ${were} not checked`, 'dim'), '');
977
+ }
978
+ for (const note of report.notes)
979
+ lines.push(paint(`${glyphs.skip} ${note}`, 'dim'));
980
+ if (report.notes.length > 0)
981
+ lines.push('');
982
+ const optionLines = formatOptionLines(report.config, report.exclude);
983
+ if (optionLines.length > 0)
984
+ lines.push(...optionLines, '');
985
+ const parts = [
986
+ `${countLabel(summary.citations, 'citation')} in ${countLabel(summary.files, 'file')}`,
987
+ summary.ghosts > 0 ? paint(countLabel(summary.ghosts, 'ghost'), 'red', 'bold') : null,
988
+ summary.stale > 0 ? paint(`${summary.stale} stale`, 'yellow') : null,
989
+ paint(formatDuration(report.durationMs), 'dim'),
990
+ ].filter((part) => part !== null);
991
+ lines.push(parts.join(paint(' · ', 'dim')));
992
+ const names = (count) => `${countLabel(count, 'citation')} ${count === 1 ? 'names' : 'name'}`;
993
+ if (summary.ghosts > 0) {
994
+ lines.push(paint(`${glyphs.fail} ${names(summary.ghosts)} a document that does not exist`, 'red', 'bold'));
995
+ }
996
+ else if (report.families.length === 0) {
997
+ lines.push(paint(`${glyphs.warn} no citation was looked for, so nothing was checked`, 'yellow'));
998
+ }
999
+ else if (summary.files === 0) {
1000
+ lines.push(paint(`${glyphs.warn} no source file was read, so nothing was checked`, 'yellow'));
1001
+ }
1002
+ else if (summary.stale > 0) {
1003
+ lines.push(paint(`${report.ok ? glyphs.warn : glyphs.fail} ${names(summary.stale)} a document no longer in force`, 'yellow', 'bold'));
1004
+ }
1005
+ else {
1006
+ lines.push(paint(`${glyphs.pass} every citation names a document in force`, 'green'));
1007
+ }
1008
+ return lines.join('\n');
1009
+ }
1010
+ /**
1011
+ * The version of `spec-guard cites --json`'s document. A field removed or
1012
+ * renamed moves it; a field added does not.
1013
+ */
1014
+ export const CITES_FORMAT_VERSION = 1;
1015
+ /** What `spec-guard cites` found, for a script. */
1016
+ export function formatCitesJson(report) {
1017
+ return JSON.stringify({
1018
+ formatVersion: CITES_FORMAT_VERSION,
1019
+ ok: report.ok,
1020
+ root: report.root,
1021
+ durationMs: Math.round(report.durationMs * 1000) / 1000,
1022
+ families: report.families,
1023
+ summary: report.summary,
1024
+ findings: report.findings,
1025
+ gaps: report.gaps,
1026
+ unclassified: report.unclassified,
1027
+ notes: report.notes,
1028
+ exclude: report.exclude,
1029
+ config: report.config,
1030
+ }, null, 2);
1031
+ }
1032
+ const CITE_SARIF_RULES = [
1033
+ { id: 'ghost-citation', text: 'A comment cites a document that does not exist.' },
1034
+ { id: 'stale-citation', text: 'A comment cites a document that is no longer in force.' },
1035
+ ];
1036
+ /**
1037
+ * What `spec-guard cites` found, for code scanning: each finding on the line
1038
+ * of the comment, with its hint beneath. The fingerprint is the file, the rule
1039
+ * and the id as written, which survives the comment moving. A file whose
1040
+ * comments could not all be read, and every note, is a notification.
1041
+ */
1042
+ export function formatCitesSarif(report, options = {}) {
1043
+ const notifications = [
1044
+ ...report.gaps.map((gap) => ({ level: 'warning', message: { text: `${gap.file} ${gap.detail}` } })),
1045
+ ...report.notes.map((note) => ({ level: 'note', message: { text: note } })),
1046
+ ];
1047
+ return JSON.stringify({
1048
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
1049
+ version: '2.1.0',
1050
+ runs: [
1051
+ {
1052
+ ...(notifications.length > 0 ? { invocations: [{ executionSuccessful: report.ok, toolExecutionNotifications: notifications }] } : {}),
1053
+ tool: {
1054
+ driver: {
1055
+ name: 'spec-guard cites',
1056
+ informationUri: 'https://github.com/DescentVTT/spec-guard',
1057
+ version: options.version ?? '0.0.0',
1058
+ rules: CITE_SARIF_RULES.map((rule) => ({ id: rule.id, name: rule.id, shortDescription: { text: rule.text } })),
1059
+ },
1060
+ },
1061
+ results: report.findings.map((finding) => ({
1062
+ ruleId: finding.rule,
1063
+ level: finding.severity,
1064
+ message: { text: `${finding.message}\n${finding.hint}` },
1065
+ locations: [sarifLocation(finding.file, finding.line, finding.column)],
1066
+ partialFingerprints: { specGuardCitation: fingerprint([finding.file, finding.rule, finding.cited]) },
1067
+ })),
1068
+ },
1069
+ ],
1070
+ }, null, 2);
1071
+ }
1072
+ /**
1073
+ * What `spec-guard cites` found, placed for GitLab and GitHub.
1074
+ *
1075
+ * A ghost is `critical`, an error. A stale citation is a warning and `minor`,
1076
+ * and under `--strict`, where it fails the check, an error and `critical`. A
1077
+ * file whose comments could not all be read is a notice on its first line.
1078
+ */
1079
+ export function citesAnnotations(report) {
1080
+ return [
1081
+ ...report.findings.map((finding) => ({
1082
+ rule: finding.rule,
1083
+ identity: [finding.rule, finding.file, finding.family, finding.cited],
1084
+ level: finding.severity === 'error' ? 'error' : 'warning',
1085
+ severity: finding.severity === 'error' ? 'critical' : 'minor',
1086
+ file: finding.file,
1087
+ line: finding.line,
1088
+ message: `${finding.message}. ${finding.hint}`,
1089
+ })),
1090
+ ...report.gaps.map((gap) => ({
1091
+ rule: 'unread-comments',
1092
+ identity: ['unread-comments', gap.file, gap.reason],
1093
+ level: 'notice',
1094
+ severity: 'info',
1095
+ file: gap.file,
1096
+ line: 1,
1097
+ message: `${gap.file} ${gap.detail}, so a citation in it may have been missed`,
1098
+ })),
1099
+ ];
1100
+ }
529
1101
  //# sourceMappingURL=reporter.js.map