Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Source continuation — 2026-10-12

- Add `artifacts --compare` to review actual corrected files against a preserved
JSON inspection from the same contract, with visible HTML/text/JSON transitions.
- Separate finding-category resolution and reconciliation count improvements
from business acceptance; reject invalid or incomplete comparison evidence.
- Bound historical reads to1MiB and existing report ceilings, reuse stable-file
reads and strict JSON parsing, and omit previous messages/record positions.
- Preserve source0.1.7 and existing public releases; no publishing is performed.

## 0.1.7 source candidate - 2026-10-10

- Add `artifacts --format html`: a self-contained offline delivery review with
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
> **Alpha · source candidate v0.1.7 · 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.

Source v0.1.7 adds an offline human acceptance report on top of bounded composite-key reconciliation. The published GitHub package
Source v0.1.7 adds an offline human acceptance report on top of bounded composite-key reconciliation.
Use `artifacts --compare <previous-artifacts.json>` to review whether a corrected
delivery resolves finding categories or improves individual reconciliation counts.
Changed contracts and incomplete history remain unavailable, without copying old
messages or record values. See the [correction review guide](docs/artifact-delivery.md#review-a-correction-against-its-previous-report).
The published GitHub package
is still v0.1.4 and npm is still 0.1.0; neither includes the new rule.

[![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)
Expand Down
37 changes: 37 additions & 0 deletions docs/artifact-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,43 @@ absent from the published GitHub0.1.4/npm0.1.0 packages.

## Per-key integer reconciliation

### Review a correction against its previous report

The source CLI accepts `--compare <previous-artifacts.json>`. Save an ordinary
JSON inspection before correcting a separate copy of the delivery, then inspect
the corrected directory using the same contract:

```bash
(set -C; node dist/cli/index.js artifacts --contract reviewed-contract.json \
--path preserved-original --format json > previous-artifacts.json)
(set -C; node dist/cli/index.js artifacts --contract reviewed-contract.json \
--path corrected-copy --compare previous-artifacts.json --format html > correction-review.html)
```

The first command may exit1 for a complete failed check; preserve that report.
The second reads at most1MiB of strict UTF-8 JSON through the existing bounded
stable-file reader. Invalid schema, duplicate keys, inconsistent digests/counts,
duplicate files, symlinks, over-limit input and read changes fail setup before
inspecting the current directory. Histories with an earlier derived comparison
are structurally checked; their derived transitions are not reused.

Matching contract hashes and ordered reconciliation declarations are required.
Unknown checks, failed Receipt binding, missing file-set identity or a saturated
256-finding list make comparison unavailable; no omitted finding is declared
resolved. Unavailable comparison returns exit2 and a failed overall result, while
`comparison.current_status` retains the independently observed current status.
Ordinary inspections without `--compare` keep their existing outputs and exits.

Comparable reports show new, ongoing and resolved **finding categories** by
code/file/column, and improved, regressed, mixed or unchanged defect counts per
declared reconciliation. Record identities and row positions are not compared;
the same counts can refer to different records. At most128 transitions are
retained and64 are displayed in HTML, with full-scope counts. Previous messages,
record positions and raw values are not forwarded. A report byte hash identifies
the caller-supplied history, which is not authenticated or a reviewed content
pin. Passing checks or fewer defects do not establish business approval. This
addition is source-only and does not change any public package or release.

Source candidate 0.1.6 supports `keyed_integer_sum_equals`. It is absent from
published npm0.1.0 and the existing GitHub0.1.4 package. Each side declares a CSV
path, required nonempty string `key`, and required integer `value` column.
Expand Down
38 changes: 27 additions & 11 deletions src/cli/commands/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { parseStrictJson } from "../../domain/strict-json.js";
import { inspectArtifactContent, type ArtifactFacts, type ArtifactFinding } from "../../domain/artifact-content.js";
import { createArtifactReferenceIndex } from "../../domain/artifact-references.js";
import { createArtifactReconciliationIndex, type ReconciliationSummary } from "../../domain/artifact-reconciliation.js";
import { artifactSetHash, compareArtifactReports, parseArtifactReport, type ArtifactComparison } from "../../domain/artifact-report-comparison.js";
import { redactLocalPaths } from "../../reporters/local-paths.js";
import { renderArtifactsHtml } from "../../reporters/artifacts-html.js";
import { readWorkspaceFile, scanStagedWorkspace, WorkspaceTreeError } from "../../sandbox/workspace-tree.js";
Expand All @@ -20,7 +21,7 @@ const manifestSchema = z.object({ schema: z.literal("skilltape.dev/delivery/v1")
files: z.array(z.object({ path: z.string().min(1).max(1024), bytes: z.number().int().min(0).max(16 * 1024 * 1024), sha256: sha }).strict()).max(10_000),
}).strict();

export type ArtifactsOptions = { contract: string; delivery?: string; path?: string; includeRowEvidence?: boolean };
export type ArtifactsOptions = { contract: string; delivery?: string; path?: string; includeRowEvidence?: boolean; compare?: string };
export type ArtifactReport = {
schema: "skillsync.artifacts-report/v1"; status: "passed" | "failed"; exitCode: number;
evidence: "physical-files"; execution: "not-run"; provenance: "not-authenticated";
Expand All @@ -29,22 +30,34 @@ export type ArtifactReport = {
files: Array<{ path: string; bytes: number; sha256: string; rows?: number }>;
findings: ArtifactFinding[];
reconciliations?: ReconciliationSummary[];
comparison?: ArtifactComparison;
};

function setHash(files: ArtifactReport["files"]): string {
const digest = createHash("sha256");
for (const file of [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)))) {
const size = Buffer.alloc(8); size.writeBigUInt64BE(BigInt(file.bytes));
digest.update(file.path); digest.update(Buffer.from([0])); digest.update(size); digest.update(file.sha256);
}
return digest.digest("hex");
}

function readJson(bytes: Uint8Array): unknown {
return parseStrictJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
}

export async function runArtifacts(options: ArtifactsOptions): Promise<ArtifactReport> {
if (!options.compare) return inspectArtifacts(options);
let before: ArtifactReport, bytes: Uint8Array;
try {
bytes = await readWorkspaceFile(options.compare, 1024 * 1024);
before = parseArtifactReport(readJson(bytes));
} catch {
return { schema: "skillsync.artifacts-report/v1", status: "failed", exitCode: 2,
evidence: "physical-files", execution: "not-run", provenance: "not-authenticated",
receipt_binding: "not-provided", files: [], findings: [{ code: "artifact.baseline-invalid" }] };
}
const report = await inspectArtifacts(options);
report.comparison = compareArtifactReports(before, report, hash(bytes));
if (report.comparison.status === "unavailable") {
report.status = "failed"; report.exitCode = 2;
if (report.findings.length < 256) report.findings.push({ code: "artifact.baseline-unavailable" });
}
return report;
}

async function inspectArtifacts(options: ArtifactsOptions): Promise<ArtifactReport> {
const report: ArtifactReport = { schema: "skillsync.artifacts-report/v1", status: "failed", exitCode: 1,
evidence: "physical-files", execution: "not-run", provenance: "not-authenticated",
receipt_binding: options.delivery ? "failed" : "not-provided", files: [], findings: [] };
Expand Down Expand Up @@ -117,7 +130,7 @@ export async function runArtifacts(options: ArtifactsOptions): Promise<ArtifactR
return report;
}
report.files.sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
report.artifact_set_sha256 = setHash(report.files);
report.artifact_set_sha256 = artifactSetHash(report.files);
if (manifest) {
if (manifest.artifact_set_sha256 !== report.artifact_set_sha256) issue("delivery.artifact-set-digest");
for (const file of report.files) {
Expand Down Expand Up @@ -151,6 +164,9 @@ export function renderArtifacts(report: ArtifactReport, format = "text"): string
const safe = redactLocalPaths(report);
if (format === "json") return JSON.stringify(safe, null, 2) + "\n";
return [`Artifact validation: ${safe.status}`, `Evidence: physical files; execution: not-run; receipt binding: ${safe.receipt_binding}`,
...(safe.comparison ? [`Baseline comparison: ${safe.comparison.status}${safe.comparison.reason ? `; ${safe.comparison.reason}` : ""}`,
...(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";
}
1 change: 1 addition & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export function createCli(io: CliIO = defaultCliIO): Command {
.requiredOption("--contract <path>", "JSON or YAML artifact contract")
.option("--delivery <path>", "SkillTape delivery bundle directory")
.option("--path <path>", "Standalone artifact directory; choose this or --delivery")
.option("--compare <path>", "Compare with a bounded prior artifacts JSON report from the same contract")
.option("--format <format>", "Output format: text, json or offline html", "text")
.action(async (options: ArtifactsOptions & { format?: string }) => {
if (options.format !== "text" && options.format !== "json" && options.format !== "html") throw new Error("artifacts format must be text, json or html");
Expand Down
112 changes: 112 additions & 0 deletions src/domain/artifact-report-comparison.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import type { ArtifactReport } from "../cli/commands/artifacts.js";

const sha = z.string().regex(/^[0-9a-f]{64}$/);
const count = z.number().int().min(0).max(100_000);
const status = z.enum(["passed", "failed"]);
const checkStatus = z.enum(["passed", "failed", "unknown"]);
const path = z.string().min(1).max(1024).refine(value => !value.startsWith("/") &&
!/^[A-Za-z]:/.test(value) && !/[\\\p{Cc}]/u.test(value) &&
value.split("/").every(part => part && part !== "." && part !== ".."));
const field = z.string().min(1).max(128);
const side = z.object({ path, key: z.union([field, z.array(field).min(2).max(4)]), value: field }).strict();
const change = z.enum(["improved", "regressed", "changed", "unchanged"]);
export const artifactComparisonSchema = z.object({
schema: z.literal("skillsync.artifact-comparison/v1"), status: z.enum(["compared", "unavailable"]),
reason: z.enum(["contract-changed", "incomplete-evidence", "check-declarations-changed"]).optional(),
baseline_report_sha256: sha, baseline_status: status, current_status: status,
baseline_artifact_set_sha256: sha.optional(), current_artifact_set_sha256: sha.optional(),
findings: z.object({ added: count, ongoing: count, resolved: count }).strict().optional(),
checks: z.array(z.object({ index: z.number().int().min(0).max(127), before: checkStatus,
after: checkStatus, change }).strict()).max(128).optional(),
}).strict();
export type ArtifactComparison = z.infer<typeof artifactComparisonSchema>;

const summary = z.object({ type: z.literal("keyed_integer_sum_equals"), source: side, target: side,
status: checkStatus, sourceKeys: count.optional(), targetKeys: count.optional(), missingKeys: count.optional(),
unexpectedKeys: count.optional(), valueMismatches: count.optional(),
recordEvidence: z.object({ total: count, truncated: z.boolean(), items: z.array(z.object({
kind: z.enum(["value_mismatch", "missing", "unexpected"]), sourceRows: z.array(count.min(1)).max(8),
targetRows: z.array(count.min(1)).max(8), sourceRowsTruncated: z.boolean(), targetRowsTruncated: z.boolean(),
}).strict()).max(64) }).strict().optional(),
}).strict().superRefine((item, context) => {
const values = [item.sourceKeys, item.targetKeys, item.missingKeys, item.unexpectedKeys, item.valueMismatches];
if (item.status === "unknown" ? values.some(value => value !== undefined) : values.some(value => value === undefined)) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "incomplete reconciliation counts" });
}
if (item.status === "passed" && [item.missingKeys, item.unexpectedKeys, item.valueMismatches].some(value => value !== 0)) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "inconsistent reconciliation status" });
}
});

const reportSchema = z.object({ schema: z.literal("skillsync.artifacts-report/v1"), status,
exitCode: z.number().int().min(0).max(2), evidence: z.literal("physical-files"), execution: z.literal("not-run"),
provenance: z.literal("not-authenticated"), receipt_binding: z.enum(["verified", "not-provided", "failed"]),
contract_sha256: sha.optional(), artifact_set_sha256: sha.optional(),
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(),
row: count.min(1).optional(), message: z.string().max(4096).optional(),
}).strict()).max(256), reconciliations: z.array(summary).max(128).optional(),
comparison: artifactComparisonSchema.optional(),
}).strict().superRefine((report, context) => {
if (new Set(report.files.map(file => file.path)).size !== report.files.length ||
report.files.reduce((total, file) => total + file.bytes, 0) > 64 * 1024 * 1024 ||
(report.artifact_set_sha256 !== undefined && report.artifact_set_sha256 !== artifactSetHash(report.files)) ||
(report.status === "passed" && (report.exitCode !== 0 || report.findings.length ||
report.reconciliations?.some(item => item.status !== "passed"))) ||
(report.status === "failed" && report.exitCode === 0)) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "inconsistent artifact report" });
}
});

