diff --git a/CHANGELOG.md b/CHANGELOG.md index 62366ed..ca224d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.1.6 source candidate - 2026-10-10 + +- Compare ordered two-to-four-column string identities as well as existing + single keys. Organization-local IDs no longer need to be flattened into a + delimiter string for integer reconciliation. +- Frame component counts and UTF-8 byte lengths; preserve exact spelling and + positional aliases. Count every component and value against the existing + 100,000-cell limit. Invalid dimensions fail before artifact reads. +- Composite-key findings contain only the bounded declared column-name array; + raw identifiers, fingerprints and amounts remain internal. Existing single-key + contracts and reports retain their behavior. +- Add a reproducible synthetic multi-organization fixture. This source candidate + is not a new GitHub/npm release; GitHub0.1.4/npm0.1.0 remain unchanged. + ## 0.1.5 source candidate - 2026-10-09 - Add bounded per-key integer reconciliation across physical CSV files. Split diff --git a/README.md b/README.md index 27ce45c..82c1324 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ **Verify Agent Skills before you trust them.** SkillSync checks a local Skill's provenance, compatibility, and changes without executing it. -> **Alpha · source candidate v0.1.5 · Node.js 20+** +> **Alpha · source candidate v0.1.6 · 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.5 adds per-key integer reconciliation. The published GitHub package +Source v0.1.6 adds bounded ordered composite keys to per-key integer reconciliation. 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) @@ -21,7 +21,7 @@ npm install -g @chumanic/skillsync@0.1.0 skillsync verify --path . --target codex ``` -**From source (0.1.5 candidate):** +**From source (0.1.6 candidate):** ```bash git clone https://github.com/Chumaniac/skillsync.git @@ -62,6 +62,9 @@ The command above verifies the included sample Skill. Replace the fixture path w ## What you get +- Source0.1.6 supports ordered two-to-four-column identity tuples for integer + reconciliation. The [multi-organization example](fixtures/product/tenant-reconciliation/README.md) + separates repeated local IDs, without delimiter collisions or raw ID reporting. - Source `keyed_integer_sum_equals` checks compare each record key's integer total across CSV files, including split deliveries. They detect balanced but wrongly allocated results that global sums cannot identify. The diff --git a/docs/artifact-delivery.md b/docs/artifact-delivery.md index 89aa1a2..f596087 100644 --- a/docs/artifact-delivery.md +++ b/docs/artifact-delivery.md @@ -23,7 +23,7 @@ authenticated evidence; the report marks provenance as not authenticated. ## Per-key integer reconciliation -Source candidate 0.1.5 supports `keyed_integer_sum_equals`. It is absent from +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. The two paths must be distinct; comparing a file with itself is rejected before @@ -63,6 +63,40 @@ Without the rule, existing normalized contracts and report shapes are unchanged. Reports still say `execution: not-run` and `provenance: not-authenticated`; equality does not authenticate the expected input or approve a business action. +### Ordered composite identities in source 0.1.6 + +The `key` selector may also be an ordered array of two to four distinct column +names. Every component must be a declared required nonempty string. Both sides +must have the same number of components; positional column names may differ. +One-element arrays, duplicate/empty names, undeclared fields, wrong scalar types, +nullable components and mixed-width sides are rejected before artifact reads. + +```json +{ + "type": "keyed_integer_sum_equals", + "source": { "path": "expected.csv", "key": ["tenant_id", "order_id"], "value": "amount_cents" }, + "target": { "path": "allocations.csv", "key": ["tenant_id", "order_id"], "value": "amount_cents" } +} +``` + +Two organizations can use the same local order ID without their totals being +merged. Component order, whitespace, Unicode spelling and numeric-looking +strings remain exact. Tuples are internally framed with component count and +UTF-8 byte lengths before hashing; joining IDs with a separator is not equivalent. +Single-string keys preserve the existing behavior. + +Each unique grouping observes `key components + integer value` cells per row. +The invocation-wide limit remains 100,000, including repeated rows. Matching +group declarations reuse an index; matching selectors reuse their fingerprint +within a row. Exhaustion and unsafe sums preserve `unknown` without partial +counts. Composite missing-key findings have a `field` array of declared column +names, while ordinary findings retain a string. No tuple values are reported. + +The [multi-organization example](../fixtures/product/tenant-reconciliation/README.md) +detects a balanced cross-organization shift that a single local ID accepts. +Composite identities apply to this integer reconciliation rule. The separate +`reference_exists` rule still uses a single unique string field. + ## Declared requirements The [synthetic contract](../fixtures/product/order-summary/contract.json) declares: diff --git a/docs/deep-optimization-roadmap.md b/docs/deep-optimization-roadmap.md index aa81365..9c1fd63 100644 --- a/docs/deep-optimization-roadmap.md +++ b/docs/deep-optimization-roadmap.md @@ -49,6 +49,17 @@ Keep the existing 10,000-file/16 MiB-file/64 MiB-total and 256-finding ceilings. ## Domain expansion +### Continuation cycle — 2026-10-10 + +Source0.1.6 implements ordered two-to-four-column string keys for integer +reconciliation, with positional aliases and unambiguous UTF-8 framing. +Every component consumes the existing bounded selected-cell budget. The +multi-organization fixture demonstrates repeated local IDs and balanced shifts; +review, knowledge and operations can use workspace/file/check or source/version +dimensions deliberately. This does not add composite references, JSON relations, +signed identity or any live service. The next producer-consumer journey retains +actual synthetic deliveries from the existing restricted SkillTape executor. + | Domain | Contract example | Benefit | Evidence limit | | --- | --- | --- | --- | | Code review | Expected versus delivered findings/counts per changed file | Detect lost or misassigned review materials | No semantic correctness or reviewer authentication. | diff --git a/fixtures/product/tenant-reconciliation/README.md b/fixtures/product/tenant-reconciliation/README.md new file mode 100644 index 0000000..fc8f6a9 --- /dev/null +++ b/fixtures/product/tenant-reconciliation/README.md @@ -0,0 +1,41 @@ +# Multi-organization integer reconciliation + +Status: source candidate0.1.6, 2026-10-10. IDs and amounts are synthetic. +This fixture is absent from GitHub0.1.4/npm0.1.0 and runs no Skill, network, +account or payment operation. Build the current source: + +```bash +npm ci +npm run build +node dist/cli/index.js artifacts \ + --contract fixtures/product/tenant-reconciliation/contract.json \ + --path fixtures/product/tenant-reconciliation/artifacts --format json +``` + +ORG_A and ORG_B both use ORDER_1. Their expectations are 1,000 and 2,000 minor +units. Split allocation rows pass when grouped by the ordered +`["tenant_id", "order_id"]` tuple. A rule using only `order_id` merges these +organizations and can miss allocation errors. + +Preserve the successful input and copy a fault into an independent directory: + +```bash +workspace="$(mktemp -d)" +cp -R fixtures/product/tenant-reconciliation/artifacts "$workspace/artifacts" +cp fixtures/product/tenant-reconciliation/faults/shifted-allocations.csv \ + "$workspace/artifacts/allocations.csv" +node dist/cli/index.js artifacts \ + --contract fixtures/product/tenant-reconciliation/contract.json \ + --path "$workspace/artifacts" --format json +``` + +The second command exits1 and reports two mismatched tuple totals. Global +counts and total3,000 remain consistent. Keys have two to four required exact +string components; all components and values consume the bounded cell budget. +Findings expose only declared paths/columns and counts, not organization/order +values or per-record amounts. + +Reports retain `execution: not-run` and `provenance: not-authenticated`. +Requirements and their ownership must be reviewed separately. No identity, +source truth, authorization or real financial integration is established. +See [artifact rules](../../../docs/artifact-delivery.md). diff --git a/fixtures/product/tenant-reconciliation/artifacts/allocations.csv b/fixtures/product/tenant-reconciliation/artifacts/allocations.csv new file mode 100644 index 0000000..0b35735 --- /dev/null +++ b/fixtures/product/tenant-reconciliation/artifacts/allocations.csv @@ -0,0 +1,4 @@ +tenant_id,order_id,amount_cents +ORG_A,ORDER_1,400 +ORG_B,ORDER_1,2000 +ORG_A,ORDER_1,600 diff --git a/fixtures/product/tenant-reconciliation/artifacts/expected.csv b/fixtures/product/tenant-reconciliation/artifacts/expected.csv new file mode 100644 index 0000000..032f957 --- /dev/null +++ b/fixtures/product/tenant-reconciliation/artifacts/expected.csv @@ -0,0 +1,3 @@ +tenant_id,order_id,amount_cents +ORG_A,ORDER_1,1000 +ORG_B,ORDER_1,2000 diff --git a/fixtures/product/tenant-reconciliation/artifacts/summary.json b/fixtures/product/tenant-reconciliation/artifacts/summary.json new file mode 100644 index 0000000..4f36057 --- /dev/null +++ b/fixtures/product/tenant-reconciliation/artifacts/summary.json @@ -0,0 +1,5 @@ +{ + "expected_rows": 2, + "allocation_rows": 3, + "total_cents": 3000 +} diff --git a/fixtures/product/tenant-reconciliation/contract.json b/fixtures/product/tenant-reconciliation/contract.json new file mode 100644 index 0000000..da9fd45 --- /dev/null +++ b/fixtures/product/tenant-reconciliation/contract.json @@ -0,0 +1,120 @@ +{ + "schema": "skillsync.artifacts/v1", + "limits": { + "max_files": 3, + "max_file_bytes": 1048576, + "max_total_bytes": 3145728 + }, + "files": [ + { + "path": "expected.csv", + "format": "csv", + "columns": [ + { + "name": "tenant_id", + "type": "string" + }, + { + "name": "order_id", + "type": "string" + }, + { + "name": "amount_cents", + "type": "integer", + "min": 0 + } + ], + "unique_by": [ + "tenant_id", + "order_id" + ], + "max_rows": 1000 + }, + { + "path": "allocations.csv", + "format": "csv", + "columns": [ + { + "name": "tenant_id", + "type": "string" + }, + { + "name": "order_id", + "type": "string" + }, + { + "name": "amount_cents", + "type": "integer", + "min": 0 + } + ], + "max_rows": 1000 + }, + { + "path": "summary.json", + "format": "json", + "fields": [ + { + "name": "expected_rows", + "type": "integer", + "min": 0 + }, + { + "name": "allocation_rows", + "type": "integer", + "min": 0 + }, + { + "name": "total_cents", + "type": "integer", + "min": 0 + } + ] + } + ], + "checks": [ + { + "type": "csv_summary", + "csv": "expected.csv", + "json": "summary.json", + "row_count_field": "expected_rows", + "sums": [ + { + "column": "amount_cents", + "field": "total_cents" + } + ] + }, + { + "type": "csv_summary", + "csv": "allocations.csv", + "json": "summary.json", + "row_count_field": "allocation_rows", + "sums": [ + { + "column": "amount_cents", + "field": "total_cents" + } + ] + }, + { + "type": "keyed_integer_sum_equals", + "source": { + "path": "expected.csv", + "key": [ + "tenant_id", + "order_id" + ], + "value": "amount_cents" + }, + "target": { + "path": "allocations.csv", + "key": [ + "tenant_id", + "order_id" + ], + "value": "amount_cents" + } + } + ] +} diff --git a/fixtures/product/tenant-reconciliation/faults/shifted-allocations.csv b/fixtures/product/tenant-reconciliation/faults/shifted-allocations.csv new file mode 100644 index 0000000..6b7ef93 --- /dev/null +++ b/fixtures/product/tenant-reconciliation/faults/shifted-allocations.csv @@ -0,0 +1,4 @@ +tenant_id,order_id,amount_cents +ORG_A,ORDER_1,500 +ORG_B,ORDER_1,1900 +ORG_A,ORDER_1,600 diff --git a/package-lock.json b/package-lock.json index a6a280b..f120d20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chumanic/skillsync", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chumanic/skillsync", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index bc2b608..b1c27ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chumanic/skillsync", - "version": "0.1.5", + "version": "0.1.6", "private": false, "publishConfig": { "access": "public" diff --git a/src/domain/artifact-content.ts b/src/domain/artifact-content.ts index 4ab67e4..3ead466 100644 --- a/src/domain/artifact-content.ts +++ b/src/domain/artifact-content.ts @@ -1,7 +1,7 @@ import type { ArtifactFileRule, ArtifactScalarRule } from "./artifact-contract.js"; import { parseStrictJson } from "./strict-json.js"; -export type ArtifactFinding = { code: string; path?: string; field?: string; row?: number; message?: string }; +export type ArtifactFinding = { code: string; path?: string; field?: string | string[]; row?: number; message?: string }; export type ArtifactFacts = { rows?: number; integers: Map }; export type ArtifactCellObserver = (field: string, value: string, row: number) => void; export type ArtifactRowObserver = (values: ReadonlyMap) => void; diff --git a/src/domain/artifact-contract.ts b/src/domain/artifact-contract.ts index b4849c5..3d868dc 100644 --- a/src/domain/artifact-contract.ts +++ b/src/domain/artifact-contract.ts @@ -41,7 +41,8 @@ const referenceField = z.object({ path: safePath, field: z.string().min(1).max(1 const reference = z.object({ type: z.literal("reference_exists"), source: referenceField, target: referenceField, require_all_targets: z.boolean().optional(), }).strict(); -const keyedSide = z.object({ path: safePath, key: z.string().min(1).max(128), value: z.string().min(1).max(128) }).strict(); +const keyColumn = z.string().min(1).max(128); +const keyedSide = z.object({ path: safePath, key: z.union([keyColumn, z.array(keyColumn).min(2).max(4)]), value: z.string().min(1).max(128) }).strict(); const reconciliation = z.object({ type: z.literal("keyed_integer_sum_equals"), source: keyedSide, target: keyedSide }).strict(); export const artifactContractSchema = z.object({ schema: z.literal("skillsync.artifacts/v1"), @@ -83,13 +84,19 @@ export const artifactContractSchema = z.object({ schema: z.literal("skillsync.ar for (const check of contract.checks) { if (check.type === "keyed_integer_sum_equals") { if (check.source.path === check.target.path) fail(); + const width = (key: string | string[]) => typeof key === "string" ? 1 : key.length; + if (width(check.source.key) !== width(check.target.key)) fail(); for (const side of [check.source, check.target]) { const file = files.get(side.path); if (file?.format !== "csv") { fail(); continue; } - const key = file.columns.find(column => column.name === side.key); + const keys = typeof side.key === "string" ? [side.key] : side.key; + if (new Set(keys).size !== keys.length) fail(); + for (const name of keys) { + const key = file.columns.find(column => column.name === name); + if (key?.type !== "string" || key.allow_empty || !key.required) fail(); + } const value = file.columns.find(column => column.name === side.value); - if (key?.type !== "string" || key.allow_empty || !key.required || - value?.type !== "integer" || !value.required) fail(); + if (value?.type !== "integer" || !value.required) fail(); } continue; } diff --git a/src/domain/artifact-reconciliation.ts b/src/domain/artifact-reconciliation.ts index a9a4aba..2404b79 100644 --- a/src/domain/artifact-reconciliation.ts +++ b/src/domain/artifact-reconciliation.ts @@ -6,6 +6,7 @@ export const RECONCILIATION_CELL_LIMIT = 100_000; type Check = Extract; type Side = Check["source"]; type Group = { sums: Map; overflow: boolean; invalid: boolean }; +type Selection = { side: Side; group: Group; keys: string[]; keyIdentity: string }; export type ReconciliationSummary = Check & { status: "passed" | "failed" | "unknown"; sourceKeys?: number; targetKeys?: number; missingKeys?: number; @@ -15,7 +16,7 @@ export type ReconciliationSummary = Check & { export function createArtifactReconciliationIndex(contract: ArtifactContract) { const checks = contract.checks.filter((check): check is Check => check.type === "keyed_integer_sum_equals"); const groups = new Map(); - const byPath = new Map>(); + const byPath = new Map(); const identity = (side: Side) => JSON.stringify([side.path, side.key, side.value]); for (const check of checks) for (const side of [check.source, check.target]) { const id = identity(side); @@ -23,7 +24,8 @@ export function createArtifactReconciliationIndex(contract: ArtifactContract) { const group: Group = { sums: new Map(), overflow: false, invalid: false }; groups.set(id, group); const entries = byPath.get(side.path) ?? []; - entries.push({ side, group }); byPath.set(side.path, entries); + const keys = typeof side.key === "string" ? [side.key] : side.key; + entries.push({ side, group, keys, keyIdentity: JSON.stringify(keys) }); byPath.set(side.path, entries); } let observedCells = 0, exhausted = false; let completed: { summaries: ReconciliationSummary[]; findings: ArtifactFinding[] } | undefined; @@ -35,20 +37,34 @@ export function createArtifactReconciliationIndex(contract: ArtifactContract) { return values => { if (completed) throw new Error("reconciliation index is closed"); if (exhausted) return; - // Hash each selected key field once per row; raw IDs never enter the index. + // Hash each selected key tuple once per row; raw IDs never enter the index. const fingerprints = new Map(); - for (const { side, group } of entries) { + for (const { side, group, keys, keyIdentity } of entries) { if (group.invalid || group.overflow) continue; - const key = values.get(side.key), value = values.get(side.value); - if (typeof key !== "string" || !key.length || typeof value !== "number" || !Number.isSafeInteger(value)) { + const components = keys.map(key => values.get(key)); + const value = values.get(side.value); + if (!components.every((key): key is string => typeof key === "string" && key.length > 0) || + typeof value !== "number" || !Number.isSafeInteger(value)) { group.invalid = true; group.sums.clear(); continue; } - observedCells += 2; + observedCells += keys.length + 1; if (observedCells > RECONCILIATION_CELL_LIMIT) { exhausted = true; release(); return; } - let fingerprint = fingerprints.get(side.key); + let fingerprint = fingerprints.get(keyIdentity); if (!fingerprint) { - fingerprint = createHash("sha256").update(key).digest("hex"); - fingerprints.set(side.key, fingerprint); + const hash = createHash("sha256"); + if (typeof side.key === "string") hash.update(components[0]); + else { + // Four components at most; frame UTF-8 lengths, never concatenate IDs. + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(components.length)); + hash.update("skillsync.key-tuple/v1\0").update(length); + for (const key of components) { + length.writeBigUInt64BE(BigInt(Buffer.byteLength(key, "utf8"))); + hash.update(length).update(key); + } + } + fingerprint = hash.digest("hex"); + fingerprints.set(keyIdentity, fingerprint); } const sum = (group.sums.get(fingerprint) ?? 0) + value; if (!Number.isSafeInteger(sum)) { group.overflow = true; group.sums.clear(); } diff --git a/tests/cli/help.test.ts b/tests/cli/help.test.ts index c899b3f..58ec45f 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.1.5\n"); + expect(version.stdout).toBe("0.1.6\n"); const result = await runCli(["--help"]); diff --git a/tests/cli/report.test.ts b/tests/cli/report.test.ts index 7087edd..3612554 100644 --- a/tests/cli/report.test.ts +++ b/tests/cli/report.test.ts @@ -218,7 +218,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.1.5"); + expect(JSON.parse(json.stdout).toolVersion).toBe("0.1.6"); const sarif = await runCli(["report", "--before", beforePath, "--after", afterPath, "--format", "sarif"]); const parsedSarif = JSON.parse(sarif.stdout) as { @@ -229,7 +229,7 @@ describe("skillsync report", () => { }>; }; expect(parsedSarif.version).toBe("2.1.0"); - expect(parsedSarif.runs[0]?.tool.driver.version).toBe("0.1.5"); + expect(parsedSarif.runs[0]?.tool.driver.version).toBe("0.1.6"); 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 d866127..ee12c13 100644 --- a/tests/docs/documentation.test.ts +++ b/tests/docs/documentation.test.ts @@ -39,7 +39,7 @@ describe("release documentation", () => { }; expect(readme).toContain("Verify Agent Skills before you trust them."); - expect(readme).toContain("Alpha · source candidate v0.1.5 · Node.js 20+"); + expect(readme).toContain("Alpha · source candidate v0.1.6 · Node.js 20+"); expect(readme).toContain("published GitHub package"); expect(readme).toContain("still v0.1.4"); expect(readme).toContain("## Install"); diff --git a/tests/domain/artifact-composite-reconciliation.test.ts b/tests/domain/artifact-composite-reconciliation.test.ts new file mode 100644 index 0000000..f722573 --- /dev/null +++ b/tests/domain/artifact-composite-reconciliation.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { parseArtifactContract } from "../../src/domain/artifact-contract"; +import { createArtifactReconciliationIndex, RECONCILIATION_CELL_LIMIT } from "../../src/domain/artifact-reconciliation"; + +function build(width: number) { + const keys = Array.from({ length: width }, (_, index) => `key${index}`); + return { keys, index: createArtifactReconciliationIndex(parseArtifactContract({ schema: "skillsync.artifacts/v1", + limits: { max_files: 2, max_file_bytes: 4096, max_total_bytes: 8192 }, + files: ["source.csv", "target.csv"].map(path => ({ path, format: "csv", max_rows: 100_000, + columns: [...keys.map(name => ({ name, type: "string" })), { name: "units", type: "integer" }] })), + checks: [{ type: "keyed_integer_sum_equals", source: { path: "source.csv", key: keys, value: "units" }, + target: { path: "target.csv", key: keys, value: "units" } }], + })) }; +} + +describe("composite key index bounds", () => { + it.each([2, 3, 4])("counts all %i key components plus the integer value", width => { + const { keys, index } = build(width); + const values = new Map([...keys.map(key => [key, "PRIVATE"] as [string, string]), ["units", 0]]); + const observe = index.observer("source.csv")!; + const rows = Math.floor(RECONCILIATION_CELL_LIMIT / (width + 1)); + for (let row = 0; row < rows; row += 1) observe(values); + expect(index.finish().findings.some(finding => finding.code === "artifact.reconciliation-capacity")).toBe(false); + expect(() => observe(values)).toThrow("closed"); + const overflow = build(width).index; + for (let row = 0; row <= rows; row += 1) overflow.observer("source.csv")!(values); + const finished = overflow.finish(); + expect(finished.findings).toContainEqual({ code: "artifact.reconciliation-capacity" }); + expect(finished.summaries[0]).toMatchObject({ status: "unknown" }); + expect(finished.summaries[0]).not.toHaveProperty("sourceKeys"); + expect(overflow.finish()).toEqual(finished); + }); + + it("does not accept a missing component as a smaller tuple", () => { + const { index } = build(2); + index.observer("source.csv")!(new Map([["key0", "PRIVATE"], ["units", 0]])); + expect(index.finish().summaries[0].status).toBe("unknown"); + }); + + it("matches declared positional aliases without binding a tuple to column names", () => { + const value = createArtifactReconciliationIndex(parseArtifactContract({ schema: "skillsync.artifacts/v1", + limits: { max_files: 2, max_file_bytes: 4096, max_total_bytes: 8192 }, + files: [["source.csv", "organization", "order"], ["target.csv", "account", "ticket"]].map(([path, first, second]) => ({ + path, format: "csv", max_rows: 1, columns: [first, second].map(name => ({ name, type: "string" })).concat([{ name: "units", type: "integer" }]), + })), checks: [{ type: "keyed_integer_sum_equals", source: { path: "source.csv", key: ["organization", "order"], value: "units" }, + target: { path: "target.csv", key: ["account", "ticket"], value: "units" } }], + })); + value.observer("source.csv")!(new Map([["organization", "PRIVATE_A"], ["order", "0001"], ["units", 2]])); + value.observer("target.csv")!(new Map([["account", "PRIVATE_A"], ["ticket", "0001"], ["units", 2]])); + expect(value.finish().summaries[0].status).toBe("passed"); + }); +}); diff --git a/tests/integration/artifact-composite-reconciliation.test.ts b/tests/integration/artifact-composite-reconciliation.test.ts new file mode 100644 index 0000000..34f2096 --- /dev/null +++ b/tests/integration/artifact-composite-reconciliation.test.ts @@ -0,0 +1,100 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderArtifacts, runArtifacts } from "../../src/cli/commands/artifacts"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); }); +type Row = [string, string, number]; +const expected: Row[] = [["PRIVATE_ORG_A", "PRIVATE_001", 10], ["PRIVATE_ORG_B", "PRIVATE_001", 20]]; +const split: Row[] = [["PRIVATE_ORG_A", "PRIVATE_001", 4], ["PRIVATE_ORG_B", "PRIVATE_001", 20], ["PRIVATE_ORG_A", "PRIVATE_001", 6]]; + +function declaration(sourceKey: string | string[] = ["tenant_id", "order_id"], targetKey = sourceKey) { + return { schema: "skillsync.artifacts/v1", limits: { max_files: 3, max_file_bytes: 1_000_000, max_total_bytes: 3_000_000 }, + files: ["expected.csv", "actual.csv"].map(path => ({ path, format: "csv", max_rows: 1000, + unique_by: path === "expected.csv" ? ["tenant_id", "order_id"] : [], + columns: [{ name: "tenant_id", type: "string" }, { name: "order_id", type: "string" }, { name: "cents", type: "integer" }] })), + checks: [{ type: "keyed_integer_sum_equals", source: { path: "expected.csv", key: sourceKey, value: "cents" }, + target: { path: "actual.csv", key: targetKey, value: "cents" } }], + }; +} + +async function fixture(actual = split, contract = declaration(), source = expected) { + const root = await mkdtemp(join(tmpdir(), "composite-artifacts-")); roots.push(root); + const path = join(root, "data"); await mkdir(path); + const csv = (rows: Row[]) => "tenant_id,order_id,cents\n" + rows.map(row => row.map(value => `"${String(value).replaceAll('"', '""')}"`).join(",")).join("\n") + "\n"; + await writeFile(join(path, "expected.csv"), csv(source)); await writeFile(join(path, "actual.csv"), csv(actual)); + const contractPath = join(root, "contract.json"); await writeFile(contractPath, JSON.stringify(contract)); + return { root, path, contract: contractPath }; +} + +describe("composite delivery identities", () => { + it("accepts split rows for organizations sharing a local order ID", async () => { + const report = await runArtifacts(await fixture()); + expect(report.exitCode).toBe(0); + expect(report.reconciliations?.[0]).toMatchObject({ status: "passed", sourceKeys: 2, targetKeys: 2, valueMismatches: 0 }); + }); + + it("detects cross-organization shifts that a single local ID accepts", async () => { + const wrong: Row[] = [["PRIVATE_ORG_A", "PRIVATE_001", 11], ["PRIVATE_ORG_B", "PRIVATE_001", 19]]; + expect((await runArtifacts(await fixture(wrong, declaration("order_id")))).exitCode).toBe(0); + const report = await runArtifacts(await fixture(wrong)); + expect(report.exitCode).toBe(1); + expect(report.reconciliations?.[0]).toMatchObject({ status: "failed", missingKeys: 0, unexpectedKeys: 0, valueMismatches: 2 }); + for (const format of ["json", "text"]) expect(renderArtifacts(report, format)).not.toContain("PRIVATE_"); + }); + + it("frames values rather than joining ambiguous separators or adjacent strings", async () => { + for (const source of [[ ["ab", "c", 10], ["a", "bc", 20] ], [ ["a|b", "c", 10], ["a", "b|c", 20] ]] as Row[][]) { + const actual = source.map(([organization, id, cents]) => [organization, id, cents === 10 ? 20 : 10] as Row); + const report = await runArtifacts(await fixture(actual, declaration(), source)); + expect(report.exitCode).toBe(1); + expect(report.reconciliations?.[0]).toMatchObject({ sourceKeys: 2, targetKeys: 2, valueMismatches: 2 }); + } + }); + + it("preserves tuple order and exact spelling with structured field diagnostics", async () => { + const report = await runArtifacts(await fixture(split, declaration(["tenant_id", "order_id"], ["order_id", "tenant_id"]))); + expect(report.exitCode).toBe(1); + expect(report.findings).toContainEqual({ code: "artifact.reconciliation-keys", path: "actual.csv", field: ["order_id", "tenant_id"] }); + expect(renderArtifacts(report, "text")).toContain('field=["order_id","tenant_id"]'); + for (const replacement of ["PRIVATE_org_A", " PRIVATE_ORG_A", "PRIVATE_ORG_A "]) { + const changed = split.map(row => [...row] as Row); changed[0][0] = replacement; + expect((await runArtifacts(await fixture(changed))).exitCode).toBe(1); + } + }); + + it("keeps invalid components and unsafe per-tuple sums unknown", async () => { + const empty = split.map(row => [...row] as Row); empty[0][0] = ""; + expect((await runArtifacts(await fixture(empty))).reconciliations?.[0].status).toBe("unknown"); + const max = Number.MAX_SAFE_INTEGER; + const source: Row[] = [["A", "I", max], ["B", "I", -max]]; + const actual: Row[] = [...source, ["A", "I", 1], ["B", "I", -1]]; + const report = await runArtifacts(await fixture(actual, declaration(), source)); + expect(report.exitCode).toBe(1); + expect(report.reconciliations?.[0].status).toBe("unknown"); + expect(report.findings.some(item => item.code === "artifact.reconciliation-unsafe-sum")).toBe(true); + }); + + it("does not normalize Unicode or numeric-looking identity components", async () => { + const source: Row[] = [["ORG", "é", 10], ["ORG", "0001", 20]]; + const actual: Row[] = [["ORG", "e\u0301", 10], ["ORG", "1", 20]]; + const report = await runArtifacts(await fixture(actual, declaration(), source)); + expect(report.reconciliations?.[0]).toMatchObject({ status: "failed", missingKeys: 2, unexpectedKeys: 2 }); + }); + + it("rejects malformed dimensions before reading any artifact", async () => { + const variants: unknown[] = [[], ["tenant_id"], ["tenant_id", "tenant_id"], ["tenant_id", ""], ["tenant_id", 1], + ["tenant_id", "missing"], ["tenant_id", "cents"], ["a", "b", "c", "d", "e"]]; + for (const key of variants) { + const contract = declaration(); Object.assign(contract.checks[0].source, { key }); + const options = await fixture(split, contract); + expect((await runArtifacts({ contract: options.contract, path: join(options.root, "absent") })).exitCode).toBe(2); + } + const mismatched = declaration(["tenant_id", "order_id"], "order_id"); + expect((await runArtifacts(await fixture(split, mismatched))).exitCode).toBe(2); + const nullable = declaration(); Object.assign(nullable.files[0].columns[0], { allow_empty: true }); + expect((await runArtifacts(await fixture(split, nullable))).exitCode).toBe(2); + }); +}); diff --git a/tests/reporters/sarif.test.ts b/tests/reporters/sarif.test.ts index 8d50991..150452f 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.1.5"); + expect(sarif.runs[0]?.tool.driver.version).toBe("0.1.6"); 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");