1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.modules.sarif;
7   
8   import org.schemastore.json.sarif.x210.Artifact;
9   import org.schemastore.json.sarif.x210.ArtifactLocation;
10  import org.schemastore.json.sarif.x210.Invocation;
11  import org.schemastore.json.sarif.x210.LetTimingEntry;
12  import org.schemastore.json.sarif.x210.Location;
13  import org.schemastore.json.sarif.x210.LogicalLocation;
14  import org.schemastore.json.sarif.x210.Message;
15  import org.schemastore.json.sarif.x210.MultiformatMessageString;
16  import org.schemastore.json.sarif.x210.Notification;
17  import org.schemastore.json.sarif.x210.PhysicalLocation;
18  import org.schemastore.json.sarif.x210.PropertyBag;
19  import org.schemastore.json.sarif.x210.Region;
20  import org.schemastore.json.sarif.x210.ReportingDescriptor;
21  import org.schemastore.json.sarif.x210.Result;
22  import org.schemastore.json.sarif.x210.Run;
23  import org.schemastore.json.sarif.x210.Sarif;
24  import org.schemastore.json.sarif.x210.SarifModule;
25  import org.schemastore.json.sarif.x210.TimingData;
26  import org.schemastore.json.sarif.x210.Tool;
27  import org.schemastore.json.sarif.x210.ToolComponent;
28  
29  import java.io.IOException;
30  import java.io.StringWriter;
31  import java.math.BigDecimal;
32  import java.math.BigInteger;
33  import java.math.RoundingMode;
34  import java.net.URI;
35  import java.net.URISyntaxException;
36  import java.nio.file.Path;
37  import java.nio.file.StandardOpenOption;
38  import java.time.Instant;
39  import java.time.ZoneOffset;
40  import java.time.ZonedDateTime;
41  import java.util.ArrayList;
42  import java.util.Collection;
43  import java.util.LinkedHashMap;
44  import java.util.LinkedList;
45  import java.util.List;
46  import java.util.Map;
47  import java.util.Set;
48  import java.util.UUID;
49  import java.util.concurrent.ConcurrentHashMap;
50  import java.util.concurrent.atomic.AtomicInteger;
51  
52  import dev.metaschema.core.datatype.markup.MarkupLine;
53  import dev.metaschema.core.datatype.markup.MarkupMultiline;
54  import dev.metaschema.core.metapath.item.node.INodeItem;
55  import dev.metaschema.core.model.IAttributable;
56  import dev.metaschema.core.model.IResourceLocation;
57  import dev.metaschema.core.model.MetaschemaException;
58  import dev.metaschema.core.model.constraint.ConstraintValidationFinding;
59  import dev.metaschema.core.model.constraint.IConstraint;
60  import dev.metaschema.core.model.constraint.IConstraint.Level;
61  import dev.metaschema.core.model.constraint.ILet;
62  import dev.metaschema.core.model.constraint.TimingCollector;
63  import dev.metaschema.core.model.constraint.TimingRecord;
64  import dev.metaschema.core.model.constraint.ValidationEventListener;
65  import dev.metaschema.core.model.constraint.ValidationPhase;
66  import dev.metaschema.core.model.validation.IValidationFinding;
67  import dev.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
68  import dev.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
69  import dev.metaschema.core.util.CollectionUtil;
70  import dev.metaschema.core.util.IVersionInfo;
71  import dev.metaschema.core.util.ObjectUtils;
72  import dev.metaschema.core.util.UriUtils;
73  import dev.metaschema.databind.IBindingContext;
74  import dev.metaschema.databind.io.Format;
75  import dev.metaschema.databind.io.SerializationFeature;
76  import edu.umd.cs.findbugs.annotations.NonNull;
77  import edu.umd.cs.findbugs.annotations.Nullable;
78  
79  /**
80   * Supports building a Static Analysis Results Interchange Format (SARIF)
81   * document based on a set of validation findings.
82   */
83  @SuppressWarnings("PMD.CouplingBetweenObjects")
84  public final class SarifValidationHandler implements ValidationEventListener {
85    private enum Kind {
86      NOT_APPLICABLE("notApplicable"),
87      PASS("pass"),
88      FAIL("fail"),
89      REVIEW("review"),
90      OPEN("open"),
91      INFORMATIONAL("informational");
92  
93      @NonNull
94      private final String label;
95  
96      Kind(@NonNull String label) {
97        this.label = label;
98      }
99  
100     @NonNull
101     public String getLabel() {
102       return label;
103     }
104   }
105 
106   private enum SeverityLevel {
107     NONE("none"),
108     NOTE("note"),
109     WARNING("warning"),
110     ERROR("error");
111 
112     @NonNull
113     private final String label;
114 
115     SeverityLevel(@NonNull String label) {
116       this.label = label;
117     }
118 
119     @NonNull
120     public String getLabel() {
121       return label;
122     }
123   }
124 
125   @NonNull
126   static final String SARIF_NS = "https://docs.oasis-open.org/sarif/sarif/v2.1.0";
127   /**
128    * The property key for specifying a URL that provides help information for a
129    * constraint.
130    */
131   @NonNull
132   public static final IAttributable.Key SARIF_HELP_URL_KEY
133       = IAttributable.key("help-url", SARIF_NS);
134   /**
135    * The property key for specifying plain text help content for a constraint.
136    */
137   @NonNull
138   public static final IAttributable.Key SARIF_HELP_TEXT_KEY
139       = IAttributable.key("help-text", SARIF_NS);
140   /**
141    * The property key for specifying markdown-formatted help content for a
142    * constraint.
143    */
144   @NonNull
145   public static final IAttributable.Key SARIF_HELP_MARKDOWN_KEY
146       = IAttributable.key("help-markdown", SARIF_NS);
147 
148   @NonNull
149   private final URI source;
150   @Nullable
151   private final IVersionInfo toolVersion;
152   private final AtomicInteger artifactIndex = new AtomicInteger(-1);
153   private final AtomicInteger ruleIndex = new AtomicInteger(-1);
154 
155   @SuppressWarnings("PMD.UseConcurrentHashMap")
156   @NonNull
157   private final Map<URI, ArtifactRecord> artifacts = new LinkedHashMap<>();
158   @NonNull
159   private final List<AbstractRuleRecord> rules = new LinkedList<>();
160   @SuppressWarnings("PMD.UseConcurrentHashMap")
161   @NonNull
162   private final Map<IConstraint, ConstraintRuleRecord> constraintRules = new LinkedHashMap<>();
163   @NonNull
164   private final List<IResult> results = new LinkedList<>();
165   @NonNull
166   private final SchemaRuleRecord schemaRule = new SchemaRuleRecord();
167   private boolean schemaValid = true;
168   @Nullable
169   private TimingCollector timingCollector;
170   @NonNull
171   private final Instant constructionTimestamp = Instant.now();
172   private final ThreadLocal<Long> currentEvaluationStartNanos = new ThreadLocal<>();
173   private final ThreadLocal<List<ConstraintResult>> currentEvaluationResults = new ThreadLocal<>();
174   private final ThreadLocal<Long> currentLetStartNanos = new ThreadLocal<>();
175   @SuppressWarnings("PMD.UseConcurrentHashMap")
176   private final ThreadLocal<Map<ILet, Long>> currentLetDurations = new ThreadLocal<>();
177   @NonNull
178   private final ConcurrentHashMap<IConstraint, EvaluationTimingSnapshot> evaluationTimings
179       = new ConcurrentHashMap<>();
180 
181   /**
182    * Construct a new validation handler.
183    *
184    * @param source
185    *          the URI of the content that was validated
186    * @param toolVersion
187    *          the version information for the tool producing the validation
188    *          results
189    */
190   public SarifValidationHandler(
191       @NonNull URI source,
192       @Nullable IVersionInfo toolVersion) {
193     if (!source.isAbsolute()) {
194       throw new IllegalArgumentException(String.format("The source URI '%s' is not absolute.", source.toASCIIString()));
195     }
196 
197     this.source = source;
198     this.toolVersion = toolVersion;
199   }
200 
201   @NonNull
202   private URI getSource() {
203     return source;
204   }
205 
206   private IVersionInfo getToolVersion() {
207     return toolVersion;
208   }
209 
210   /**
211    * Set the timing collector to enrich SARIF output with performance data.
212    * <p>
213    * When set, the generated SARIF document will include:
214    * <ul>
215    * <li>An invocation element with start/end timestamps</li>
216    * <li>Phase timing as tool execution notifications</li>
217    * <li>Per-constraint timing in rule properties</li>
218    * </ul>
219    *
220    * @param collector
221    *          the timing collector containing measurement data, or {@code null} to
222    *          disable timing output
223    */
224   public void setTimingCollector(@Nullable TimingCollector collector) {
225     this.timingCollector = collector;
226   }
227 
228   @Override
229   public void beforeValidation(@NonNull URI document) {
230     // No-op: always-on timing uses construction timestamp
231   }
232 
233   @Override
234   public void afterValidation(@NonNull URI document) {
235     // No-op: always-on timing captures end time at SARIF generation
236   }
237 
238   @Override
239   public void beforePhase(@NonNull ValidationPhase phase) {
240     // No-op: phase timing is handled by TimingCollector
241   }
242 
243   @Override
244   public void afterPhase(@NonNull ValidationPhase phase) {
245     // No-op: phase timing is handled by TimingCollector
246   }
247 
248   @Override
249   public void beforeConstraintEvaluation(@NonNull IConstraint constraint, @NonNull INodeItem target) {
250     currentEvaluationStartNanos.set(System.nanoTime());
251     currentEvaluationResults.set(new ArrayList<>());
252     currentLetDurations.set(new LinkedHashMap<>());
253   }
254 
255   @SuppressWarnings("PMD.NullAssignment") // ThreadLocal cleanup
256   @Override
257   public void afterConstraintEvaluation(@NonNull IConstraint constraint, @NonNull INodeItem target) {
258     Long startNanos = currentEvaluationStartNanos.get();
259     List<ConstraintResult> evaluationResults = currentEvaluationResults.get();
260     Map<ILet, Long> letDurations = currentLetDurations.get();
261 
262     if (startNanos != null) {
263       long durationNs = System.nanoTime() - startNanos;
264       Map<ILet, Long> snapshotLetDurations = letDurations != null && !letDurations.isEmpty()
265           ? new LinkedHashMap<>(letDurations)
266           : null;
267 
268       // Set timing on inline results (added during this evaluation)
269       if (evaluationResults != null) {
270         for (ConstraintResult result : evaluationResults) {
271           result.setEvaluationDurationNs(durationNs);
272           if (snapshotLetDurations != null) {
273             result.setLetDurations(snapshotLetDurations);
274           }
275         }
276       }
277 
278       // Store for deferred lookup (when findings are added after validation)
279       evaluationTimings.put(constraint,
280           new EvaluationTimingSnapshot(durationNs, snapshotLetDurations));
281     }
282 
283     currentEvaluationStartNanos.remove();
284     currentEvaluationResults.remove();
285     currentLetStartNanos.remove();
286     currentLetDurations.remove();
287   }
288 
289   @Override
290   public void beforeLetEvaluation(@NonNull ILet let) {
291     currentLetStartNanos.set(System.nanoTime());
292   }
293 
294   @Override
295   public void afterLetEvaluation(@NonNull ILet let) {
296     Long startNanos = currentLetStartNanos.get();
297     Map<ILet, Long> letDurations = currentLetDurations.get();
298     if (startNanos != null && letDurations != null) {
299       long durationNs = System.nanoTime() - startNanos;
300       letDurations.merge(let, durationNs, Long::sum);
301     }
302     currentLetStartNanos.remove();
303   }
304 
305   @NonNull
306   private static final BigDecimal NS_PER_MS = BigDecimal.valueOf(1_000_000L);
307 
308   /**
309    * Convert nanoseconds to milliseconds as a BigDecimal with 3 decimal places.
310    *
311    * @param nanoseconds
312    *          the duration in nanoseconds
313    * @return the duration in milliseconds
314    */
315   @NonNull
316   private static BigDecimal nsToMs(long nanoseconds) {
317     return ObjectUtils.notNull(
318         BigDecimal.valueOf(nanoseconds).divide(NS_PER_MS, 3, RoundingMode.HALF_UP));
319   }
320 
321   /**
322    * Convert a {@link TimingRecord} to a SARIF {@link TimingData} object.
323    *
324    * @param record
325    *          the timing record to convert
326    * @return the SARIF timing data
327    */
328   @NonNull
329   private static TimingData toTimingData(@NonNull TimingRecord record) {
330     TimingData data = new TimingData();
331     data.setTotalMs(nsToMs(record.getTotalTimeNs()));
332     data.setCount(BigInteger.valueOf(record.getCount()));
333     if (record.getCount() > 0) {
334       data.setMinMs(nsToMs(record.getMinTimeNs()));
335       data.setMaxMs(nsToMs(record.getMaxTimeNs()));
336     }
337     return data;
338   }
339 
340   /**
341    * Register a collection of validation finding.
342    *
343    * @param findings
344    *          the findings to register
345    */
346   public void addFindings(@NonNull Collection<? extends IValidationFinding> findings) {
347     for (IValidationFinding finding : findings) {
348       assert finding != null;
349       addFinding(finding);
350     }
351   }
352 
353   /**
354    * Register a validation finding.
355    *
356    * @param finding
357    *          the finding to register
358    */
359   public void addFinding(@NonNull IValidationFinding finding) {
360     if (finding instanceof JsonValidationFinding) {
361       addJsonValidationFinding((JsonValidationFinding) finding);
362     } else if (finding instanceof XmlValidationFinding) {
363       addXmlValidationFinding((XmlValidationFinding) finding);
364     } else if (finding instanceof ConstraintValidationFinding) {
365       addConstraintValidationFinding((ConstraintValidationFinding) finding);
366     } else {
367       throw new IllegalStateException();
368     }
369   }
370 
371   private ConstraintRuleRecord getRuleRecord(@NonNull IConstraint constraint) {
372     ConstraintRuleRecord retval = constraintRules.get(constraint);
373     if (retval == null) {
374       retval = new ConstraintRuleRecord(constraint);
375       constraintRules.put(constraint, retval);
376       rules.add(retval);
377     }
378     return retval;
379   }
380 
381   private ArtifactRecord getArtifactRecord(@NonNull URI artifactUri) {
382     ArtifactRecord retval = artifacts.get(artifactUri);
383     if (retval == null) {
384       retval = new ArtifactRecord(artifactUri);
385       artifacts.put(artifactUri, retval);
386     }
387     return retval;
388   }
389 
390   private void addJsonValidationFinding(@NonNull JsonValidationFinding finding) {
391     results.add(new SchemaResult(finding));
392     if (schemaValid && IValidationFinding.Kind.FAIL.equals(finding.getKind())) {
393       schemaValid = false;
394     }
395   }
396 
397   private void addXmlValidationFinding(@NonNull XmlValidationFinding finding) {
398     results.add(new SchemaResult(finding));
399     if (schemaValid && IValidationFinding.Kind.FAIL.equals(finding.getKind())) {
400       schemaValid = false;
401     }
402   }
403 
404   private void addConstraintValidationFinding(@NonNull ConstraintValidationFinding finding) {
405     ConstraintResult constraintResult = new ConstraintResult(finding);
406     results.add(constraintResult);
407 
408     // Track for per-evaluation timing if within a constraint evaluation (inline)
409     List<ConstraintResult> evaluationResults = currentEvaluationResults.get();
410     if (evaluationResults != null) {
411       evaluationResults.add(constraintResult);
412     } else {
413       // Deferred pattern: look up timing from the most recent evaluation
414       for (IConstraint constraint : finding.getConstraints()) {
415         EvaluationTimingSnapshot snapshot = evaluationTimings.get(constraint);
416         if (snapshot != null) {
417           constraintResult.setEvaluationDurationNs(snapshot.durationNs);
418           if (snapshot.letDurations != null) {
419             constraintResult.setLetDurations(snapshot.letDurations);
420           }
421           break;
422         }
423       }
424     }
425   }
426 
427   /**
428    * Generate a SARIF document based on the collected findings.
429    *
430    * @param outputUri
431    *          the URI to use as the base for relative paths in the SARIF document
432    * @return the generated SARIF document
433    * @throws IOException
434    *           if an error occurred while generating the SARIF document
435    */
436   @NonNull
437   private Sarif generateSarif(@NonNull URI outputUri) throws IOException {
438     Sarif sarif = new Sarif();
439     sarif.setVersion("2.1.0");
440 
441     Run run = new Run();
442     sarif.addRun(run);
443 
444     Artifact artifact = new Artifact();
445     artifact.setLocation(getArtifactRecord(getSource()).generateArtifactLocation(outputUri));
446     run.addArtifact(artifact);
447 
448     for (IResult result : results) {
449       result.generateResults(outputUri).forEach(run::addResult);
450     }
451 
452     IVersionInfo toolVersion = getToolVersion();
453     if (!rules.isEmpty() || toolVersion != null) {
454       Tool tool = new Tool();
455       ToolComponent driver = new ToolComponent();
456 
457       if (toolVersion != null) {
458         driver.setName(toolVersion.getName());
459         driver.setVersion(toolVersion.getVersion());
460       }
461 
462       for (AbstractRuleRecord rule : rules) {
463         driver.addRule(rule.generate());
464       }
465 
466       tool.setDriver(driver);
467       run.setTool(tool);
468     }
469 
470     enrichWithTiming(run);
471 
472     return sarif;
473   }
474 
475   /**
476    * Enrich the SARIF run with timing data.
477    * <p>
478    * Always creates an invocation with start/end timestamps (always-on timing). If
479    * a timing collector is set, overrides timestamps from the collector and adds
480    * phase/let-statement timing as tool execution notifications.
481    *
482    * @param run
483    *          the SARIF run to enrich
484    */
485   @SuppressWarnings("PMD.CognitiveComplexity")
486   private void enrichWithTiming(@NonNull Run run) {
487     // Always create invocation with timestamps (always-on timing)
488     Invocation invocation = new Invocation();
489     invocation.setExecutionSuccessful(Boolean.TRUE);
490     invocation.setStartTimeUtc(ZonedDateTime.ofInstant(constructionTimestamp, ZoneOffset.UTC));
491     invocation.setEndTimeUtc(ZonedDateTime.ofInstant(Instant.now(), ZoneOffset.UTC));
492 
493     TimingCollector collector = this.timingCollector;
494     if (collector != null) {
495       // Override with collector timestamps if available
496       TimingRecord validationTiming = collector.getValidationTiming();
497       if (validationTiming != null) {
498         Instant start = validationTiming.getStartTimestampUtc();
499         if (start != null) {
500           invocation.setStartTimeUtc(ZonedDateTime.ofInstant(start, ZoneOffset.UTC));
501         }
502         Instant end = validationTiming.getEndTimestampUtc();
503         if (end != null) {
504           invocation.setEndTimeUtc(ZonedDateTime.ofInstant(end, ZoneOffset.UTC));
505         }
506       }
507 
508       // Add phase timing as notifications
509       for (Map.Entry<ValidationPhase, TimingRecord> entry : collector.getPhaseTimings().entrySet()) {
510         @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
511         Notification notification = new Notification();
512         @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
513         Message msg = new Message();
514         msg.setText("Phase: " + entry.getKey().name());
515         notification.setMessage(msg);
516 
517         TimingRecord phaseRecord = entry.getValue();
518         Instant phaseEnd = phaseRecord.getEndTimestampUtc();
519         if (phaseEnd != null) {
520           notification.setTimeUtc(ZonedDateTime.ofInstant(phaseEnd, ZoneOffset.UTC));
521         }
522 
523         @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
524         PropertyBag phaseProps = new PropertyBag();
525         phaseProps.setTiming(toTimingData(phaseRecord));
526         notification.setProperties(phaseProps);
527 
528         invocation.addToolExecutionNotification(notification);
529       }
530 
531       // Add let-statement timing as notifications
532       for (Map.Entry<ILet, TimingRecord> entry : collector.getLetTimings().entrySet()) {
533         ILet let = entry.getKey();
534 
535         @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
536         Notification notification = new Notification();
537         @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
538         Message msg = new Message();
539         msg.setText("$" + let.getName().getLocalName() + " := " + let.getValueExpression().getPath());
540         notification.setMessage(msg);
541 
542         TimingRecord letRecord = entry.getValue();
543         Instant letEnd = letRecord.getEndTimestampUtc();
544         if (letEnd != null) {
545           notification.setTimeUtc(ZonedDateTime.ofInstant(letEnd, ZoneOffset.UTC));
546         }
547 
548         @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
549         PropertyBag letProps = new PropertyBag();
550         letProps.setTiming(toTimingData(letRecord));
551         notification.setProperties(letProps);
552 
553         invocation.addToolExecutionNotification(notification);
554       }
555     }
556 
557     run.addInvocation(invocation);
558   }
559 
560   /**
561    * Write the collection of findings to a string in SARIF format.
562    *
563    * @param bindingContext
564    *          the context used to access Metaschema module information based on
565    *          Java class bindings
566    * @return the SARIF document as a string
567    * @throws IOException
568    *           if an error occurred while generating the SARIF document
569    */
570   @NonNull
571   public String writeToString(@NonNull IBindingContext bindingContext) throws IOException {
572     registerSarifMetaschemaModule(bindingContext);
573     try (StringWriter writer = new StringWriter()) {
574       bindingContext.newSerializer(Format.JSON, Sarif.class)
575           .disableFeature(SerializationFeature.SERIALIZE_ROOT)
576           .serialize(generateSarif(getSource()), writer);
577       return ObjectUtils.notNull(writer.toString());
578     }
579   }
580 
581   /**
582    * Write the collection of findings to the provided output file.
583    *
584    * @param outputFile
585    *          the path to the output file to write to
586    * @param bindingContext
587    *          the context used to access Metaschema module information based on
588    *          Java class bindings
589    * @throws IOException
590    *           if an error occurred while writing the SARIF file
591    */
592   public void write(
593       @NonNull Path outputFile,
594       @NonNull IBindingContext bindingContext) throws IOException {
595 
596     URI output = ObjectUtils.notNull(outputFile.toUri());
597     Sarif sarif = generateSarif(output);
598 
599     registerSarifMetaschemaModule(bindingContext);
600     bindingContext.newSerializer(Format.JSON, Sarif.class)
601         .disableFeature(SerializationFeature.SERIALIZE_ROOT)
602         .serialize(
603             sarif,
604             outputFile,
605             StandardOpenOption.CREATE,
606             StandardOpenOption.WRITE,
607             StandardOpenOption.TRUNCATE_EXISTING);
608   }
609 
610   private static void registerSarifMetaschemaModule(@NonNull IBindingContext bindingContext) {
611     try {
612       bindingContext.registerModule(SarifModule.class);
613     } catch (MetaschemaException ex) {
614       throw new IllegalStateException("Unable to register the builtin SARIF module.", ex);
615     }
616   }
617 
618   private interface IResult {
619     @NonNull
620     IValidationFinding getFinding();
621 
622     @NonNull
623     List<Result> generateResults(@NonNull URI output) throws IOException;
624   }
625 
626   private abstract class AbstractResult<T extends IValidationFinding> implements IResult {
627     @NonNull
628     private final T finding;
629 
630     protected AbstractResult(@NonNull T finding) {
631       this.finding = finding;
632     }
633 
634     @Override
635     public T getFinding() {
636       return finding;
637     }
638 
639     @NonNull
640     protected Kind kind(@NonNull IValidationFinding finding) {
641       IValidationFinding.Kind kind = finding.getKind();
642 
643       Kind retval;
644       switch (kind) {
645       case FAIL:
646         retval = Kind.FAIL;
647         break;
648       case INFORMATIONAL:
649         retval = Kind.INFORMATIONAL;
650         break;
651       case NOT_APPLICABLE:
652         retval = Kind.NOT_APPLICABLE;
653         break;
654       case PASS:
655         retval = Kind.PASS;
656         break;
657       default:
658         throw new IllegalArgumentException(String.format("Invalid finding kind '%s'.", kind));
659       }
660       return retval;
661     }
662 
663     @NonNull
664     protected SeverityLevel level(@NonNull Level severity) {
665       SeverityLevel retval;
666       switch (severity) {
667       case CRITICAL:
668       case ERROR:
669         retval = SeverityLevel.ERROR;
670         break;
671       case INFORMATIONAL:
672       case DEBUG:
673         retval = SeverityLevel.NOTE;
674         break;
675       case WARNING:
676         retval = SeverityLevel.WARNING;
677         break;
678       case NONE:
679         retval = SeverityLevel.NONE;
680         break;
681       default:
682         throw new IllegalArgumentException(String.format("Invalid severity '%s'.", severity));
683       }
684       return retval;
685     }
686 
687     protected void message(@NonNull IValidationFinding finding, @NonNull Result result) {
688       String message = finding.getMessage();
689       if (message == null) {
690         message = "";
691       }
692 
693       Message msg = new Message();
694       msg.setText(message);
695       result.setMessage(msg);
696     }
697 
698     protected void location(@NonNull IValidationFinding finding, @NonNull Result result, @NonNull URI base)
699         throws IOException {
700       IResourceLocation location = finding.getLocation();
701       if (location != null) {
702         // region
703         Region region = new Region();
704 
705         if (location.getLine() > -1) {
706           region.setStartLine(BigInteger.valueOf(location.getLine()));
707           region.setEndLine(BigInteger.valueOf(location.getLine()));
708         }
709         if (location.getColumn() > -1) {
710           region.setStartColumn(BigInteger.valueOf(location.getColumn() + 1));
711           region.setEndColumn(BigInteger.valueOf(location.getColumn() + 1));
712         }
713         if (location.getByteOffset() > -1) {
714           region.setByteOffset(BigInteger.valueOf(location.getByteOffset()));
715           region.setByteLength(BigInteger.ZERO);
716         }
717         if (location.getCharOffset() > -1) {
718           region.setCharOffset(BigInteger.valueOf(location.getCharOffset()));
719           region.setCharLength(BigInteger.ZERO);
720         }
721 
722         PhysicalLocation physical = new PhysicalLocation();
723 
724         URI documentUri = finding.getDocumentUri();
725         if (documentUri != null) {
726           physical.setArtifactLocation(getArtifactRecord(documentUri).generateArtifactLocation(base));
727         }
728         physical.setRegion(region);
729 
730         LogicalLocation logical = new LogicalLocation();
731 
732         logical.setDecoratedName(finding.getPath());
733 
734         Location loc = new Location();
735         loc.setPhysicalLocation(physical);
736         loc.addLogicalLocation(logical);
737         result.addLocation(loc);
738       }
739     }
740   }
741 
742   private final class SchemaResult
743       extends AbstractResult<IValidationFinding> {
744 
745     protected SchemaResult(@NonNull IValidationFinding finding) {
746       super(finding);
747     }
748 
749     @Override
750     public List<Result> generateResults(@NonNull URI output) throws IOException {
751       IValidationFinding finding = getFinding();
752 
753       Result result = new Result();
754 
755       result.setRuleId(schemaRule.getId());
756       result.setRuleIndex(BigInteger.valueOf(schemaRule.getIndex()));
757       result.setGuid(schemaRule.getGuid());
758 
759       result.setKind(kind(finding).getLabel());
760       result.setLevel(level(finding.getSeverity()).getLabel());
761       message(finding, result);
762       location(finding, result, output);
763 
764       return CollectionUtil.singletonList(result);
765     }
766   }
767 
768   private final class ConstraintResult
769       extends AbstractResult<ConstraintValidationFinding> {
770     @Nullable
771     private Long evaluationDurationNs;
772     @Nullable
773     private Map<ILet, Long> letDurations;
774 
775     protected ConstraintResult(@NonNull ConstraintValidationFinding finding) {
776       super(finding);
777     }
778 
779     void setEvaluationDurationNs(long durationNs) {
780       this.evaluationDurationNs = durationNs;
781     }
782 
783     void setLetDurations(@NonNull Map<ILet, Long> durations) {
784       this.letDurations = durations;
785     }
786 
787     @Override
788     public List<Result> generateResults(@NonNull URI output) throws IOException {
789       ConstraintValidationFinding finding = getFinding();
790 
791       List<Result> retval = new LinkedList<>();
792 
793       Kind kind = kind(finding);
794       SeverityLevel level = level(finding.getSeverity());
795 
796       for (IConstraint constraint : finding.getConstraints()) {
797         assert constraint != null;
798         ConstraintRuleRecord rule = getRuleRecord(constraint);
799 
800         @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
801         Result result = new Result();
802 
803         String id = constraint.getId();
804         if (id != null) {
805           result.setRuleId(id);
806         }
807         result.setRuleIndex(BigInteger.valueOf(rule.getIndex()));
808         result.setGuid(rule.getGuid());
809         result.setKind(kind.getLabel());
810         result.setLevel(level.getLabel());
811         message(finding, result);
812         location(finding, result, output);
813         addPerResultTiming(result);
814 
815         retval.add(result);
816       }
817       return retval;
818     }
819 
820     @SuppressWarnings("PMD.CognitiveComplexity")
821     private void addPerResultTiming(@NonNull Result result) {
822       Long durationNs = this.evaluationDurationNs;
823       if (durationNs == null) {
824         return;
825       }
826 
827       PropertyBag props = result.getProperties();
828       if (props == null) {
829         props = new PropertyBag();
830         result.setProperties(props);
831       }
832 
833       TimingData timing = new TimingData();
834       timing.setTotalMs(nsToMs(durationNs));
835       timing.setCount(BigInteger.ONE);
836       props.setTiming(timing);
837 
838       Map<ILet, Long> letDurs = this.letDurations;
839       if (letDurs != null) {
840         for (Map.Entry<ILet, Long> entry : letDurs.entrySet()) {
841           @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
842           LetTimingEntry letEntry = new LetTimingEntry();
843           letEntry.setName(entry.getKey().getName().getLocalName());
844 
845           @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
846           TimingData letTiming = new TimingData();
847           letTiming.setTotalMs(nsToMs(entry.getValue()));
848           letTiming.setCount(BigInteger.ONE);
849           letEntry.setTiming(letTiming);
850 
851           props.addLetTimingEntry(letEntry);
852         }
853       }
854     }
855   }
856 
857   private abstract class AbstractRuleRecord {
858     private final int index;
859     @NonNull
860     private final UUID guid;
861 
862     private AbstractRuleRecord() {
863       this.index = ruleIndex.addAndGet(1);
864       this.guid = ObjectUtils.notNull(UUID.randomUUID());
865     }
866 
867     public int getIndex() {
868       return index;
869     }
870 
871     @NonNull
872     public UUID getGuid() {
873       return guid;
874     }
875 
876     @NonNull
877     protected abstract ReportingDescriptor generate();
878   }
879 
880   private final class SchemaRuleRecord
881       extends AbstractRuleRecord {
882 
883     @Override
884     protected ReportingDescriptor generate() {
885       ReportingDescriptor retval = new ReportingDescriptor();
886       retval.setId(getId());
887       retval.setGuid(getGuid());
888       return retval;
889     }
890 
891     public String getId() {
892       return "schema-valid";
893     }
894   }
895 
896   private final class ConstraintRuleRecord
897       extends AbstractRuleRecord {
898     @NonNull
899     private final IConstraint constraint;
900 
901     public ConstraintRuleRecord(@NonNull IConstraint constraint) {
902       this.constraint = constraint;
903     }
904 
905     @NonNull
906     public IConstraint getConstraint() {
907       return constraint;
908     }
909 
910     @Override
911     protected ReportingDescriptor generate() {
912       ReportingDescriptor retval = new ReportingDescriptor();
913       IConstraint constraint = getConstraint();
914 
915       UUID guid = getGuid();
916 
917       String id = constraint.getId();
918       if (id == null) {
919         retval.setId(guid.toString());
920       } else {
921         retval.setId(id);
922       }
923       retval.setGuid(guid);
924       String formalName = constraint.getFormalName();
925       if (formalName != null) {
926         MultiformatMessageString text = new MultiformatMessageString();
927         text.setText(formalName);
928         retval.setShortDescription(text);
929       }
930       MarkupLine description = constraint.getDescription();
931       if (description != null) {
932         MultiformatMessageString text = new MultiformatMessageString();
933         text.setText(description.toText());
934         text.setMarkdown(description.toMarkdown());
935         retval.setFullDescription(text);
936       }
937 
938       Set<String> helpUrls = constraint.getPropertyValues(SARIF_HELP_URL_KEY);
939       if (!helpUrls.isEmpty()) {
940         retval.setHelpUri(URI.create(helpUrls.stream().findFirst().get()));
941       }
942 
943       Set<String> helpText = constraint.getPropertyValues(SARIF_HELP_TEXT_KEY);
944       Set<String> helpMarkdown = constraint.getPropertyValues(SARIF_HELP_MARKDOWN_KEY);
945       // if there is help text or markdown, produce a message
946       if (!helpText.isEmpty() || !helpMarkdown.isEmpty()) {
947         MultiformatMessageString help = new MultiformatMessageString();
948 
949         MarkupMultiline markdown = helpMarkdown.stream().map(MarkupMultiline::fromMarkdown).findFirst().orElse(null);
950         if (markdown != null) {
951           // markdown is provided
952           help.setMarkdown(markdown.toMarkdown());
953         }
954 
955         String text = helpText.isEmpty()
956             ? ObjectUtils.requireNonNull(markdown).toText() // if text is empty, markdown must be provided
957             : helpText.stream().findFirst().get(); // use the provided text
958         help.setText(text);
959 
960         retval.setHelp(help);
961       }
962 
963       // Add timing data if available
964       TimingCollector collector = timingCollector;
965       if (collector != null) {
966         TimingRecord record = collector.getConstraintTiming(constraint.getInternalIdentifier());
967         if (record != null) {
968           PropertyBag props = retval.getProperties();
969           if (props == null) {
970             props = new PropertyBag();
971             retval.setProperties(props);
972           }
973           props.setTiming(toTimingData(record));
974         }
975       }
976 
977       return retval;
978     }
979 
980   }
981 
982   private final class ArtifactRecord {
983     @NonNull
984     private final URI uri;
985     private final int index;
986 
987     public ArtifactRecord(@NonNull URI uri) {
988       this.uri = uri;
989       this.index = artifactIndex.addAndGet(1);
990     }
991 
992     @NonNull
993     public URI getUri() {
994       return uri;
995     }
996 
997     public int getIndex() {
998       return index;
999     }
1000 
1001     public ArtifactLocation generateArtifactLocation(@NonNull URI baseUri) throws IOException {
1002       ArtifactLocation location = new ArtifactLocation();
1003 
1004       try {
1005         location.setUri(UriUtils.relativize(baseUri, getUri(), true));
1006       } catch (URISyntaxException ex) {
1007         throw new IOException(ex);
1008       }
1009 
1010       location.setIndex(BigInteger.valueOf(getIndex()));
1011       return location;
1012     }
1013   }
1014 
1015   /**
1016    * Snapshot of per-evaluation timing data, stored for deferred lookup when
1017    * findings are added after validation completes.
1018    */
1019   private static final class EvaluationTimingSnapshot {
1020     final long durationNs;
1021     @Nullable
1022     final Map<ILet, Long> letDurations;
1023 
1024     EvaluationTimingSnapshot(long durationNs, @Nullable Map<ILet, Long> letDurations) {
1025       this.durationNs = durationNs;
1026       this.letDurations = letDurations;
1027     }
1028   }
1029 }