export function artifactSetHash(files: ArtifactReport["files"]): string {
const digest = createHash("sha256");
for (const file of [...files].sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))) {
const size = Buffer.alloc(8); size.writeBigUInt64BE(BigInt(file.bytes));
digest.update(file.path); digest.update(Buffer.from([0])); digest.update(size); digest.update(file.sha256);
}
return digest.digest("hex");
}

export function parseArtifactReport(value: unknown): ArtifactReport {
return reportSchema.parse(value);
}

/** Caller-supplied history is not authenticated; never forward its messages or record positions. */
export function compareArtifactReports(before: ArtifactReport, after: ArtifactReport, digest: string): ArtifactComparison {
const result: ArtifactComparison = { schema: "skillsync.artifact-comparison/v1", status: "unavailable",
baseline_report_sha256: digest, baseline_status: before.status, current_status: after.status,
...(before.artifact_set_sha256 ? { baseline_artifact_set_sha256: before.artifact_set_sha256 } : {}),
...(after.artifact_set_sha256 ? { current_artifact_set_sha256: after.artifact_set_sha256 } : {}) };
if (!before.contract_sha256 || before.contract_sha256 !== after.contract_sha256) return { ...result, reason: "contract-changed" };
const left = before.reconciliations ?? [], right = after.reconciliations ?? [];
// A saturated finding list may have omitted defects; do not call them resolved.
if ([before, after].some(report => !report.artifact_set_sha256 || report.receipt_binding === "failed" ||
report.findings.length >= 256 || report.comparison?.status === "unavailable" ||
report.reconciliations?.some(item => item.status === "unknown"))) {
return { ...result, reason: "incomplete-evidence" };
}
const identity = (item: (typeof left)[number]) => JSON.stringify([item.type, item.source, item.target]);
if (left.length !== right.length || left.some((item, index) => identity(item) !== identity(right[index]))) {
return { ...result, reason: "check-declarations-changed" };
}
const findings = (report: ArtifactReport) => new Set(report.findings.map(item => JSON.stringify([item.code, item.path, item.field])));
const a = findings(before), b = findings(after);
result.status = "compared";
result.findings = { added: [...b].filter(key => !a.has(key)).length,
ongoing: [...b].filter(key => a.has(key)).length, resolved: [...a].filter(key => !b.has(key)).length };
result.checks = left.map((item, index) => {
const current = right[index];
const x = [item.missingKeys!, item.unexpectedKeys!, item.valueMismatches!];
const y = [current.missingKeys!, current.unexpectedKeys!, current.valueMismatches!];
const equal = x.every((value, i) => value === y[i]);
const kind = item.status !== current.status ? current.status === "passed" ? "improved" : "regressed" :
equal ? "unchanged" : y.every((value, i) => value <= x[i]) ? "improved" :
y.every((value, i) => value >= x[i]) ? "regressed" : "changed";
return { index, before: item.status, after: current.status, change: kind };
});
return result;
}
Loading
Loading