From 671682b5141115ac93c135df1cdbf65a146c92e6 Mon Sep 17 00:00:00 2001 From: Chumaniac <121109854+Chumaniac@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:14:24 +0800 Subject: [PATCH] feat: locate reference metadata failures in offline review --- CHANGELOG.md | 6 +++ README.md | 18 +++----- docs/artifact-delivery.md | 7 +++ fixtures/product/citation-metadata/README.md | 2 + package-lock.json | 4 +- package.json | 2 +- src/cli/commands/artifacts.ts | 2 +- src/domain/artifact-content.ts | 3 +- src/domain/artifact-references.ts | 9 ++-- src/domain/artifact-report-comparison.ts | 5 ++- src/reporters/artifacts-html.ts | 33 ++++++++++---- tests/cli/help.test.ts | 2 +- tests/cli/report.test.ts | 4 +- tests/docs/documentation.test.ts | 6 +-- tests/integration/reference-values.test.ts | 47 +++++++++++++++++++- tests/reporters/artifacts-html.test.ts | 23 ++++++++++ tests/reporters/sarif.test.ts | 2 +- 17 files changed, 139 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aee19c..18c7e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.1 - 2026-10-19 + +- Locate citation metadata failures in both the delivered table and referenced table, including data-record positions and declared column mappings. Raw IDs and cell values remain omitted. +- Add a direct offline review link and replace unrelated zero-value statistic cards with inspected-file and reported-finding counts. +- Retain legacy baseline reports, accept all eight declared metadata mappings, and bound expanded HTML reference details to32 findings. Checks remain local and read-only. + ## 0.2.0 - 2026-10-19 - Distribute exact citation digest/revision checks in the installable GitHub CLI diff --git a/README.md b/README.md index ce5d01a..1a0b464 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,10 @@ **Verify Agent Skills before you trust them.** SkillSync checks a local Skill's provenance, compatibility, and changes without executing it. -> **Alpha · GitHub release v0.2.0 · Node.js 20+** +> **Alpha · GitHub release v0.2.1 · Node.js 20+** > SkillSync performs offline checks of local Skill content. It does not execute Skill scripts, does not read credentials, and does not enable live provider, remote-worker, or runtime capabilities. -GitHub v0.2.0 binds citations to exact source metadata, including digest and -revision, and retains the offline human acceptance report, correction review -and bounded composite-key reconciliation. Mismatched metadata produces an -explicit finding without copying record values into the report. The npm -registry remains at 0.1.0; use the verified GitHub archive for current features. +GitHub v0.2.1 locates citation metadata failures by file, data record and compared columns. It checks exact digests and revisions without copying record values into reports. The offline review, correction workflow and bounded composite-key reconciliation remain available. The npm registry stays at 0.1.0; use the verified GitHub archive for current features. [![Terminal demo](https://raw.githubusercontent.com/Chumaniac/skillsync/main/docs/assets/verify-demo.svg)](https://github.com/Chumaniac/skillsync/blob/main/docs/assets/verify-demo.svg) @@ -19,7 +15,7 @@ registry remains at 0.1.0; use the verified GitHub archive for current features. Tagged builds now publish a CLI tarball, checksums, a validated CycloneDX SBOM and a source manifest on [GitHub Releases](https://github.com/Chumaniac/skillsync/releases). -[GitHub v0.2.0](https://github.com/Chumaniac/skillsync/releases/tag/v0.2.0) +[GitHub v0.2.1](https://github.com/Chumaniac/skillsync/releases/tag/v0.2.1) is distributed as a versioned CLI tarball with checksums, SBOM, source manifest and GitHub provenance. Its release workflow requires the exact immutable tag and an already successful main check before packaging. Earlier v0.1.7 assets @@ -28,12 +24,12 @@ Installation uses Node.js 20+ and fetches the package's public dependencies; it needs no npm login. ```bash -release_base="https://github.com/Chumaniac/skillsync/releases/download/v0.2.0" -for asset in chumanic-skillsync-0.2.0.tgz checksums.txt sbom.cyclonedx.json release-manifest.json; do +release_base="https://github.com/Chumaniac/skillsync/releases/download/v0.2.1" +for asset in chumanic-skillsync-0.2.1.tgz checksums.txt sbom.cyclonedx.json release-manifest.json; do curl --fail --location --output "$asset" "$release_base/$asset" done shasum -a 256 -c checksums.txt -npm install --prefix ./skillsync-tools ./chumanic-skillsync-0.2.0.tgz +npm install --prefix ./skillsync-tools ./chumanic-skillsync-0.2.1.tgz ./skillsync-tools/node_modules/.bin/skillsync --version ``` @@ -51,7 +47,7 @@ npm install -g @chumanic/skillsync@0.1.0 skillsync verify --path . --target codex ``` -**From source (0.2.0):** +**From source (0.2.1):** ```bash git clone https://github.com/Chumaniac/skillsync.git diff --git a/docs/artifact-delivery.md b/docs/artifact-delivery.md index a440b92..dc5f5cb 100644 --- a/docs/artifact-delivery.md +++ b/docs/artifact-delivery.md @@ -338,3 +338,10 @@ The interface is informed by source-linked citation review in code or dependencies were copied. This local check proves consistency between observed metadata only. It does not fetch the cited source, authenticate a publisher, or establish that the underlying source bytes or claims are true. + + +### Locate a mismatched reference in GitHub 0.2.1 + +Metadata mismatch and unavailable findings include an optional `reference` object: the declared target path, lookup column, matched data-record position, and compared source/target column names. It contains no record ID or cell value. JSON and text retain these coordinates; the offline HTML displays the first32 reference locations with up to8 mappings each and links directly to the review section. Counts still cover all reported findings. Data-record positions exclude the CSV header, including for quoted multiline records. + +The baseline reader continues accepting older reports without this context. Reference context uses the same relative-path, row and eight-column bounds as the current contract. It locates evidence for review; it does not fetch sources, authenticate authors or approve corrections. diff --git a/fixtures/product/citation-metadata/README.md b/fixtures/product/citation-metadata/README.md index f355e7f..d1aff05 100644 --- a/fixtures/product/citation-metadata/README.md +++ b/fixtures/product/citation-metadata/README.md @@ -18,3 +18,5 @@ the declared metadata columns. It does not copy the ID or cell values into JSON, text or HTML. Preserve the original example when trying a correction. See the [contract guide](../../../docs/artifact-delivery.md#bind-citations-to-exact-source-metadata). + +GitHub0.2.1 adds the referenced filename, matched data-record position and compared column mapping to the offline review. Use the review link to jump to the finding. IDs and metadata values remain omitted; preserve originals while checking a copied correction. diff --git a/package-lock.json b/package-lock.json index ffffeff..e7aabcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chumanic/skillsync", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chumanic/skillsync", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index d0631bf..3967b0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chumanic/skillsync", - "version": "0.2.0", + "version": "0.2.1", "private": false, "publishConfig": { "access": "public" diff --git a/src/cli/commands/artifacts.ts b/src/cli/commands/artifacts.ts index 9ebcfdd..36c0a4a 100644 --- a/src/cli/commands/artifacts.ts +++ b/src/cli/commands/artifacts.ts @@ -176,5 +176,5 @@ export function renderArtifacts(report: ArtifactReport, format = "text"): string ...(safe.comparison.findings ? [`Finding categories: ${safe.comparison.findings.added} new; ${safe.comparison.findings.ongoing} ongoing; ${safe.comparison.findings.resolved} resolved`] : [])] : []), ...(safe.comparison?.checks ? [`Record check changes: ${safe.comparison.checks.filter(check => check.change === "improved").length} improved; ${safe.comparison.checks.filter(check => check.change === "regressed").length} regressed; ${safe.comparison.checks.filter(check => check.change === "changed").length} mixed changes; ${safe.comparison.checks.filter(check => check.change === "unchanged").length} unchanged`] : []), `Files: ${safe.files.length}`, ...(safe.reconciliations ?? []).map(item => `Record reconciliation: ${item.status}${item.status !== "unknown" ? `; source keys=${item.sourceKeys}; target keys=${item.targetKeys}; missing=${item.missingKeys}; unexpected=${item.unexpectedKeys}; mismatched values=${item.valueMismatches}` : ""}`), - ...safe.findings.map(finding => `${finding.code}${finding.path ? ` file=${JSON.stringify(finding.path)}` : ""}${finding.field ? ` field=${JSON.stringify(finding.field)}` : ""}${finding.row !== undefined ? ` row=${finding.row}` : ""}${finding.message ? ` ${finding.message}` : ""}`)].join("\n") + "\n"; + ...safe.findings.map(finding => `${finding.code}${finding.path ? ` file=${JSON.stringify(finding.path)}` : ""}${finding.field ? ` field=${JSON.stringify(finding.field)}` : ""}${finding.row !== undefined ? ` row=${finding.row}` : ""}${finding.reference ? ` reference=${JSON.stringify(finding.reference)}` : ""}${finding.message ? ` ${finding.message}` : ""}`)].join("\n") + "\n"; } diff --git a/src/domain/artifact-content.ts b/src/domain/artifact-content.ts index 6c51ff7..6e577fc 100644 --- a/src/domain/artifact-content.ts +++ b/src/domain/artifact-content.ts @@ -1,7 +1,8 @@ import type { ArtifactFileRule, ArtifactScalarRule } from "./artifact-contract.js"; import { parseStrictJson } from "./strict-json.js"; -export type ArtifactFinding = { code: string; path?: string; field?: string | string[]; row?: number; message?: string }; +export type ArtifactFinding = { code: string; path?: string; field?: string | string[]; row?: number; message?: string; + reference?: { path: string; field: string; row: number; matches: Array<{ source: string; target: string }> } }; export type ArtifactFacts = { rows?: number; integers: Map }; export type ArtifactCellObserver = (field: string, value: string, row: number) => void; export type ArtifactRowObserver = (values: ReadonlyMap, dataRecord?: number) => void; diff --git a/src/domain/artifact-references.ts b/src/domain/artifact-references.ts index 7d5c388..967f9fc 100644 --- a/src/domain/artifact-references.ts +++ b/src/domain/artifact-references.ts @@ -85,16 +85,19 @@ export function createArtifactReferenceIndex(contract: ArtifactContract) { for (const [position, check] of checks.entries()) { const targets = index(check.target.path, check.target.field).targets!; for (const source of index(check.source.path, check.source.field).sources!) { - if (!targets.has(source.digest)) { + const targetRow = targets.get(source.digest); + if (targetRow === undefined) { findings.push({ code: "artifact.reference-missing", path: check.source.path, field: check.source.field, row: source.row }); if (findings.length >= 256) return findings; } else if (check.matches) { const sourceValue = metadata[position].source.get(source.row); - const targetValue = metadata[position].target.get(targets.get(source.digest)!); + const targetValue = metadata[position].target.get(targetRow); const unavailable = sourceValue === undefined || targetValue === undefined; if (unavailable || sourceValue !== targetValue) { findings.push({ code: unavailable ? "artifact.reference-value-unavailable" : "artifact.reference-value-mismatch", - path: check.source.path, field: check.matches.map(pair => pair.source), row: source.row }); + path: check.source.path, field: check.matches.map(pair => pair.source), row: source.row, + reference: { path: check.target.path, field: check.target.field, row: targetRow, + matches: check.matches.map(pair => ({ source: pair.source, target: pair.target })) } }); if (findings.length >= 256) return findings; } } diff --git a/src/domain/artifact-report-comparison.ts b/src/domain/artifact-report-comparison.ts index 4caf0d9..0efcb38 100644 --- a/src/domain/artifact-report-comparison.ts +++ b/src/domain/artifact-report-comparison.ts @@ -47,8 +47,11 @@ const reportSchema = z.object({ schema: z.literal("skillsync.artifacts-report/v1 files: z.array(z.object({ path, bytes: z.number().int().min(0).max(16 * 1024 * 1024), sha256: sha, rows: count.optional() }).strict()).max(10_000), findings: z.array(z.object({ code: z.string().min(1).max(128).regex(/^[a-z][a-z0-9.-]*$/), - path: path.optional(), field: z.union([field, z.array(field).min(1).max(4)]).optional(), + path: path.optional(), field: z.union([field, z.array(field).min(1).max(8)]).optional(), row: count.min(1).optional(), message: z.string().max(4096).optional(), + reference: z.object({ path, field, row: count.min(1), + matches: z.array(z.object({ source: field, target: field }).strict()).min(1).max(8), + }).strict().optional(), }).strict()).max(256), reconciliations: z.array(summary).max(128).optional(), comparison: artifactComparisonSchema.optional(), }).strict().superRefine((report, context) => { diff --git a/src/reporters/artifacts-html.ts b/src/reporters/artifacts-html.ts index b7374d2..a1f8bd2 100644 --- a/src/reporters/artifacts-html.ts +++ b/src/reporters/artifacts-html.ts @@ -6,6 +6,7 @@ import { redactLocalPaths } from "./local-paths.js"; const FILE_DETAILS = 100; const CHECK_DETAILS = 64; +const REFERENCE_DETAILS = 32; const TEXT_LENGTH = 160; // Only metadata supplied by the existing inspector reaches this offline view. @@ -33,10 +34,18 @@ function action(code: string): string { return "Review the declared file, field and requirement below. Keep original evidence and check again after an approved correction."; } -function findingRow(finding: ArtifactFinding): string { +function findingRow(finding: ArtifactFinding, budget: { remaining: number }): string { + const reference = finding.reference; + const showReference = reference !== undefined && budget.remaining > 0; + if (showReference) budget.remaining -= 1; + const location = showReference ? `
+ Compare with ${text(reference.path)} + Lookup column: ${text(reference.field)} · Data record: ${text(reference.row)} + Compared columns
    ${reference.matches.slice(0, 8).map(pair => `
  • ${text(pair.source)} → ${text(pair.target)}
  • `).join("")}
