← Files ECZ-ID SBOM & CRA ReadinessARCHIVED FILE

skills/sbom-cra-evidence-review/scripts/review.mjs

24 KB · Oct 2, 2026 · 00:32 UTC

↓ Download file

#!/usr/bin/env node
// ECZ-ID SBOM & CRA Readiness: portable evidence review (generated by the ECZ-ID Plugin Foundry; do not edit).
// Reads file NAMES and PATHS under the given root. Opens no file. No network. Writes nothing.
// Same detectors, Review Priority rules and next actions as the ECZ-ID VS Code extension.
import { readdirSync } from "node:fs";
import { join, relative } from "node:path";



export const DEFAULT_IGNORES = new Set(["node_modules", ".git", ".pnpm-store", "dist", "out", "build", ".next", ".turbo", ".venv", "venv", "__pycache__", "target", "coverage"]);
export const DOT_ALLOWLIST = new Set([".github", ".gitlab", ".well-known"]);

/** Workspace-relative file paths, filename and path only. Dot-entries are skipped except the allowlist. */
export function listFiles(root, { maxDepth = 8, maxFiles = 20000, extraDotEntries = [] } = {}) {
  const results = [];
  const dots = new Set([...DOT_ALLOWLIST, ...extraDotEntries]);
  const walk = (dir, depth) => {
    if (depth > maxDepth || results.length >= maxFiles) return;
    let entries;
    try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
    for (const e of entries) {
      if (results.length >= maxFiles) return;
      if (e.name.startsWith(".") && !dots.has(e.name)) continue;
      const full = join(dir, e.name);
      if (e.isDirectory()) { if (DEFAULT_IGNORES.has(e.name)) continue; walk(full, depth + 1); }
      else if (e.isFile()) results.push(relative(root, full).split("\\").join("/"));
    }
  };
  walk(root, 0);
  return results;
}

const RESOLVER_REF = [/(^|\/)\.well-known\/ecz-[a-z0-9-]*\.json$/i, /(^|\/)ecz-(agent|mcp|id)[a-z0-9-]*\.json$/i, /(^|\/)ecz-[a-z0-9-]+\.json$/i];
const REASON = {
  EVIDENCE_OBSERVED: "Evidence observed locally for the items listed.",
  EVIDENCE_NOT_OBSERVED: "Some expected evidence was not observed locally. This is neutral. It does not mean a problem exists.",
  REVIEW_RECOMMENDED: "Observed evidence may still need human review before reliance.",
  NO_PUBLIC_PROOF_REFERENCE: "No public resolver proof reference was found yet. This does not mean unsafe. Local policy decides.",
  PARTIAL_PUBLIC_PROOF: "Partial public proof reference detected. Resolver-verifiable proof may make this easier to review.",
  RECHECK_BEFORE_RELIANCE: "Re-check before reliance. Results reflect the workspace at scan time.",
  LOCAL_POLICY_DECIDES: "Your local policy decides whether the observed evidence is sufficient."
};

function matchAny(patterns, files) {
  for (const f of files) for (const re of patterns) if (re.test(f)) return f;
  return undefined;
}

/** Same semantics as family/detect.ts detectEvidence. */
export function detectEvidence(spec, files, workspaceName) {
  const observed = [], notObserved = [], reviewRequired = [];
  for (const d of spec.detectors) {
    const hit = matchAny(d.patterns.map((p) => new RegExp(p, "i")), files);
    if (hit) {
      const item = { id: d.id, label: d.label, status: "observed", detail: d.observedDetail, path: hit };
      observed.push(item);
      if (d.reviewWhenObserved) reviewRequired.push({ ...item, status: "review-required" });
    } else notObserved.push({ id: d.id, label: d.label, status: "not-observed", detail: d.notObservedDetail });
  }
  const codes = [];
  if (observed.length) codes.push("EVIDENCE_OBSERVED");
  if (notObserved.length) codes.push("EVIDENCE_NOT_OBSERVED");
  if (reviewRequired.length) codes.push("REVIEW_RECOMMENDED");
  codes.push(matchAny(RESOLVER_REF, files) ? "PARTIAL_PUBLIC_PROOF" : "NO_PUBLIC_PROOF_REFERENCE");
  codes.push("LOCAL_POLICY_DECIDES", "RECHECK_BEFORE_RELIANCE");
  return { specialistId: spec.extensionId ?? spec.name, scannedAt: new Date().toISOString(), workspaceName, observed, notObserved, reviewRequired, reasonCodes: codes.map((id) => ({ id, message: REASON[id] })) };
}

