diff --git a/CHANGELOG.md b/CHANGELOG.md index e0436b1..db5e9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- 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. - Source `https_url` artifact fields validate bounded HTTPS addresses against exact declared DNS hosts, with an offline knowledge-source inventory. The checker never fetches sources, retains URL values in reports or proves truth. diff --git a/README.md b/README.md index e1e4934..a56211a 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,13 @@ The command above verifies the included sample Skill. Replace the fixture path w ## What you get -- The local source candidate `artifacts` command independently checks actual +- The source `artifacts` command independently checks actual CSV/JSON delivery files, Receipt/file hashes and cross-file count/integer sums. 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. + 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. - `scan` inventories local Skills, while `compat` checks their declared features against agent profiles. - `diff` shows the meaningful changes between two Skill versions before you accept them. diff --git a/docs/artifact-delivery.md b/docs/artifact-delivery.md index b29ee32..53998ce 100644 --- a/docs/artifact-delivery.md +++ b/docs/artifact-delivery.md @@ -48,6 +48,21 @@ follow redirects, assess the source text or prove that a named host is public or trustworthy. Exact URL spelling remains unchanged for uniqueness comparisons. Optional JSON fields may be omitted; an empty URL does not represent a source. +String fields can declare `one_of` with 1–32 unique exact options, each limited +to 256 UTF-16 code units. Options cannot contain surrounding whitespace, control +or format characters. Other scalar types cannot carry `one_of`; an `equals` +value must also be in the declared list. An empty option requires +`allow_empty: true`. Matching does not trim, normalize, fold case or coerce types; +CSV formula rejection still applies. Optional JSON fields may still be omitted. + +For example, `{ "name": "currency", "type": "string", "one_of": ["CNY", "USD"] }` +checks only that the label is one of the caller's declarations. The +[material intake catalog](../fixtures/product/review-intake/README.md) demonstrates +domain and decision labels in physical CSV/JSON plus independent reference totals. +These classifications do not authenticate reviewers, authorize actions or prove +business correctness. This source extension is not in npm0.1.0; findings omit +the actual string values. + `bytes` rules check integrity and capacity without interpreting content. JSON 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 diff --git a/docs/domain-adaptation.md b/docs/domain-adaptation.md index 1f5a6d3..89e955b 100644 --- a/docs/domain-adaptation.md +++ b/docs/domain-adaptation.md @@ -47,6 +47,10 @@ not vendor certification, and unknown/runtime-dependent features stay explicit. checks actual bounded CSV/JSON, exact HTTPS host declarations and independent reference counts. It is not in npm0.1.0 and does not verify external source truth. - Add further versioned input-schema checks and bounded fixture resources. +- The source [material intake catalog](../fixtures/product/review-intake/README.md) + checks caller-declared domain/decision labels in real CSV/JSON with bounded + `one_of` string options. Labels are exact data classifications, not authenticated + approval or semantic review; this source extension is not in npm0.1.0. - Add references and output-evidence requirements for additional domains. - Validate a separately approved local execution backend using synthetic inputs before describing it as an actual integration. diff --git a/fixtures/product/review-intake/README.md b/fixtures/product/review-intake/README.md new file mode 100644 index 0000000..4ae7fe1 --- /dev/null +++ b/fixtures/product/review-intake/README.md @@ -0,0 +1,30 @@ +# Bounded material intake labels + +This synthetic source example checks actual CSV and JSON using closed string +options. It demonstrates a shared intake format for code-review material, +knowledge references and operations data; it performs no semantic review, +approval, authenticated handoff or external platform connection. + +```bash +npm run build +node dist/cli/index.js artifacts \ + --contract fixtures/product/review-intake/contract.json \ + --path fixtures/product/review-intake/artifacts --format json +``` + +Three synthetic records have exact declared domains and decision labels. The +summary independently matches their row count and three reference markers. An +unknown or mistyped label fails even when numeric summaries still match. A +permitted label such as `ready` does not prove a person reviewed or approved the +material; the labels are only declared data values. Reference markers do not +prove source truth. + +The contract allows two files, 16 KiB per file, 32 KiB total and 1,000 CSV rows. +`one_of` accepts 1–32 unique literal strings of at most 256 UTF-16 code units, +with no surrounding whitespace, control or format characters. Matching is exact: +no trimming, case folding, regular-expression evaluation or type coercion. An +empty option requires `allow_empty: true`. Values are not included in findings. + +These checks use the current source implementation. They are not in the public +npm0.1.0 package, and are physical-file evidence with execution `not-run` and +provenance `not-authenticated`. diff --git a/fixtures/product/review-intake/artifacts/materials.csv b/fixtures/product/review-intake/artifacts/materials.csv new file mode 100644 index 0000000..848a64e --- /dev/null +++ b/fixtures/product/review-intake/artifacts/materials.csv @@ -0,0 +1,4 @@ +material_id,domain,decision,reference_count +SYN-MAT-001,code-review,needs-review,1 +SYN-MAT-002,knowledge-reference,ready,2 +SYN-MAT-003,data-ops,blocked,0 diff --git a/fixtures/product/review-intake/artifacts/summary.json b/fixtures/product/review-intake/artifacts/summary.json new file mode 100644 index 0000000..d65b16b --- /dev/null +++ b/fixtures/product/review-intake/artifacts/summary.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "row_count": 3, + "total_references": 3, + "queue": "manual-review" +} diff --git a/fixtures/product/review-intake/contract.json b/fixtures/product/review-intake/contract.json new file mode 100644 index 0000000..926d352 --- /dev/null +++ b/fixtures/product/review-intake/contract.json @@ -0,0 +1,31 @@ +{ + "schema": "skillsync.artifacts/v1", + "limits": { "max_files": 2, "max_file_bytes": 16384, "max_total_bytes": 32768 }, + "files": [ + { + "path": "materials.csv", + "format": "csv", + "columns": [ + { "name": "material_id", "type": "string" }, + { "name": "domain", "type": "string", "one_of": ["code-review", "knowledge-reference", "data-ops"] }, + { "name": "decision", "type": "string", "one_of": ["ready", "needs-review", "blocked"] }, + { "name": "reference_count", "type": "integer", "min": 0, "max": 10 } + ], + "min_rows": 1, + "max_rows": 1000, + "unique_by": ["material_id"] + }, + { + "path": "summary.json", + "format": "json", + "fields": [ + { "name": "schema_version", "type": "integer", "equals": 1 }, + { "name": "row_count", "type": "integer", "min": 1, "max": 1000 }, + { "name": "total_references", "type": "integer", "min": 0, "max": 10000 }, + { "name": "queue", "type": "string", "one_of": ["manual-review", "triage"] } + ] + } + ], + "checks": [{ "type": "csv_summary", "csv": "materials.csv", "json": "summary.json", + "row_count_field": "row_count", "sums": [{ "column": "reference_count", "field": "total_references" }] }] +} diff --git a/src/domain/artifact-content.ts b/src/domain/artifact-content.ts index 6c3d77b..c7ebc0f 100644 --- a/src/domain/artifact-content.ts +++ b/src/domain/artifact-content.ts @@ -63,6 +63,8 @@ function scalarValid(value: unknown, rule: ArtifactScalarRule): boolean { if (rule.type === "boolean" && typeof value !== "boolean") return false; if (rule.type === "array" && !Array.isArray(value)) return false; if (rule.type === "object" && (!value || typeof value !== "object" || Array.isArray(value))) return false; + // One bounded literal list; do not coerce, normalize, evaluate or retain values. + if (rule.one_of !== undefined && (typeof value !== "string" || !rule.one_of.includes(value))) return false; if (rule.equals !== undefined && value !== rule.equals) return false; if (typeof value === "number" && ((rule.min !== undefined && value < rule.min) || (rule.max !== undefined && value > rule.max))) return false; return true; diff --git a/src/domain/artifact-contract.ts b/src/domain/artifact-contract.ts index 17f15e4..7cf02c7 100644 --- a/src/domain/artifact-contract.ts +++ b/src/domain/artifact-contract.ts @@ -15,6 +15,8 @@ const scalar = z.object({ name: z.string().min(1).max(128), type: z.enum(["string", "integer", "number", "boolean", "date", "https_url", "array", "object"]), allowed_hosts: z.array(allowedHost).min(1).max(16).optional(), + one_of: z.array(z.string().max(256).refine(value => value === value.trim() && + !/[\p{Cc}\p{Cf}]/u.test(value), "options must be exact plain strings")).min(1).max(32).optional(), required: z.boolean().default(true), equals: z.union([z.string(), z.number().finite(), z.boolean()]).optional(), min: z.number().finite().optional(), max: z.number().finite().optional(), @@ -56,6 +58,10 @@ export const artifactContractSchema = z.object({ schema: z.literal("skillsync.ar const rules = file.format === "csv" ? file.columns : file.format === "json" ? file.fields : []; if (new Set(rules.map(rule => rule.name)).size !== rules.length) fail(); for (const rule of rules) { + if (rule.one_of !== undefined && (rule.type !== "string" || + new Set(rule.one_of).size !== rule.one_of.length || + (!rule.allow_empty && rule.one_of.includes("")) || + (rule.equals !== undefined && (typeof rule.equals !== "string" || !rule.one_of.includes(rule.equals))))) fail(); if (rule.type === "https_url" ? !rule.allowed_hosts || new Set(rule.allowed_hosts).size !== rule.allowed_hosts.length : rule.allowed_hosts !== undefined) fail(); if ((rule.min !== undefined || rule.max !== undefined) && !["integer", "number"].includes(rule.type)) fail(); diff --git a/tests/domain/artifact-enum-values.test.ts b/tests/domain/artifact-enum-values.test.ts new file mode 100644 index 0000000..23b0826 --- /dev/null +++ b/tests/domain/artifact-enum-values.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { parseArtifactContract } from "../../src/domain/artifact-contract"; +import { inspectArtifactContent } from "../../src/domain/artifact-content"; + +function contract(field: Record = {}) { + return { schema: "skillsync.artifacts/v1", + limits: { max_files: 1, max_file_bytes: 4096, max_total_bytes: 4096 }, + files: [{ path: "review.json", format: "json", fields: [{ + name: "decision", type: "string", one_of: ["ready", "needs-review", "blocked"], ...field, + }] }] }; +} + +function inspect(value: unknown, field: Record = {}) { + return inspectArtifactContent(parseArtifactContract(contract(field)).files[0], + Buffer.from(JSON.stringify({ decision: value }))); +} + +describe("bounded exact scalar options", () => { + it.each(["ready", "needs-review", "blocked"])("accepts declared value %s", value => { + expect(inspect(value).findings).toEqual([]); + }); + + it.each(["approved", "Ready", " ready", "ready ", "re\u200Bady", "", 1, true, null, { label: "ready" }])("rejects unlisted or mistyped material without retaining it: %j", value => { + const report = inspect(value); + expect(report.findings).toEqual([{ code: "artifact.json-value", path: "review.json", field: "decision" }]); + expect(report.facts.integers.size).toBe(0); + expect(Object.keys(report.facts)).toEqual(["integers"]); + }); + + it("shares the same rule with actual CSV cells", () => { + const rule = parseArtifactContract({ ...contract(), files: [{ path: "review.csv", format: "csv", + columns: [{ name: "decision", type: "string", one_of: ["ready", "blocked"] }], max_rows: 10 }] }).files[0]; + expect(inspectArtifactContent(rule, Buffer.from("decision\nready\nblocked\n")).findings).toEqual([]); + expect(inspectArtifactContent(rule, Buffer.from("decision\nReady\n")).findings) + .toContainEqual({ code: "artifact.csv-value", path: "review.csv", field: "decision", row: 1 }); + }); + + it("preserves formula rejection even for an explicitly listed string", () => { + const rule = parseArtifactContract({ ...contract(), files: [{ path: "review.csv", format: "csv", + columns: [{ name: "decision", type: "string", one_of: ["+ready"] }], max_rows: 10 }] }).files[0]; + expect(inspectArtifactContent(rule, Buffer.from("decision\n+ready\n")).findings) + .toContainEqual({ code: "artifact.csv-formula", path: "review.csv", field: "decision", row: 1 }); + }); + + it("allows an empty option only when the rule explicitly allows empty material", () => { + expect(inspect("", { one_of: ["", "ready"], allow_empty: true }).findings).toEqual([]); + expect(() => parseArtifactContract(contract({ one_of: ["", "ready"] }))).toThrow("artifact contract is invalid"); + }); + + it("does not require an optional JSON field to be present", () => { + const rule = parseArtifactContract(contract({ required: false })).files[0]; + expect(inspectArtifactContent(rule, Buffer.from("{}")).findings).toEqual([]); + }); + + it.each([ + { one_of: [] }, { one_of: ["ready", "ready"] }, { one_of: [1] }, + { one_of: Array.from({ length: 33 }, (_, index) => `state-${index}`) }, + { one_of: ["x".repeat(257)] }, { one_of: [" ready"] }, + { one_of: ["ready\n"] }, { one_of: ["re\u200Bady"] }, + { type: "integer", one_of: ["1"] }, { type: "date", one_of: ["2026-10-08"] }, + { type: "https_url", allowed_hosts: ["docs.example.test"], one_of: ["https://docs.example.test/"] }, + { equals: "approved" }, + ])("rejects inconsistent or unbounded declarations: %j", field => { + expect(() => parseArtifactContract(contract(field))).toThrow("artifact contract is invalid"); + }); + + it("accepts the inclusive 32-option and 256-character boundaries", () => { + const values = ["x".repeat(256), ...Array.from({ length: 31 }, (_, index) => `state-${index}`)]; + expect(inspect(values[0], { one_of: values, equals: values[0] }).findings).toEqual([]); + }); + + it("keeps original string contracts unchanged when options are absent", () => { + const rule = parseArtifactContract(contract({ one_of: undefined })).files[0]; + expect(JSON.stringify(rule)).not.toContain("one_of"); + expect(inspectArtifactContent(rule, Buffer.from('{"decision":"any-original-string"}')).findings).toEqual([]); + }); + + it("uses caller-declared currency labels without interpreting monetary values", () => { + expect(inspect("CNY", { one_of: ["CNY", "USD"] }).findings).toEqual([]); + expect(inspect("cny", { one_of: ["CNY", "USD"] }).findings) + .toContainEqual({ code: "artifact.json-value", path: "review.json", field: "decision" }); + expect(inspect("待审", { one_of: ["待审", "已完成"] }).findings).toEqual([]); + }); +}); diff --git a/tests/integration/review-intake.test.ts b/tests/integration/review-intake.test.ts new file mode 100644 index 0000000..42bf2f1 --- /dev/null +++ b/tests/integration/review-intake.test.ts @@ -0,0 +1,63 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runArtifacts, renderArtifacts } from "../../src/cli/commands/artifacts"; +import { createCli } from "../../src/cli/index"; + +const source = resolve("fixtures/product/review-intake"); +async function fixture(run: (contract: string, data: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), "skillsync-review-intake-")); + try { + await cp(join(source, "artifacts"), join(root, "artifacts"), { recursive: true }); + await cp(join(source, "contract.json"), join(root, "contract.json")); + await run(join(root, "contract.json"), join(root, "artifacts")); + } finally { await rm(root, { recursive: true, force: true }); } +} + +describe("physical review intake labels", () => { + it("accepts three synthetic domain records and a matching reference summary", async () => fixture(async (contract, data) => { + const report = await runArtifacts({ contract, path: data }); + expect(report.status).toBe("passed"); + expect(report.evidence).toBe("physical-files"); + expect(report.execution).toBe("not-run"); + expect(report.provenance).toBe("not-authenticated"); + expect(report.files.find(file => file.path === "materials.csv")?.rows).toBe(3); + })); + + it("fails unknown CSV labels through the CLI without exposing values or absolute paths", async () => fixture(async (contract, data) => { + const path = join(data, "materials.csv"), value = "PRIVATE_SYNTHETIC_SELECTION"; + await writeFile(path, (await readFile(path, "utf8")).replace("needs-review", value)); + const report = await runArtifacts({ contract, path: data }); + expect(report.findings).toContainEqual({ code: "artifact.csv-value", path: "materials.csv", field: "decision", row: 1 }); + for (const format of ["json", "text"]) { + const output = renderArtifacts(report, format); + expect(output).not.toContain(value); + expect(output).not.toContain(data); + } + let output = "", exitCode = 0; + const cli = createCli({ writeOut: text => { output += text; }, writeErr: () => undefined, setExitCode: code => { exitCode = code; } }); + await cli.parseAsync(["node", "skillsync", "artifacts", "--contract", contract, "--path", data, "--format", "json"]); + expect(exitCode).toBe(1); + expect(JSON.parse(output).status).toBe("failed"); + expect(output).not.toContain(value); + })); + + it("applies the same closed options to the JSON summary", async () => fixture(async (contract, data) => { + const path = join(data, "summary.json"); + const summary = JSON.parse(await readFile(path, "utf8")); + summary.queue = "AUTOMATIC_APPROVAL_NOT_DECLARED"; + await writeFile(path, JSON.stringify(summary)); + expect((await runArtifacts({ contract, path: data })).findings) + .toContainEqual({ code: "artifact.json-value", path: "summary.json", field: "queue" }); + })); + + it("retains independent aggregate checks when all labels are allowed", async () => fixture(async (contract, data) => { + const path = join(data, "summary.json"); + const summary = JSON.parse(await readFile(path, "utf8")); + summary.total_references = 99; + await writeFile(path, JSON.stringify(summary)); + expect((await runArtifacts({ contract, path: data })).findings) + .toContainEqual({ code: "artifact.summary-sum", path: "summary.json", field: "total_references" }); + })); +});