+ One or more of these columns differ or are unavailable. Record positions exclude the header; IDs and cell values are omitted.
` : reference ? "Reference-location details are limited in this view. Use the JSON report for the full metadata." : ""; return `${text(finding.code)}${text(finding.path ?? "Whole delivery")} ${finding.field ? `Field: ${columns(finding.field)}` : ""} - ${finding.row !== undefined ? `Row: ${text(finding.row)}` : ""} + ${finding.row !== undefined ? `Data record: ${text(finding.row)}` : ""}${location} ${finding.message ? `

${text(finding.message)}

` : ""}${action(finding.code)}`; } @@ -85,28 +94,36 @@ export function renderArtifactsHtml(rawReport: ArtifactReport): string { const incomplete = checks.filter(check => check.status === "unknown").length; const failedChecks = checks.filter(check => check.status === "failed").length; const passed = report.status === "passed"; - const title = passed ? "Declared checks passed" : report.exitCode === 2 ? "Check setup required" : "Review required"; + const referenceOnly = report.findings.length > 0 && report.findings.every(finding => + finding.code === "artifact.reference-value-mismatch" || finding.code === "artifact.reference-value-unavailable"); + const title = passed ? "Declared checks passed" : report.exitCode === 2 ? "Check setup required" + : referenceOnly ? "Reference metadata needs review" : "Review required"; const csp = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; const files = report.files.slice(0, FILE_DETAILS); // Failed and incomplete checks stay visible before successful checks. const ordered = [...checks.filter(check => check.status !== "passed"), ...checks.filter(check => check.status === "passed")]; const rowBudget = { remaining: 64 }; + const referenceBudget = { remaining: REFERENCE_DETAILS }; + const referenceCount = report.findings.filter(finding => finding.reference).length; return ` SkillSync delivery review — ${title}
SkillSync
Offline delivery review
${passed ? "Passed" : "Not accepted"}

