From 5081556943976677227123f28aec393570beef50 Mon Sep 17 00:00:00 2001 From: Chumaniac <121109854+Chumaniac@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:20:56 +0800 Subject: [PATCH 1/3] feat: validate bounded cross-file artifact references --- CHANGELOG.md | 4 + docs/artifact-delivery.md | 16 ++++ .../product/reference-integrity/README.md | 28 ++++++ .../artifacts/citations.csv | 4 + .../reference-integrity/artifacts/sources.csv | 3 + .../product/reference-integrity/contract.json | 13 +++ src/cli/commands/artifacts.ts | 6 +- src/domain/artifact-content.ts | 7 +- src/domain/artifact-contract.ts | 15 +++- src/domain/artifact-references.ts | 59 ++++++++++++ tests/integration/artifact-references.test.ts | 90 +++++++++++++++++++ 11 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 fixtures/product/reference-integrity/README.md create mode 100644 fixtures/product/reference-integrity/artifacts/citations.csv create mode 100644 fixtures/product/reference-integrity/artifacts/sources.csv create mode 100644 fixtures/product/reference-integrity/contract.json create mode 100644 src/domain/artifact-references.ts create mode 100644 tests/integration/artifact-references.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db5e9dc..1c13ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Source artifact contracts support bounded CSV `reference_exists` checks with + exact string IDs and declared unique target keys. An offline citation example + rejects dangling references without exposing values; the aggregate index is + capped at 100,000 referenced cells, including repeats. - Source string artifact fields support bounded exact `one_of` options for classification labels, with a physical CSV/JSON material-intake example. Unknown labels fail without value disclosure; classifications do not prove approval. diff --git a/docs/artifact-delivery.md b/docs/artifact-delivery.md index 53998ce..6f2b215 100644 --- a/docs/artifact-delivery.md +++ b/docs/artifact-delivery.md @@ -67,6 +67,22 @@ the actual string values. checks cover declared top-level fields, not full JSON Schema or arbitrary expressions. Contract files are limited to 64 KiB. Limits must be explicit and within the shared 10,000-file/16 MiB-file/64 MiB-total workspace ceilings. + +`reference_exists` checks an exact nonempty string ID from one CSV against a +declared single-column `unique_by` key in another CSV. Both columns must be +declared required strings with `allow_empty: false`. File traversal order does +not affect references. IDs are not trimmed, normalized or folded; numeric IDs, +JSON references and composite keys are unsupported. Existing scalar, formula, +duplicate-key and physical inventory checks continue to fail independently. +The [synthetic citation catalog](../fixtures/product/reference-integrity/README.md) +includes a runnable contract and explains adaptations for review, knowledge and +operations data. Only declared reference fields are retained as SHA-256 +fingerprints with source row numbers. At most 100,000 referenced cells are +indexed per invocation, counting repeated IDs and deduplicating field declarations. +Exceeding this ceiling releases indexes and fails with `artifact.reference-capacity`. +Missing references report relative file, field and data-row number, never the ID +or fingerprint. This checks internal consistency without authenticating records. + Duplicate JSON keys are rejected, including escaped-key aliases in metadata and payloads. JSON depth is capped at 64 and object keys at 100,000; individual keys are at most 1,024 characters. CSV rows stop at the declared field count instead diff --git a/fixtures/product/reference-integrity/README.md b/fixtures/product/reference-integrity/README.md new file mode 100644 index 0000000..bbd03bd --- /dev/null +++ b/fixtures/product/reference-integrity/README.md @@ -0,0 +1,28 @@ +# Offline reference integrity + +This source-only synthetic example checks that three citation records reference +two existing, uniquely identified source records. Repeated citations are allowed. + +```bash +npm run build +node dist/cli/index.js artifacts \ + --contract fixtures/product/reference-integrity/contract.json \ + --path fixtures/product/reference-integrity/artifacts --format json +``` + +In a disposable copy, replacing `SOURCE2` in `citations.csv` with `UNKNOWN` +produces `artifact.reference-missing` for data row 2. Reports omit that value. +Duplicate source IDs fail the existing CSV uniqueness check. File names may be +ordered either way; all physical checks finish before references are compared. + +The same contract can bind review findings to a changed-file catalog, knowledge +claims to a source catalog, or orders to a customer catalog. IDs are exact +nonempty strings. The referenced target must declare its single-column unique +key. Composite keys, JSON references and numeric/coerced IDs are unsupported. +Only declared reference fields are indexed, using SHA-256 fingerprints; the +100,000-cell aggregate ceiling counts repeated IDs too. Indexes are released on +overflow, which fails with `artifact.reference-capacity`. + +This verifies local file consistency, not source truth, reviewer identity, +customer ownership or permission to perform business actions. It does not run a +Skill, fetch sources or activate a provider. Published npm0.1.0 lacks this command. diff --git a/fixtures/product/reference-integrity/artifacts/citations.csv b/fixtures/product/reference-integrity/artifacts/citations.csv new file mode 100644 index 0000000..59ac6b5 --- /dev/null +++ b/fixtures/product/reference-integrity/artifacts/citations.csv @@ -0,0 +1,4 @@ +claim_id,source_id +CLAIM1,SOURCE1 +CLAIM2,SOURCE2 +CLAIM3,SOURCE1 diff --git a/fixtures/product/reference-integrity/artifacts/sources.csv b/fixtures/product/reference-integrity/artifacts/sources.csv new file mode 100644 index 0000000..48b2c43 --- /dev/null +++ b/fixtures/product/reference-integrity/artifacts/sources.csv @@ -0,0 +1,3 @@ +source_id,title +SOURCE1,Fictional engineering note +SOURCE2,Fictional operations report diff --git a/fixtures/product/reference-integrity/contract.json b/fixtures/product/reference-integrity/contract.json new file mode 100644 index 0000000..13d62a1 --- /dev/null +++ b/fixtures/product/reference-integrity/contract.json @@ -0,0 +1,13 @@ +{ + "schema": "skillsync.artifacts/v1", + "limits": { "max_files": 2, "max_file_bytes": 4096, "max_total_bytes": 8192 }, + "files": [ + { "path": "citations.csv", "format": "csv", "max_rows": 100, + "columns": [{ "name": "claim_id", "type": "string" }, { "name": "source_id", "type": "string" }] }, + { "path": "sources.csv", "format": "csv", "max_rows": 100, "unique_by": ["source_id"], + "columns": [{ "name": "source_id", "type": "string" }, { "name": "title", "type": "string" }] } + ], + "checks": [{ "type": "reference_exists", + "source": { "path": "citations.csv", "field": "source_id" }, + "target": { "path": "sources.csv", "field": "source_id" } }] +} diff --git a/src/cli/commands/artifacts.ts b/src/cli/commands/artifacts.ts index 9bc7fcf..f9bcc2c 100644 --- a/src/cli/commands/artifacts.ts +++ b/src/cli/commands/artifacts.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { parseArtifactContract } from "../../domain/artifact-contract.js"; 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 { redactLocalPaths } from "../../reporters/local-paths.js"; import { readWorkspaceFile, scanStagedWorkspace, WorkspaceTreeError } from "../../sandbox/workspace-tree.js"; @@ -85,6 +86,7 @@ export async function runArtifacts(options: ArtifactsOptions): Promise(); + const references = createArtifactReferenceIndex(contract); try { const rules = new Map(contract.files.map(file => [file.path, file])); const tree = await scanStagedWorkspace(root, { @@ -94,7 +96,7 @@ export async function runArtifacts(options: ArtifactsOptions): Promise }; +export type ArtifactCellObserver = (field: string, value: string, row: number) => void; function* csvRows(text: string, maxFields: number): Generator { let field = "", row: string[] = [], quoted = false, afterQuote = false, active = false; @@ -83,7 +84,7 @@ function formula(value: string): boolean { return /^[\t\r\n]/.test(value) || /^[=+@\-\uFF1D\uFF0B\uFF0D\uFF20]/.test(value.trimStart()); } -export function inspectArtifactContent(rule: ArtifactFileRule, bytes: Uint8Array): { facts: ArtifactFacts; findings: ArtifactFinding[] } { +export function inspectArtifactContent(rule: ArtifactFileRule, bytes: Uint8Array, observe?: ArtifactCellObserver): { facts: ArtifactFacts; findings: ArtifactFinding[] } { const facts: ArtifactFacts = { integers: new Map() }, findings: ArtifactFinding[] = []; const issue = (code: string, field?: string, row?: number) => { if (findings.length < 64) findings.push({ code, path: rule.path, ...(field ? { field } : {}), ...(row !== undefined ? { row } : {}) }); @@ -127,8 +128,10 @@ export function inspectArtifactContent(rule: ArtifactFileRule, bytes: Uint8Array } for (const [index, column] of rule.columns.entries()) { const value = csvValue(row[index], column); - if (rule.reject_formulas && ["string", "date"].includes(column.type) && formula(row[index])) issue("artifact.csv-formula", column.name, count); + const riskyFormula = rule.reject_formulas && ["string", "date"].includes(column.type) && formula(row[index]); + if (riskyFormula) issue("artifact.csv-formula", column.name, count); if (!scalarValid(value, column)) { issue("artifact.csv-value", column.name, count); continue; } + if (!riskyFormula && column.type === "string") observe?.(column.name, value as string, count); if (column.type === "integer") { const total = (facts.integers.get(column.name) ?? 0) + (value as number); if (!Number.isSafeInteger(total)) issue("artifact.csv-unsafe-sum", column.name, count); diff --git a/src/domain/artifact-contract.ts b/src/domain/artifact-contract.ts index 7cf02c7..6782f5b 100644 --- a/src/domain/artifact-contract.ts +++ b/src/domain/artifact-contract.ts @@ -37,6 +37,8 @@ const comparison = z.object({ type: z.literal("csv_summary"), csv: safePath, jso row_count_field: z.string().min(1).max(128), sums: z.array(z.object({ column: z.string().min(1).max(128), field: z.string().min(1).max(128) }).strict()).max(128), }).strict(); +const referenceField = z.object({ path: safePath, field: z.string().min(1).max(128) }).strict(); +const reference = z.object({ type: z.literal("reference_exists"), source: referenceField, target: referenceField }).strict(); export const artifactContractSchema = z.object({ schema: z.literal("skillsync.artifacts/v1"), limits: z.object({ @@ -45,7 +47,7 @@ export const artifactContractSchema = z.object({ schema: z.literal("skillsync.ar max_total_bytes: z.number().int().positive().max(WORKSPACE_TREE_LIMITS.maxTotalBytes), }).strict(), files: z.array(z.discriminatedUnion("format", [bytes, csv, json])).max(WORKSPACE_TREE_LIMITS.maxFiles), - checks: z.array(comparison).max(128).default([]), + checks: z.array(z.discriminatedUnion("type", [comparison, reference])).max(128).default([]), }).strict().superRefine((contract, context) => { const fail = () => context.addIssue({ code: z.ZodIssueCode.custom, message: "inconsistent artifact declarations" }); const files = new Map(contract.files.map(file => [file.path, file])); @@ -75,6 +77,17 @@ export const artifactContractSchema = z.object({ schema: z.literal("skillsync.ar file.unique_by.some(name => !file.columns.some(column => column.name === name)))) fail(); } for (const check of contract.checks) { + if (check.type === "reference_exists") { + const source = files.get(check.source.path), target = files.get(check.target.path); + if (source?.format !== "csv" || target?.format !== "csv") { fail(); continue; } + const sourceColumn = source.columns.find(column => column.name === check.source.field); + const targetColumn = target.columns.find(column => column.name === check.target.field); + // Exact nonempty string IDs only; a component of a composite key is not unique. + if (sourceColumn?.type !== "string" || targetColumn?.type !== "string" || + sourceColumn.allow_empty || targetColumn.allow_empty || + target.unique_by.length !== 1 || target.unique_by[0] !== check.target.field) fail(); + continue; + } const csv = files.get(check.csv), json = files.get(check.json); if (csv?.format !== "csv" || json?.format !== "json") { fail(); continue; } const integerField = (name: string) => json.fields.some(field => field.name === name && field.type === "integer" && field.required); diff --git a/src/domain/artifact-references.ts b/src/domain/artifact-references.ts new file mode 100644 index 0000000..ff6534a --- /dev/null +++ b/src/domain/artifact-references.ts @@ -0,0 +1,59 @@ +import { createHash } from "node:crypto"; +import type { ArtifactContract } from "./artifact-contract.js"; +import type { ArtifactCellObserver, ArtifactFinding } from "./artifact-content.js"; + +export const MAX_REFERENCE_CELLS = 100_000; +type Index = { targets?: Set; sources?: Array<{ digest: string; row: number }> }; + +/** One invocation-local index: declared fields only, hashed IDs, fixed aggregate ceiling. */ +export function createArtifactReferenceIndex(contract: ArtifactContract) { + const checks = contract.checks.filter(check => check.type === "reference_exists"); + const fields = new Map>(); + const index = (path: string, field: string): Index => { + let columns = fields.get(path); + if (!columns) { columns = new Map(); fields.set(path, columns); } + let result = columns.get(field); + if (!result) { result = {}; columns.set(field, result); } + return result; + }; + for (const check of checks) { + index(check.source.path, check.source.field).sources ??= []; + index(check.target.path, check.target.field).targets ??= new Set(); + } + let cells = 0, exhausted = false; + return { + observer(path: string): ArtifactCellObserver | undefined { + const columns = fields.get(path); + if (!columns) return undefined; + return (field, value, row) => { + const entry = columns.get(field); + if (!entry || exhausted) return; + // Repeated IDs count too. Stop hashing and release retained indexes at the ceiling. + cells += 1; + if (cells > MAX_REFERENCE_CELLS) { + exhausted = true; + for (const columns of fields.values()) for (const entry of columns.values()) { + entry.targets?.clear(); if (entry.sources) entry.sources.length = 0; + } + return; + } + const digest = createHash("sha256").update("skillsync.references/v1\0string\0").update(value).digest("hex"); + entry.targets?.add(digest); entry.sources?.push({ digest, row }); + }; + }, + findings(): ArtifactFinding[] { + if (exhausted) return [{ code: "artifact.reference-capacity" }]; + const findings: ArtifactFinding[] = []; + for (const check of checks) { + 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)) { + findings.push({ code: "artifact.reference-missing", path: check.source.path, field: check.source.field, row: source.row }); + if (findings.length >= 256) return findings; + } + } + } + return findings; + }, + }; +} diff --git a/tests/integration/artifact-references.test.ts b/tests/integration/artifact-references.test.ts new file mode 100644 index 0000000..64ee63c --- /dev/null +++ b/tests/integration/artifact-references.test.ts @@ -0,0 +1,90 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import { runArtifacts, renderArtifacts } from "../../src/cli/commands/artifacts"; + +const dirs: string[] = []; +afterEach(async () => { await Promise.all(dirs.splice(0).map(path => rm(path, { recursive: true, force: true }))); }); + +function declaration() { + return { schema: "skillsync.artifacts/v1", limits: { max_files: 2, max_file_bytes: 4096, max_total_bytes: 8192 }, + files: [ + { path: "a-orders.csv", format: "csv", columns: [{ name: "customer", type: "string" }], max_rows: 10 }, + { path: "z-customers.csv", format: "csv", columns: [{ name: "id", type: "string" }], unique_by: ["id"], max_rows: 10 }, + ], checks: [{ type: "reference_exists", source: { path: "a-orders.csv", field: "customer" }, target: { path: "z-customers.csv", field: "id" } }] }; +} + +async function fixture(source = "customer\nC1\nC2\n", target = "id\nC2\nC1\n", contract = declaration()) { + const root = await mkdtemp(join(tmpdir(), "artifact-references-")); dirs.push(root); + const path = join(root, "files"); + await mkdir(path); + await writeFile(join(path, "a-orders.csv"), source); + await writeFile(join(path, "z-customers.csv"), target); + const contractPath = join(root, "contract.json"); await writeFile(contractPath, JSON.stringify(contract)); + return { root, path, contract: contractPath }; +} + +describe("physical CSV references", () => { + it("checks forward references after the entire physical inventory", async () => { + const report = await runArtifacts(await fixture()); + expect(report.status).toBe("passed"); expect(report.files).toHaveLength(2); + expect(report.execution).toBe("not-run"); expect(report.provenance).toBe("not-authenticated"); + }); + it("reports missing references with a row and no cell value", async () => { + const report = await runArtifacts(await fixture("customer\nC1\nDO_NOT_DISCLOSE\n")); + expect(report.findings).toEqual([{ code: "artifact.reference-missing", path: "a-orders.csv", field: "customer", row: 2 }]); + expect(report.exitCode).toBe(1); + expect(renderArtifacts(report, "json") + renderArtifacts(report)).not.toContain("DO_NOT_DISCLOSE"); + }); + it("preserves exact spelling rather than trimming or folding IDs", async () => { + const report = await runArtifacts(await fixture("customer\nc1\n C1\nC1\n", "id\nC1\n")); + expect(report.findings.map(finding => finding.row)).toEqual([1, 2]); + }); + it("allows repeated source IDs but rejects an empty declared target table", async () => { + expect((await runArtifacts(await fixture("customer\nC1\nC1\n", "id\nC1\n"))).exitCode).toBe(0); + const contract = declaration(); Object.assign(contract.files[1], { min_rows: 0 }); + const report = await runArtifacts(await fixture("customer\nC1\n", "id\n", contract)); + expect(report.findings).toEqual([{ code: "artifact.reference-missing", path: "a-orders.csv", field: "customer", row: 1 }]); + }); + it("bounds aggregate referenced cells at 100,000, including repeated IDs", async () => { + const contract = declaration(); + contract.limits = { max_files: 2, max_file_bytes: 1_000_000, max_total_bytes: 2_000_000 }; + for (const file of contract.files) file.max_rows = 100_000; + const source = "customer\n" + "C1\n".repeat(50_000); + const target = "id\n" + Array.from({ length: 50_000 }, (_, index) => `C${index + 1}\n`).join(""); + const options = await fixture(source, target, contract); + expect((await runArtifacts(options)).exitCode).toBe(0); + await writeFile(join(options.path, "z-customers.csv"), target + "C50001\n"); + const report = await runArtifacts(options); + expect(report.findings).toEqual([{ code: "artifact.reference-capacity" }]); + expect(report.exitCode).toBe(1); + }); + it("does not accept duplicate targets or invalid formula cells", async () => { + const report = await runArtifacts(await fixture("customer\n=SECRET\n", "id\n=SECRET\n=SECRET\n")); + expect(report.findings.some(finding => finding.code === "artifact.csv-duplicate")).toBe(true); + expect(report.findings.some(finding => finding.code === "artifact.csv-formula")).toBe(true); + expect(JSON.stringify(report)).not.toContain("=SECRET"); + }); + it("rejects undeclared, non-string, empty and non-unique targets before file access", async () => { + for (const mutation of ["undeclared", "integer", "empty", "composite"] as const) { + const contract = declaration(); + if (mutation === "undeclared") contract.checks[0].target.path = "undeclared.csv"; + if (mutation === "integer") contract.files[1].columns[0].type = "integer"; + if (mutation === "empty") Object.assign(contract.files[1].columns[0], { allow_empty: true }); + if (mutation === "composite") { + contract.files[1].columns.push({ name: "region", type: "string" }); + contract.files[1].unique_by = ["id", "region"]; + } + const fixtureOptions = await fixture(undefined, undefined, contract); + const report = await runArtifacts({ ...fixtureOptions, path: join(fixtureOptions.root, "does-not-exist") }); + expect(report.exitCode).toBe(2); expect(report.findings[0].code).toBe("artifact.contract-invalid"); + } + }); + it("keeps physical inventory guards ahead of relational findings", async () => { + const options = await fixture("customer\nMISSING\n"); + await symlink(join(options.path, "z-customers.csv"), join(options.path, "unexpected.csv")); + const report = await runArtifacts(options); + expect(report.findings[0].code).toBe("artifact.structure-or-snapshot"); expect(report.files).toEqual([]); + }); +}); From 1503c21be553edc9bc615c2bedfa6015da7a7902 Mon Sep 17 00:00:00 2001 From: chumanic Date: Fri, 9 Oct 2026 11:02:39 +0800 Subject: [PATCH 2/3] docs: describe exact physical artifact references --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index a56211a..2e0552b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,11 @@ The command above verifies the included sample Skill. Replace the fixture path w See [actual artifact checks](docs/artifact-delivery.md); it runs no Skill code. The [knowledge source inventory](fixtures/product/source-index/README.md) adds exact declared HTTPS hosts to physical CSV/JSON checks without fetching sources. + CSV `reference_exists` checks also bind exact string IDs to a declared unique + CSV target, with a 100,000-cell reference limit and no raw IDs in findings. + The [reference-integrity example](fixtures/product/reference-integrity/README.md) + demonstrates missing citations using synthetic files; it does not fetch or + authenticate sources. The [material intake catalog](fixtures/product/review-intake/README.md) rejects unknown classification labels using bounded `one_of` strings; labels do not prove approval. - `verify` reviews one local Skill for provenance, target compatibility, and changes without running its scripts. From ebe4c3963edf42908d5482391cf6b549c88dd56a Mon Sep 17 00:00:00 2001 From: chumanic Date: Fri, 9 Oct 2026 12:31:29 +0800 Subject: [PATCH 3/3] feat: add opt-in expected delivery coverage checks --- CHANGELOG.md | 4 ++ docs/artifact-delivery.md | 11 ++++ fixtures/product/delivery-coverage/README.md | 31 +++++++++ .../artifacts/expected-orders.csv | 3 + .../artifacts/order-lines.csv | 4 ++ .../product/delivery-coverage/contract.json | 13 ++++ src/domain/artifact-contract.ts | 4 +- src/domain/artifact-references.ts | 19 ++++-- tests/integration/artifact-references.test.ts | 63 +++++++++++++++++++ 9 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 fixtures/product/delivery-coverage/README.md create mode 100644 fixtures/product/delivery-coverage/artifacts/expected-orders.csv create mode 100644 fixtures/product/delivery-coverage/artifacts/order-lines.csv create mode 100644 fixtures/product/delivery-coverage/contract.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c13ef0..c499c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Local candidate `require_all_targets` adds opt-in coverage of declared CSV + targets, detecting expected records with no delivery/evidence rows. Reports + contain only target locations; existing capacity and finding limits remain. + This candidate is not pushed or published. - Source artifact contracts support bounded CSV `reference_exists` checks with exact string IDs and declared unique target keys. An offline citation example rejects dangling references without exposing values; the aggregate index is diff --git a/docs/artifact-delivery.md b/docs/artifact-delivery.md index 6f2b215..aeda7c0 100644 --- a/docs/artifact-delivery.md +++ b/docs/artifact-delivery.md @@ -80,6 +80,17 @@ operations data. Only declared reference fields are retained as SHA-256 fingerprints with source row numbers. At most 100,000 referenced cells are indexed per invocation, counting repeated IDs and deduplicating field declarations. Exceeding this ceiling releases indexes and fails with `artifact.reference-capacity`. +An optional `require_all_targets: true` also rejects declared target rows that +have no reference in that check's source field. It reports +`artifact.reference-unused` at the target file/field/data-row without its ID. +Omitting the option keeps coverage disabled and preserves existing normalized +contract digests; ordinary reference catalogs may contain unused targets. +The [local delivery coverage candidate](../fixtures/product/delivery-coverage/README.md) +demonstrates expected-order omissions; this option is not in GitHub main or npm. +Target rows and optional source fingerprint sets share the existing 100,000-cell +intake limit and are released together on exhaustion. Findings remain capped at 256. +Coverage means reference presence, not completed work or authenticated approval. + Missing references report relative file, field and data-row number, never the ID or fingerprint. This checks internal consistency without authenticating records. diff --git a/fixtures/product/delivery-coverage/README.md b/fixtures/product/delivery-coverage/README.md new file mode 100644 index 0000000..6c574a7 --- /dev/null +++ b/fixtures/product/delivery-coverage/README.md @@ -0,0 +1,31 @@ +# Expected delivery coverage — local candidate + +This source-only candidate detects a different omission from dangling references: +all delivered rows can point to valid orders while an expected order has no rows. +The synthetic expected-order table is an explicit checklist, not an authenticated +order system. `require_all_targets: true` requires every declared unique target ID +to appear in the selected source field at least once. The default remains false. + +Build the candidate source, then run: + +```bash +node dist/cli/index.js artifacts --contract fixtures/product/delivery-coverage/contract.json --path fixtures/product/delivery-coverage/artifacts --format json +``` + +The complete example passes. In a temporary copy, remove the `DEMO-ORDER-B` item +line while retaining its expected-order row: the report fails with +`artifact.reference-unused` at target data-row 2, without showing the ID. Adding +a line for an undeclared order still fails the existing `reference-missing` check. + +Adapt the two tables to declared findings and evidence for code review, required +references and citations for knowledge material, or expected records and actual +delivery rows for operations. Coverage establishes local reference presence only: +it does not establish review quality, source truth, approval, payment or shipment. + +Only nonempty exact CSV strings and single-column unique targets are supported. +All relation checks remain within the shared 100,000-cell index and 256-finding +ceilings; the indexes store fingerprints and row numbers rather than ID contents. +No runtime, network, account or real business integration is executed. + +As of this candidate, the new coverage option is local, not pushed to GitHub main +or included in a public npm package. diff --git a/fixtures/product/delivery-coverage/artifacts/expected-orders.csv b/fixtures/product/delivery-coverage/artifacts/expected-orders.csv new file mode 100644 index 0000000..b234a97 --- /dev/null +++ b/fixtures/product/delivery-coverage/artifacts/expected-orders.csv @@ -0,0 +1,3 @@ +order_id +DEMO-ORDER-A +DEMO-ORDER-B diff --git a/fixtures/product/delivery-coverage/artifacts/order-lines.csv b/fixtures/product/delivery-coverage/artifacts/order-lines.csv new file mode 100644 index 0000000..d350f9c --- /dev/null +++ b/fixtures/product/delivery-coverage/artifacts/order-lines.csv @@ -0,0 +1,4 @@ +order_id,item_id +DEMO-ORDER-A,DEMO-ITEM-1 +DEMO-ORDER-A,DEMO-ITEM-2 +DEMO-ORDER-B,DEMO-ITEM-3 diff --git a/fixtures/product/delivery-coverage/contract.json b/fixtures/product/delivery-coverage/contract.json new file mode 100644 index 0000000..2dca1e3 --- /dev/null +++ b/fixtures/product/delivery-coverage/contract.json @@ -0,0 +1,13 @@ +{ + "schema": "skillsync.artifacts/v1", + "limits": { "max_files": 2, "max_file_bytes": 4096, "max_total_bytes": 8192 }, + "files": [ + { "path": "order-lines.csv", "format": "csv", "max_rows": 20, + "columns": [{ "name": "order_id", "type": "string" }, { "name": "item_id", "type": "string" }] }, + { "path": "expected-orders.csv", "format": "csv", "max_rows": 20, "unique_by": ["order_id"], + "columns": [{ "name": "order_id", "type": "string" }] } + ], + "checks": [{ "type": "reference_exists", "require_all_targets": true, + "source": { "path": "order-lines.csv", "field": "order_id" }, + "target": { "path": "expected-orders.csv", "field": "order_id" } }] +} diff --git a/src/domain/artifact-contract.ts b/src/domain/artifact-contract.ts index 6782f5b..bd80648 100644 --- a/src/domain/artifact-contract.ts +++ b/src/domain/artifact-contract.ts @@ -38,7 +38,9 @@ const comparison = z.object({ type: z.literal("csv_summary"), csv: safePath, jso sums: z.array(z.object({ column: z.string().min(1).max(128), field: z.string().min(1).max(128) }).strict()).max(128), }).strict(); const referenceField = z.object({ path: safePath, field: z.string().min(1).max(128) }).strict(); -const reference = z.object({ type: z.literal("reference_exists"), source: referenceField, target: referenceField }).strict(); +const reference = z.object({ type: z.literal("reference_exists"), source: referenceField, target: referenceField, + require_all_targets: z.boolean().optional(), +}).strict(); export const artifactContractSchema = z.object({ schema: z.literal("skillsync.artifacts/v1"), limits: z.object({ diff --git a/src/domain/artifact-references.ts b/src/domain/artifact-references.ts index ff6534a..2630968 100644 --- a/src/domain/artifact-references.ts +++ b/src/domain/artifact-references.ts @@ -3,7 +3,7 @@ import type { ArtifactContract } from "./artifact-contract.js"; import type { ArtifactCellObserver, ArtifactFinding } from "./artifact-content.js"; export const MAX_REFERENCE_CELLS = 100_000; -type Index = { targets?: Set; sources?: Array<{ digest: string; row: number }> }; +type Index = { targets?: Map; sources?: Array<{ digest: string; row: number }>; referenced?: Set }; /** One invocation-local index: declared fields only, hashed IDs, fixed aggregate ceiling. */ export function createArtifactReferenceIndex(contract: ArtifactContract) { @@ -18,7 +18,8 @@ export function createArtifactReferenceIndex(contract: ArtifactContract) { }; for (const check of checks) { index(check.source.path, check.source.field).sources ??= []; - index(check.target.path, check.target.field).targets ??= new Set(); + index(check.target.path, check.target.field).targets ??= new Map(); + if (check.require_all_targets) index(check.source.path, check.source.field).referenced ??= new Set(); } let cells = 0, exhausted = false; return { @@ -33,12 +34,13 @@ export function createArtifactReferenceIndex(contract: ArtifactContract) { if (cells > MAX_REFERENCE_CELLS) { exhausted = true; for (const columns of fields.values()) for (const entry of columns.values()) { - entry.targets?.clear(); if (entry.sources) entry.sources.length = 0; + entry.targets?.clear(); entry.referenced?.clear(); if (entry.sources) entry.sources.length = 0; } return; } const digest = createHash("sha256").update("skillsync.references/v1\0string\0").update(value).digest("hex"); - entry.targets?.add(digest); entry.sources?.push({ digest, row }); + if (entry.targets && !entry.targets.has(digest)) entry.targets.set(digest, row); + entry.sources?.push({ digest, row }); entry.referenced?.add(digest); }; }, findings(): ArtifactFinding[] { @@ -52,6 +54,15 @@ export function createArtifactReferenceIndex(contract: ArtifactContract) { if (findings.length >= 256) return findings; } } + if (check.require_all_targets) { + const referenced = index(check.source.path, check.source.field).referenced!; + for (const [digest, row] of targets) { + if (!referenced.has(digest)) { + findings.push({ code: "artifact.reference-unused", path: check.target.path, field: check.target.field, row }); + if (findings.length >= 256) return findings; + } + } + } } return findings; }, diff --git a/tests/integration/artifact-references.test.ts b/tests/integration/artifact-references.test.ts index 64ee63c..c8cde62 100644 --- a/tests/integration/artifact-references.test.ts +++ b/tests/integration/artifact-references.test.ts @@ -88,3 +88,66 @@ describe("physical CSV references", () => { expect(report.findings[0].code).toBe("artifact.structure-or-snapshot"); expect(report.files).toEqual([]); }); }); + +describe("required target coverage", () => { + it("preserves the normalized contract digest of the existing citation fixture", async () => { + const root = join(import.meta.dirname, "../../fixtures/product/reference-integrity"); + const report = await runArtifacts({ contract: join(root, "contract.json"), path: join(root, "artifacts") }); + expect(report.exitCode).toBe(0); + expect(report.contract_sha256).toBe("3023d7b5d113d867f7651e8743f352ef27025cfa151e9463701e0b3748677308"); + }); + it("keeps unused targets allowed by default and by an explicit false option", async () => { + const defaults = await fixture("customer\nC1\n", "id\nC1\nC2\n"); + expect((await runArtifacts(defaults)).status).toBe("passed"); + const contract = declaration(); Object.assign(contract.checks[0], { require_all_targets: false }); + expect((await runArtifacts(await fixture("customer\nC1\n", "id\nC1\nC2\n", contract))).status).toBe("passed"); + }); + it("passes complete target coverage with repeated forward references", async () => { + const contract = declaration(); Object.assign(contract.checks[0], { require_all_targets: true }); + const report = await runArtifacts(await fixture("customer\nC2\nC1\nC1\n", "id\nC1\nC2\n", contract)); + expect(report.status).toBe("passed"); expect(report.execution).toBe("not-run"); + }); + it("reports the uncovered target row without retaining or rendering its ID", async () => { + const contract = declaration(); Object.assign(contract.checks[0], { require_all_targets: true }); + const report = await runArtifacts(await fixture("customer\nC1\n", "id\nC1\nDO_NOT_DISCLOSE\n", contract)); + expect(report.findings).toEqual([{ code: "artifact.reference-unused", path: "z-customers.csv", field: "id", row: 2 }]); + expect(report.exitCode).toBe(1); + expect(renderArtifacts(report, "json") + renderArtifacts(report)).not.toContain("DO_NOT_DISCLOSE"); + }); + it("catches an empty delivery while bounding its target findings at 256", async () => { + const contract = declaration(); Object.assign(contract.checks[0], { require_all_targets: true }); + Object.assign(contract.files[0], { min_rows: 0 }); contract.files[1].max_rows = 300; + const targets = "id\n" + Array.from({ length: 300 }, (_, row) => `C${row + 1}\n`).join(""); + const report = await runArtifacts(await fixture("customer\n", targets, contract)); + expect(report.exitCode).toBe(1); expect(report.findings).toHaveLength(256); + expect(report.findings[0]).toEqual({ code: "artifact.reference-unused", path: "z-customers.csv", field: "id", row: 1 }); + expect(report.findings[255].row).toBe(256); + }); + it("checks each declared target table independently", async () => { + const contract = declaration(); Object.assign(contract.checks[0], { require_all_targets: true }); + contract.limits.max_files = 3; + contract.files.push({ ...contract.files[1], path: "other-customers.csv" }); + contract.checks.push({ ...contract.checks[0], target: { path: "other-customers.csv", field: "id" } }); + const options = await fixture("customer\nC1\n", "id\nC1\nC2\n", contract); + await writeFile(join(options.path, "other-customers.csv"), "id\nC1\n"); + const report = await runArtifacts(options); + expect(report.findings).toEqual([{ code: "artifact.reference-unused", path: "z-customers.csv", field: "id", row: 2 }]); + }); + it("keeps capacity failure ahead of target coverage and releases oversized indexes", async () => { + const contract = declaration(); Object.assign(contract.checks[0], { require_all_targets: true }); + contract.limits = { max_files: 2, max_file_bytes: 1_000_000, max_total_bytes: 2_000_000 }; + for (const file of contract.files) file.max_rows = 100_000; + const source = "customer\n" + "C1\n".repeat(50_000); + const target = "id\n" + Array.from({ length: 50_001 }, (_, row) => `C${row + 1}\n`).join(""); + const report = await runArtifacts(await fixture(source, target, contract)); + expect(report.findings).toEqual([{ code: "artifact.reference-capacity" }]); expect(report.exitCode).toBe(1); + }); + it("rejects non-boolean coverage options before reading artifact paths", async () => { + for (const value of ["true", 1, null, {}]) { + const contract = declaration(); Object.assign(contract.checks[0], { require_all_targets: value }); + const options = await fixture(undefined, undefined, contract); + const report = await runArtifacts({ ...options, path: join(options.root, "does-not-exist") }); + expect(report.exitCode).toBe(2); expect(report.findings[0].code).toBe("artifact.contract-invalid"); + } + }); +});