export const PRIORITY_DISCLAIMER = "Review Priority is not a safety, approval or compliance determination. It indicates how much attention this evidence review deserves, based only on what was observed locally by filename and path.";
export const PRIORITY_MEANING = {
  LOW: "Every evidence class this review looks for was observed. Review the documents themselves before reliance.",
  NORMAL: "Observed evidence still needs human review, or supporting evidence was not observed. Worth completing before the next review.",
  ELEVATED: "A primary evidence class was not observed. Review before you rely on this workspace as an evidence source.",
  HIGH: "Evidence that a regulator, auditor or customer is likely to ask for first was not observed, or several primary classes are missing together."
};
export const ELEVATED_GAP_AGGREGATION_THRESHOLD = 2;
const RANK = { LOW: 0, NORMAL: 1, ELEVATED: 2, HIGH: 3 };
const FROM_WEIGHT = { high: "HIGH", elevated: "ELEVATED", normal: "NORMAL" };

/** Same rules as family/valueLayer.ts computeEvidenceReviewPriority. */
export function computeReviewPriority(spec, result, profile) {
  const observed = new Map(result.observed.map((i) => [i.id, i]));
  const review = new Set(result.reviewRequired.map((i) => i.id));
  const lines = [];
  for (const d of spec.detectors) {
    const g = profile.guidance.find((x) => x.detectorId === d.id);
    if (observed.has(d.id)) {
      const needs = review.has(d.id);
      lines.push({ detectorId: d.id, label: d.label, status: needs ? "review-required" : "observed", weight: "none", contributes: needs ? "NORMAL" : "LOW", detail: needs ? "Observed by filename and path; the document itself still needs human review." : "Observed by filename and path." });
    } else {
      const w = g?.weightWhenNotObserved ?? "normal";
      lines.push({ detectorId: d.id, label: d.label, status: "not-observed", weight: w, contributes: FROM_WEIGHT[w], detail: `Not observed by filename and path (${w === "normal" ? "supporting" : "primary"} evidence class).` });
    }
  }
  const counts = { LOW: 0, NORMAL: 0, ELEVATED: 0, HIGH: 0 };
  for (const l of lines) counts[l.contributes]++;
  let priority, rationale;
  if (counts.HIGH > 0) { priority = "HIGH"; rationale = `HIGH because ${counts.HIGH} evidence class${counts.HIGH === 1 ? "" : "es"} that ${counts.HIGH === 1 ? "is" : "are"} usually requested first ${counts.HIGH === 1 ? "was" : "were"} not observed.`; }
  else if (counts.ELEVATED >= ELEVATED_GAP_AGGREGATION_THRESHOLD) { priority = "HIGH"; rationale = `HIGH because ${counts.ELEVATED} primary evidence classes were not observed together (threshold ${ELEVATED_GAP_AGGREGATION_THRESHOLD}).`; }
  else if (counts.ELEVATED > 0) { priority = "ELEVATED"; rationale = "ELEVATED because one primary evidence class was not observed."; }
  else if (counts.NORMAL > 0) { priority = "NORMAL"; rationale = `NORMAL because ${counts.NORMAL} item${counts.NORMAL === 1 ? "" : "s"} ${counts.NORMAL === 1 ? "needs" : "need"} human review or supporting evidence was not observed.`; }
  else { priority = "LOW"; rationale = "LOW because every evidence class was observed and none is flagged for review."; }
  lines.sort((a, b) => RANK[b.contributes] - RANK[a.contributes] || a.detectorId.localeCompare(b.detectorId));
  return { priority, meaning: PRIORITY_MEANING[priority], disclaimer: PRIORITY_DISCLAIMER, rationale, reasons: lines, counts };
}

/** Same rules as family/valueLayer.ts selectContextualActions. */
export function selectContextualActions(result, profile) {
  const observed = new Set(result.observed.map((i) => i.id));
  const notObserved = new Set(result.notObserved.map((i) => i.id));
  const max = profile.maxActions ?? 3;
  return profile.actions
    .map((a, idx) => ({ a, idx }))
    .filter(({ a }) => a.always || a.whenNotObserved?.some((id) => notObserved.has(id)) || a.whenObserved?.some((id) => observed.has(id)))
    .sort((x, y) => (y.a.rank ?? 0) - (x.a.rank ?? 0) || x.idx - y.idx)
    .map(({ a }) => a)
    .slice(0, Math.max(0, max));
}