${title}

-

${passed ? "The observed files satisfy the declared local checks. Review the expectations and responsible approval before using this result." : "This delivery has findings or incomplete checks. Use the file and identity metadata below to review the affected requirement."}

-
${report.files.length}Observed files
${report.findings.length}Review findings
${failedChecks}Failed record checks
${incomplete}Incomplete record checks
+

${passed ? "The inspected files match this contract. Review the expectations and responsible approval before using the result." : "Start with the reported file and field locations below. Keep the original delivery while reviewing corrections."}

+

${report.files.length} inspected ${report.files.length === 1 ? "file" : "files"} · ${report.findings.length} reported ${report.findings.length === 1 ? "finding" : "findings"}${checks.length ? ` · ${failedChecks} failed and ${incomplete} incomplete allocation checks` : ""}

+${report.findings.length ? `Review ${report.findings.length} reported ${report.findings.length === 1 ? "finding" : "findings"}` : "Review inspected files"}
Receipt / file binding${text(report.receipt_binding)}Hash consistency with supplied local evidence; author identity is not authenticated.
${comparisonSection(report)}

Allocation and identity checks

Showing ${Math.min(CHECK_DETAILS, checks.length)} of ${checks.length} reconciliation checks. Failed and incomplete checks appear first.

${ordered.slice(0, CHECK_DETAILS).map(check => reconciliationCard(check, rowBudget)).join("") || "

No record reconciliation was declared. Other declared file checks are reflected in the result.

"}
-

Review actions

${report.findings.length ? `
${report.findings.slice(0, 256).map(findingRow).join("")}
FindingAffected metadataNext review step
` : "

No finding was reported for the declared scope. This does not authenticate source truth or establish business approval.

"}
-

Observed files

Showing ${files.length} of ${report.files.length} files. Metadata values longer than 160 characters are shortened; the original JSON report retains full metadata.

+

Review actions

${referenceCount > REFERENCE_DETAILS ? `

Reference locations: first ${REFERENCE_DETAILS} of ${referenceCount} reported findings shown. The JSON report retains all location metadata.

` : ""}${report.findings.length ? `
${report.findings.slice(0, 256).map(finding => findingRow(finding, referenceBudget)).join("")}
FindingAffected metadataNext review step
` : "

No finding was reported for the declared scope. This does not authenticate source truth or establish business approval.

"}
+

Observed files

Showing ${files.length} of ${report.files.length} files. Metadata values longer than 160 characters are shortened; the original JSON report retains full metadata.

${files.map(file => ``).join("") || ''}
Declared pathBytesRowsSHA-256
${text(file.path)}${text(file.bytes)}${text(file.rows)}${text(file.sha256)}
No accepted file observation is available.
Evidence boundaries and snapshot identity

This is a local inspection snapshot. It does not execute a Skill, authenticate an author, or replace reviewed expectations and business approval.

Evidence
physical-files
Execution
execution: not-run
Provenance
provenance: not-authenticated
Contract SHA-256
${text(report.contract_sha256)}
Artifact set SHA-256
${text(report.artifact_set_sha256)}
diff --git a/tests/cli/help.test.ts b/tests/cli/help.test.ts index ba9d87a..4147a68 100644 --- a/tests/cli/help.test.ts +++ b/tests/cli/help.test.ts @@ -12,7 +12,7 @@ describe("skillsync CLI", () => { it("reports the installed source version and lists the verification commands", async () => { const version = await runCli(["--version"]); expect(version.exitCode).toBe(0); - expect(version.stdout).toBe("0.2.0\n"); + expect(version.stdout).toBe("0.2.1\n"); const result = await runCli(["--help"]); diff --git a/tests/cli/report.test.ts b/tests/cli/report.test.ts index 053e510..d0fd606 100644 --- a/tests/cli/report.test.ts +++ b/tests/cli/report.test.ts @@ -255,7 +255,7 @@ describe("skillsync report", () => { const json = await runCli(["report", "--before", beforePath, "--after", afterPath, "--format", "json"]); expect(JSON.parse(json.stdout).conclusion).toBe("verified"); - expect(JSON.parse(json.stdout).toolVersion).toBe("0.2.0"); + expect(JSON.parse(json.stdout).toolVersion).toBe("0.2.1"); const sarif = await runCli(["report", "--before", beforePath, "--after", afterPath, "--format", "sarif"]); const parsedSarif = JSON.parse(sarif.stdout) as { @@ -266,7 +266,7 @@ describe("skillsync report", () => { }>; }; expect(parsedSarif.version).toBe("2.1.0"); - expect(parsedSarif.runs[0]?.tool.driver.version).toBe("0.2.0"); + expect(parsedSarif.runs[0]?.tool.driver.version).toBe("0.2.1"); expect(parsedSarif.runs[0]?.results[0]?.properties.issueId).toMatch(/^iss_/); }); diff --git a/tests/docs/documentation.test.ts b/tests/docs/documentation.test.ts index dcbc9b3..dcdd041 100644 --- a/tests/docs/documentation.test.ts +++ b/tests/docs/documentation.test.ts @@ -39,10 +39,10 @@ describe("release documentation", () => { }; expect(readme).toContain("Verify Agent Skills before you trust them."); - expect(readme).toContain("Alpha · GitHub release v0.2.0 · Node.js 20+"); + expect(readme).toContain("Alpha · GitHub release v0.2.1 · Node.js 20+"); expect(readme).toContain("GitHub Releases"); - expect(readme).toContain("download/v0.2.0"); - expect(readme).toContain("chumanic-skillsync-0.2.0.tgz"); + expect(readme).toContain("download/v0.2.1"); + expect(readme).toContain("chumanic-skillsync-0.2.1.tgz"); expect(readme).toContain("## Install"); expect(readme).toContain("npx --yes @chumanic/skillsync@0.1.0 verify"); expect(readme).toContain([ diff --git a/tests/integration/reference-values.test.ts b/tests/integration/reference-values.test.ts index 667c8cb..df43eeb 100644 --- a/tests/integration/reference-values.test.ts +++ b/tests/integration/reference-values.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { renderArtifacts, runArtifacts } from "../../src/cli/commands/artifacts"; import { parseArtifactContract } from "../../src/domain/artifact-contract"; import { createArtifactReferenceIndex } from "../../src/domain/artifact-references"; +import { parseArtifactReport } from "../../src/domain/artifact-report-comparison"; const dirs: string[] = []; afterEach(async () => { await Promise.all(dirs.splice(0).map(path => rm(path, { recursive: true, force: true }))); }); @@ -35,7 +36,9 @@ describe("reference metadata binding", () => { for (const row of ["PRIVATE_ID,PRIVATE_DIGEST,v1", "PRIVATE_ID,known,PRIVATE_VERSION"]) { const report = await runArtifacts(await fixture(`source,digest,version\n${row}\n`)); expect(report.exitCode).toBe(1); - expect(report.findings).toEqual([{ code: "artifact.reference-value-mismatch", path: "citations.csv", field: ["digest", "version"], row: 1 }]); + expect(report.findings).toEqual([{ code: "artifact.reference-value-mismatch", path: "citations.csv", field: ["digest", "version"], row: 1, + reference: { path: "sources.csv", field: "id", row: 1, + matches: [{ source: "digest", target: "sha256" }, { source: "version", target: "revision" }] } }]); const rendered = ["json", "text", "html"].map(format => renderArtifacts(report, format)).join(""); for (const value of ["PRIVATE_ID", "PRIVATE_DIGEST", "PRIVATE_VERSION"]) expect(rendered).not.toContain(value); } @@ -44,10 +47,52 @@ describe("reference metadata binding", () => { const report = await runArtifacts(await fixture("source,digest,version\nPRIVATE_ID,a|b,c\n", "id,sha256,revision\nPRIVATE_ID,a,b|c\n")); expect(report.findings[0].code).toBe("artifact.reference-value-mismatch"); }); + it("locates the referenced table and data record without exposing identities or metadata values", async () => { + const report = await runArtifacts(await fixture("source,digest,version\nPRIVATE_ID,PRIVATE_DIGEST,v1\n", + "id,sha256,revision\nOTHER_ID,other,v1\nPRIVATE_ID,known,v1\n")); + expect(report.findings[0]).toMatchObject({ path: "citations.csv", row: 1, + reference: { path: "sources.csv", field: "id", row: 2, + matches: [{ source: "digest", target: "sha256" }, { source: "version", target: "revision" }] } }); + const reparsed = parseArtifactReport(report); + expect(reparsed.findings).toEqual(report.findings); + const html = renderArtifacts(report, "html"); + expect(html).toContain("

Reference metadata needs review

"); + expect(html).toContain('data-reference-path="sources.csv"'); + expect(html).toContain('data-reference-row="2"'); + expect(html).toContain('data-source-column="digest"'); + expect(html).toContain('data-target-column="sha256"'); + expect(html).toContain('href="#issue-title"'); + for (const value of ["PRIVATE_ID", "PRIVATE_DIGEST", "OTHER_ID"]) { + for (const format of ["json", "text", "html"]) expect(renderArtifacts(report, format)).not.toContain(value); + } + }); it("keeps missing IDs separate from metadata mismatches", async () => { const report = await runArtifacts(await fixture("source,digest,version\nMISSING,known,v1\n")); expect(report.findings).toEqual([{ code: "artifact.reference-missing", path: "citations.csv", field: "source", row: 1 }]); }); + it("accepts all eight declared mappings in new and legacy baselines and rejects unsafe extra context", async () => { + const contract = declaration(); + const sourceColumns = Array.from({ length: 8 }, (_, i) => `meta${i}`); + const targetColumns = Array.from({ length: 8 }, (_, i) => `reference${i}`); + contract.files[0].columns = ["source", ...sourceColumns].map(name => ({ name, type: "string" })); + contract.files[1].columns = ["id", ...targetColumns].map(name => ({ name, type: "string" })); + contract.checks[0].matches = sourceColumns.map((source, i) => ({ source, target: targetColumns[i] })); + const options = await fixture(["source", ...sourceColumns].join(",") + "\nPRIVATE_ID," + Array(8).fill("a").join(",") + "\n", + ["id", ...targetColumns].join(",") + "\nPRIVATE_ID," + Array(8).fill("b").join(",") + "\n", contract); + const report = await runArtifacts(options); + expect(parseArtifactReport(report).findings[0].reference?.matches).toHaveLength(8); + const legacy = structuredClone(report); + delete legacy.findings[0].reference; + expect(parseArtifactReport(legacy).findings).toEqual(legacy.findings); + for (const change of ["overflow", "escape", "raw-value", "invalid-row"] as const) { + const invalid = structuredClone(report); const reference = invalid.findings[0].reference!; + if (change === "overflow") reference.matches.push(reference.matches[0]); + if (change === "escape") reference.path = "../private.csv"; + if (change === "invalid-row") reference.row = 0; + if (change === "raw-value") Object.assign(reference, { value: "PRIVATE_CELL_VALUE" }); + expect(() => parseArtifactReport(invalid)).toThrow(); + } + }); it("never accepts missing metadata or duplicate target identities", async () => { const missing = await runArtifacts(await fixture("source,digest,version\nPRIVATE_ID,,v1\n")); expect(missing.exitCode).toBe(1); diff --git a/tests/reporters/artifacts-html.test.ts b/tests/reporters/artifacts-html.test.ts index 94df369..e0a2c66 100644 --- a/tests/reporters/artifacts-html.test.ts +++ b/tests/reporters/artifacts-html.test.ts @@ -81,4 +81,27 @@ describe("offline artifact acceptance HTML", () => { expect(html.includes("x".repeat(161))).toBe(false); expect(Buffer.byteLength(html)).toBeLessThan(2 * 1024 * 1024); }); + it("escapes and bounds reference locations and column mappings", () => { + const value = report(); value.status = "failed"; value.exitCode = 1; + value.findings = [{ code: "artifact.reference-value-mismatch", path: "citations.csv", row: 1, + reference: { path: "/private/tmp/PRIVATE_REFERENCE_PATH", field: "", row: 2, + matches: Array.from({ length: 20 }, () => ({ source: '', target: '' })) } }]; + const html = renderArtifacts(value, "html"); + expect(html).not.toContain("PRIVATE_REFERENCE_PATH"); + expect(html).not.toMatch(/ { + const value = report(); value.status = "failed"; value.exitCode = 1; + value.findings = Array.from({ length: 256 }, () => ({ code: "artifact.reference-value-mismatch", path: "citations.csv", row: 1, + reference: { path: "sources.csv", field: "id", row: 2, + matches: Array.from({ length: 8 }, () => ({ source: "&".repeat(128), target: "&".repeat(128) })) } })); + const html = renderArtifacts(value, "html"); + expect(html).toContain("first 32 of 256 reported findings shown"); + expect(html.match(/data-reference-path=/gu)).toHaveLength(32); + expect(html.match(/artifact[.]reference-value-mismatch/gu)).toHaveLength(256); + expect(Buffer.byteLength(html)).toBeLessThan(2 * 1024 * 1024); + }); }); diff --git a/tests/reporters/sarif.test.ts b/tests/reporters/sarif.test.ts index eb66c42..a2599b6 100644 --- a/tests/reporters/sarif.test.ts +++ b/tests/reporters/sarif.test.ts @@ -44,7 +44,7 @@ describe("renderSarif", () => { expect(sarif.version).toBe("2.1.0"); expect(sarif.runs[0]?.tool.driver.name).toBe("skillsync"); - expect(sarif.runs[0]?.tool.driver.version).toBe("0.2.0"); + expect(sarif.runs[0]?.tool.driver.version).toBe("0.2.1"); expect(sarif.runs[0]?.tool.driver.rules[0]?.id).toBe("structure.missing-reference"); expect(sarif.runs[0]?.tool.driver.rules[0]?.help?.text).toBe("Add the referenced file."); expect(sarif.runs[0]?.results[0]?.ruleId).toBe("structure.missing-reference");