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/2] 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/2] 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.