const NEUTRAL = [
  "This is an evidence-organising review, not a verdict.",
  "It does not assert safety, certification, approval or compliance.",
  "Filename and path detection shows that a document exists where you expect it. It does not read the document and cannot judge its quality.",
  "Missing evidence is neutral. Your local policy decides what is sufficient.",
  "Re-check before reliance; results reflect the workspace at scan time."
];

export function renderReview(spec, result, profile) {
  const p = computeReviewPriority(spec, result, profile);
  const actions = selectContextualActions(result, profile);
  const observed = new Map(result.observed.map((i) => [i.id, i]));
  const review = new Set(result.reviewRequired.map((i) => i.id));
  const L = [];
  L.push(`# ${spec.displayName}: Evidence Review`, "", `**${profile.question}**`, "", profile.hook, "");
  if (result.workspaceName) L.push(`Workspace: **${result.workspaceName}**  |  Scanned: ${result.scannedAt}  |  Method: filename and path only`, "");
  L.push(`## Review Priority: ${p.priority}`, "", p.meaning, "", `Why: ${p.rationale}`, "");
  L.push(...p.reasons.map((r) => `- ${r.label}: ${r.status.toUpperCase().replace("-", " ")} (contributes ${r.contributes}). ${r.detail}`), "");
  L.push(`_${p.disclaimer}_`, "");
  L.push("## What we observed, what we did not, and why it matters", "");
  for (const d of spec.detectors) {
    const g = profile.guidance.find((x) => x.detectorId === d.id);
    const hit = observed.get(d.id);
    const status = hit ? (review.has(d.id) ? "OBSERVED, REVIEW REQUIRED" : "OBSERVED") : "NOT OBSERVED";
    L.push(`### ${d.label}: ${status}`, "");
    if (hit?.path) L.push(`- Where: \`${hit.path}\``);
    if (hit?.detail) L.push(`- Observed: ${hit.detail}`);
    if (!hit && d.notObservedDetail) L.push(`- Observed: ${d.notObservedDetail}`);
    if (g) {
      L.push(`- Why it matters: ${g.whyItMatters}`);
      L.push(`- Review next: ${hit ? g.reviewWhenObserved : g.reviewWhenNotObserved}`);
      if (g.capability) L.push(`- If you want to go further: ${g.capability.label}. ${g.capability.note} ${g.capability.url}`);
    }
    L.push("");
  }
  L.push("## What this means", "", ...result.reasonCodes.map((rc) => `- ${rc.message}`), "");
  L.push("## What this does not mean", "", ...NEUTRAL.map((s) => `- ${s}`), "");
  L.push("## Next actions for this result", "");
  if (actions.length) { actions.forEach((a, i) => { L.push(`${i + 1}. **${a.label}**: ${a.note}`); L.push(`   ${a.url}`); }); L.push(""); }
  else L.push("_No contextual action for this result._", "");
  if (profile.discovery) L.push(`${profile.discovery.label}: ${profile.discovery.url}`, "");
  L.push("TrustOps handles setup and checkout. This review runs no payment and creates no ECZ-ID truth, entitlement or Resolver proof.", "");
  return L.join("\n");
}

/** JSON projection for machine consumers. */
export function projectReview(spec, result, profile) {
  const p = computeReviewPriority(spec, result, profile);
  return {
    schema_version: "1.0.0",
    product: spec.name,
    display_name: spec.displayName,
    generated_at_utc: result.scannedAt,
    method: "filename-and-path-only",
    workspace: result.workspaceName,
    review_priority: { level: p.priority, meaning: p.meaning, rationale: p.rationale, disclaimer: p.disclaimer, reasons: p.reasons },
    observations: [...result.observed.map((i) => ({ ...i, status: result.reviewRequired.some((r) => r.id === i.id) ? "review-required" : "observed" })), ...result.notObserved].sort((a, b) => a.id.localeCompare(b.id)),
    public_safe_reason_codes: result.reasonCodes,
    contextual_next_actions: selectContextualActions(result, profile).map((a) => ({ id: a.id, label: a.label, url: a.url, kind: a.kind, note: a.note })),
    discovery: profile.discovery ?? null,
    privacy: { local_first: true, source_upload: false, hidden_telemetry: false, network_during_review: "none" },
    do_not_infer: ["safety", "approval", "certification", "compliance", "entitlement", "binding", "current_identity_state"]
  };
}

const SPEC = {"extensionId":"ecocitizenz.eczid-sbom-readiness","name":"eczid-sbom-readiness","prefix":"eczidSbom","displayName":"ECZ-ID SBOM & CRA Readiness","purpose":"Find the SBOM, disclosure and vulnerability-evidence gaps before the CRA reporting window is running.","searchIntent":"SBOM CycloneDX SPDX VEX CRA vulnerability reporting evidence","guidanceRouteId":"sbom","setupRouteId":"sbom-readiness","setupFlow":"dora-sbom","detectors":[{"id":"sbom.machinereadable","label":"Machine-readable SBOM (CycloneDX or SPDX)","patterns":["(^|/)s?bom\\.(json|xml)$","cyclonedx","\\.cdx\\.(json|xml)$","spdx","\\.spdx(\\.(json|ya?ml))?$"],"observedDetail":"A machine-readable software bill of materials was observed.","notObservedDetail":"No machine-readable SBOM observed in either commonly used format.","reviewWhenObserved":true},{"id":"sbom.cyclonedx","label":"CycloneDX SBOM","patterns":["(^|/)s?bom\\.(json|xml)$","cyclonedx","\\.cdx\\.(json|xml)$"],"observedDetail":"A CycloneDX bill of materials was observed.","notObservedDetail":"No CycloneDX SBOM observed."},{"id":"sbom.spdx","label":"SPDX SBOM","patterns":["spdx","\\.spdx(\\.(json|ya?ml))?$"],"observedDetail":"An SPDX document was observed.","notObservedDetail":"No SPDX SBOM observed."},{"id":"sbom.lockfile","label":"Dependency lockfile","patterns":["(^|/)(package-lock\\.json|pnpm-lock\\.yaml|yarn\\.lock|poetry\\.lock|Cargo\\.lock|go\\.sum|requirements\\.txt|Gemfile\\.lock|composer\\.lock|Pipfile\\.lock|uv\\.lock)$"],"observedDetail":"A dependency lockfile is present to generate or regenerate an SBOM from.","notObservedDetail":"No dependency lockfile observed."},{"id":"sbom.vex","label":"VEX / CSAF vulnerability statements","patterns":["(^|[^a-z])vex([^a-z]|$)","\\.vex\\.(json|xml)$","openvex","csaf"],"observedDetail":"A VEX or CSAF document was observed.","notObservedDetail":"No VEX or CSAF document observed.","reviewWhenObserved":true},{"id":"sbom.disclosure","label":"Vulnerability disclosure policy / security contact","patterns":["(^|/)security\\.md$","(^|/)security\\.txt$","vulnerability-?disclosure","coordinated-?disclosure","cvd-?policy","security-?policy"],"observedDetail":"A vulnerability disclosure policy or security contact file was observed.","notObservedDetail":"No vulnerability disclosure policy or security contact file observed.","reviewWhenObserved":true},{"id":"sbom.provenance","label":"Build provenance / attestations","patterns":["provenance","(^|[^a-z])slsa([^a-z]|$)","in-?toto","\\.intoto\\.jsonl?$","attestation","sigstore","cosign"],"observedDetail":"Build provenance or attestation evidence was observed.","notObservedDetail":"No build provenance or attestation evidence observed.","reviewWhenObserved":true},{"id":"sbom.release","label":"Release record (changelog / release notes / release workflow)","patterns":["(^|/)changelog(\\.md|\\.txt|\\.rst)?$","release-?notes","(^|/)releases?/","goreleaser","release-?please","(^|/)\\.github/workflows/[^/]*release"],"observedDetail":"A release record was observed to tie the SBOM to a release.","notObservedDetail":"No release record observed."}]};
const PROFILE = {"question":"Could your team identify an affected software component and its evidence chain within the reporting window?","hook":"CRA reporting obligations apply from 11 September 2026. Could you identify an affected component and its evidence chain within 24 hours? Under Regulation (EU) 2024/2847 (Cyber Resilience Act), Article 14 applies from 11 September 2026 (Article 71(2)): an early warning within 24 hours of becoming aware of an actively exploited vulnerability or severe incident, a fuller notification within 72 hours, then a final report. The Regulation as a whole applies from 11 December 2027. This review shows which SBOM, disclosure and vulnerability evidence is visible in this workspace, what is not observed, and what deserves review next.","maxActions":3,"guidance":[{"detectorId":"sbom.machinereadable","whyItMatters":"CRA Annex I, Part II, point (1) requires manufacturers to identify and document vulnerabilities and components, including by drawing up a software bill of materials in a commonly used and machine-readable format covering at least the top-level dependencies. Without one, identifying an affected component starts from nothing inside the 24-hour early-warning window.","reviewWhenObserved":"Open the SBOM and check it names the release it describes, lists at least the top-level dependencies with versions, and was generated from the same lockfile or build the release shipped from.","reviewWhenNotObserved":"Generate one from the lockfile or the build (CycloneDX or SPDX), keep it with the release, and record which release it describes.","weightWhenNotObserved":"high","capability":{"label":"SBOM vendor credentialing (TrustOps)","url":"https://trustops.ecocitizenz.com/start?flow=dora-sbom","note":"Give buyers and auditors a resolver-verifiable view of your SBOM posture instead of exchanging documents."}},{"detectorId":"sbom.cyclonedx","whyItMatters":"CycloneDX is one of the two commonly used machine-readable SBOM formats, and the one many buyers and tools ask for by name. Format posture only: one current format is what matters.","reviewWhenObserved":"Check the CycloneDX document is current for the latest release and validates against the schema version it declares.","reviewWhenNotObserved":"Only needed if a customer or tool asks for CycloneDX specifically; an SPDX document covers the same requirement.","weightWhenNotObserved":"normal"},{"detectorId":"sbom.spdx","whyItMatters":"SPDX is the other commonly used machine-readable SBOM format and an ISO standard. Format posture only: one current format is what matters.","reviewWhenObserved":"Check the SPDX document is current for the latest release and validates against the version it declares.","reviewWhenNotObserved":"Only needed if a customer or tool asks for SPDX specifically; a CycloneDX document covers the same requirement.","weightWhenNotObserved":"normal"},{"detectorId":"sbom.lockfile","whyItMatters":"A lockfile pins the exact component versions a build used. It is what an SBOM is regenerated from, and what lets you say within the reporting window whether a named vulnerable component and version is actually in the release.","reviewWhenObserved":"Confirm the lockfile is committed, current for the release, and that the SBOM was generated from it rather than by hand.","reviewWhenNotObserved":"Commit a lockfile for each package manager in use so component versions are reproducible.","weightWhenNotObserved":"normal","capability":{"label":"ECZ-ID Dependency Security (free VS Code extension)","url":"https://open-vsx.org/extension/ecocitizenz/eczid-dependency-security","note":"Review lockfile and dependency evidence gaps in this workspace, locally and free."}},{"detectorId":"sbom.vex","whyItMatters":"CRA Article 14(2) asks for the general nature of the exploit and the vulnerability and any corrective or mitigating measures within 72 hours. A VEX or CSAF document is the machine-readable way to state whether a known vulnerability affects your product and what to do about it.","reviewWhenObserved":"Check the statements reference the same product and component identifiers as the SBOM, and carry a status and justification per vulnerability.","reviewWhenNotObserved":"Start a VEX or CSAF document for the current release, even if every entry is not affected. It is the artefact that turns an SBOM into an answer.","weightWhenNotObserved":"elevated","capability":{"label":"Cyber Resilience Passport (TrustOps)","url":"https://trustops.ecocitizenz.com/start?flow=critical-cyber-resilience","note":"Make vulnerability-handling evidence part of a credential others can verify in Resolver."}},{"detectorId":"sbom.disclosure","whyItMatters":"CRA Annex I, Part II requires a coordinated vulnerability disclosure policy and a contact address for reporting vulnerabilities, and Annex VII lists both in the technical documentation. A SECURITY.md or security.txt is where a reporter, and an authority, look first.","reviewWhenObserved":"Confirm it names a monitored contact address, the response process and the coordinated disclosure policy.","reviewWhenNotObserved":"Add a SECURITY.md or security.txt with a monitored contact address and the disclosure policy.","weightWhenNotObserved":"elevated","capability":{"label":"Cyber Resilience Passport (TrustOps)","url":"https://trustops.ecocitizenz.com/start?flow=critical-cyber-resilience","note":"A published disclosure posture becomes part of a resolver-verifiable credential."}},{"detectorId":"sbom.provenance","whyItMatters":"Build provenance and attestations (SLSA, in-toto, Sigstore) tie an artefact to the build that produced it, so the SBOM you cite in a notification can be shown to describe the shipped bytes. The CRA requires the vulnerability handling processes and the secure distribution of updates to be documented (Annex I Part II, Annex VII).","reviewWhenObserved":"Check the attestation covers the released artefact digest and that verification instructions exist.","reviewWhenNotObserved":"Consider generating provenance in CI for release builds. Supporting evidence; the SBOM comes first.","weightWhenNotObserved":"normal","capability":{"label":"ECZ-ID CI/CD Trust (free VS Code extension)","url":"https://open-vsx.org/extension/ecocitizenz/eczid-cicd-trust","note":"Review pipeline and provenance evidence in this workspace, locally and free."}},{"detectorId":"sbom.release","whyItMatters":"A changelog, release notes or release workflow is what lets you say which release a component and its SBOM belong to. Article 14 notifications describe the product concerned and the corrective measures taken, and both are per release.","reviewWhenObserved":"Check the latest release entry names the version the SBOM describes and where that release's SBOM and VEX are kept.","reviewWhenNotObserved":"Add a changelog or release notes and record where each release's SBOM lives. Supporting evidence.","weightWhenNotObserved":"normal"}],"actions":[{"id":"free-dependency-cicd-reviews","label":"Free: add Dependency Security and CI/CD Trust","url":"https://open-vsx.org/extension/ecocitizenz/eczid-dependency-security","note":"Free sibling extensions that review lockfile, dependency and pipeline provenance evidence in this workspace.","kind":"free-tool","whenNotObserved":["sbom.machinereadable","sbom.lockfile","sbom.provenance"],"rank":9},{"id":"sbom-cra-guidance","label":"Read the SBOM & CRA guidance","url":"https://developers.ecocitizenz.com/sbom/","note":"What each evidence class means, what the reporting windows ask for, and how to make the evidence verifiable. Developer Gateway, documentation only.","kind":"guidance","always":true,"rank":8},{"id":"cyber-resilience-passport","label":"Cyber Resilience Passport (TrustOps)","url":"https://trustops.ecocitizenz.com/start?flow=critical-cyber-resilience","note":"Once disclosure and vulnerability-handling evidence exists, make the posture resolver-verifiable so buyers stop asking for documents.","kind":"product","whenNotObserved":["sbom.vex","sbom.disclosure"],"rank":7},{"id":"software-supply-chain-passport","label":"Software Supply Chain Passport (TrustOps)","url":"https://trustops.ecocitizenz.com/start?flow=api-software","note":"An SBOM exists here. Make your software supply-chain posture resolver-verifiable for buyers, auditors and platforms.","kind":"product","whenObserved":["sbom.machinereadable"],"rank":7},{"id":"verified-or-assured-eczid","label":"Verified or Assured ECZ-ID","url":"https://trustops.ecocitizenz.com/start#parent-tiers","note":"The public identity spine every ECZ-ID credential attaches to. Counterparties check it in Resolver.","kind":"identity","always":true,"rank":3}],"discovery":{"label":"View all relevant SBOM / CRA products","url":"https://developers.ecocitizenz.com/dora-sbom-suite/"}};
const EXTRA_DOT_ENTRIES = [];

const args = process.argv.slice(2);
const wantJson = args.includes("--json");
const root = args.find((a) => !a.startsWith("--")) ?? process.cwd();
const { resolve: resolvePath, basename } = await import("node:path");
const { statSync } = await import("node:fs");
const abs = resolvePath(root);
let st;
try { st = statSync(abs); } catch { console.error("not a directory: " + root); process.exit(2); }
if (!st.isDirectory()) { console.error("not a directory: " + root); process.exit(2); }
const files = listFiles(abs, { extraDotEntries: EXTRA_DOT_ENTRIES });
const result = detectEvidence(SPEC, files, basename(abs));
if (wantJson) console.log(JSON.stringify(projectReview(SPEC, result, PROFILE), null, 2));
else console.log(renderReview(SPEC, result, PROFILE));

SHA-256: 1b9fd3fed1a186d9f61b098669db189ab887214b5bc1a1a353d4ecd1f4608a65