diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4422659 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + check: + name: Lint, typecheck, test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun test + + - name: Build + run: bun run build + + smoke: + name: Import built output on Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 22] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Import the package entrypoint + run: | + node --input-type=module -e " + import { assembleCodeJSON, draftCodeJSON, SCHEMA_VERSION } from './dist/index.js'; + const draft = draftCodeJSON({ name: 'smoke' }, null); + if (draft.name !== 'smoke') throw new Error('draft did not carry observed fields'); + if (typeof assembleCodeJSON !== 'function') throw new Error('assembleCodeJSON missing'); + console.log('ok — schema version ' + SCHEMA_VERSION); + " diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..108e42a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,57 @@ +name: Publish to npm + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Build and publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check tag matches package.json version + if: github.event_name == 'release' + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG="$(node -p "require('./package.json').version")" + if [ "$TAG" != "$PKG" ]; then + echo "::error::Tag $GITHUB_REF_NAME implies version $TAG, but package.json says $PKG" + exit 1 + fi + echo "Publishing version $PKG" + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun test + + - name: Build + run: bun run build + + - name: Publish + run: npm publish --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index d7fb108..72e0ea9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codejson-core", - "version": "0.1.1", + "version": "0.2.0", "description": "Schema, validation, and assembly logic for code.json.", "repository": { "type": "git", diff --git a/src/EXAMPLES.md b/src/EXAMPLES.md index a27a8b4..fcd6489 100644 --- a/src/EXAMPLES.md +++ b/src/EXAMPLES.md @@ -2,7 +2,9 @@ Practical, copy-pasteable recipes for the common ways `codejson-core` gets used. For *how the library works internally* (the assembly flow, precedence rules, derived fields), see [`README.md`](./README.md). -**The one thing to remember:** core is pure — it never touches the network, filesystem, or GitHub. *You* (the caller) acquire the data and handle the I/O; core merges, derives, validates, and either returns a complete `CodeJSON` or throws. +**The one thing to remember:** core is pure — it never touches the network, filesystem, or GitHub. *You* (the caller) acquire the data and handle the I/O; core merges, derives, and validates. + +**Pick your entry point:** `assemble` when the output has to be a valid, finished file — it validates and throws. `draft` when the output is deliberately incomplete — same merge, no validation gate, never throws. Both are on every profile. --- @@ -10,14 +12,15 @@ Practical, copy-pasteable recipes for the common ways `codejson-core` gets used. 1. [Assemble a code.json (the main use case)](#1-assemble-a-codejson-the-main-use-case) 2. [First-time generation (no existing file)](#2-first-time-generation-no-existing-file) -3. [Validate an existing file (a CLI `validate` command)](#3-validate-an-existing-file-a-cli-validate-command) -4. [Type-guard unknown data with `isValidCodeJSON`](#4-type-guard-unknown-data-with-isvalidcodejson) -5. [Archived repositories](#5-archived-repositories) -6. [Deterministic output in tests (injectable clock)](#6-deterministic-output-in-tests-injectable-clock) -7. [Cleaning stale / legacy data by hand](#7-cleaning-stale--legacy-data-by-hand) -8. [Using the CMS variant](#8-using-the-cms-variant) -9. [Adding your own agency (zero core changes)](#9-adding-your-own-agency-zero-core-changes) -10. [End-to-end: a GitHub Action](#10-end-to-end-a-github-action) +3. [Generate a draft for a human to finish](#3-generate-a-draft-for-a-human-to-finish) +4. [Validate an existing file (a CLI `validate` command)](#4-validate-an-existing-file-a-cli-validate-command) +5. [Type-guard unknown data with `isValidCodeJSON`](#5-type-guard-unknown-data-with-isvalidcodejson) +6. [Archived repositories](#6-archived-repositories) +7. [Deterministic output in tests (injectable clock)](#7-deterministic-output-in-tests-injectable-clock) +8. [Cleaning stale / legacy data by hand](#8-cleaning-stale--legacy-data-by-hand) +9. [Using the CMS variant](#9-using-the-cms-variant) +10. [Adding your own agency (zero core changes)](#10-adding-your-own-agency-zero-core-changes) +11. [End-to-end: a GitHub Action](#11-end-to-end-a-github-action) --- @@ -87,7 +90,41 @@ const fresh = assembleCodeJSON( --- -## 3. Validate an existing file (a CLI `validate` command) +## 3. Generate a draft for a human to finish + +A generator can only observe some of `code.json` — nothing in a repository tells you its `fismaLevel` or `maintenance`. The output is a **first draft**: correct where core could fill it in, blank where a human still has to. `assemble` would reject that (rightly — it's not a finished file), so use `draft` instead. Same merge rules, no validation gate, never throws. + +```ts +import { draftCodeJSON, validateCodeJSON } from "codejson-core"; + +// merges exactly like assembleCodeJSON, then just... returns. +const draft = draftCodeJSON(observed, existing); + +// unobservable fields come back at their baseline values, present and blank: +// draft.fismaLevel === "" ← for a human to fill in, not an error + +writeFileSync("code.json", JSON.stringify(draft, null, 2) + "\n"); + +// want to tell the human what's left? validate separately and report, +// instead of letting assemble throw. +const problems = validateCodeJSON(draft); +if (problems.length > 0) { + console.log("Draft written. Still to complete:\n" + problems.join("\n")); +} +``` + +Every baseline key survives `JSON.stringify` — no field is `undefined` — so the blanks actually reach the file the human opens. Reach for `droppedFields` here too, so stale keys removed from their file don't vanish unannounced: + +```ts +import { droppedFields } from "codejson-core"; + +const removed = droppedFields(existing); // keys not in the schema +if (removed.length > 0) console.log(`Removed stale fields: ${removed.join(", ")}`); +``` + +--- + +## 4. Validate an existing file (a CLI `validate` command) `validateCodeJSON` is pure validation — no merging, no I/O. It returns a `string[]`; an **empty array means valid**. Perfect for a `validate` subcommand or a CI gate. @@ -106,7 +143,7 @@ console.log("code.json is valid ✓"); --- -## 4. Type-guard unknown data with `isValidCodeJSON` +## 5. Type-guard unknown data with `isValidCodeJSON` When you want a *typed* value out of `unknown` (e.g. after parsing JSON from an API), `isValidCodeJSON` is a TypeScript type guard: inside the `if`, the value narrows to `CodeJSON`. @@ -125,7 +162,7 @@ Use `validateCodeJSON` when you want *why* it's invalid; use `isValidCodeJSON` w --- -## 5. Archived repositories +## 6. Archived repositories Pass `isArchived: true` and core sets `status` to `"Archival"` and appends `"archived"` to `tags` (once — it won't duplicate). @@ -139,7 +176,7 @@ const result = assembleCodeJSON(observed, existing, { isArchived: true }); --- -## 6. Deterministic output in tests (injectable clock) +## 7. Deterministic output in tests (injectable clock) `date.metadataLastUpdated` is always stamped with the current time. For reproducible snapshots, inject a fixed clock via `now`. @@ -157,48 +194,58 @@ expect(result.date.metadataLastUpdated).toBe("2026-01-01T00:00:00.000Z"); --- -## 7. Cleaning stale / legacy data by hand +## 8. Cleaning stale / legacy data by hand `assembleCodeJSON` already runs these internally, but they're exported for when you need them standalone (e.g. a migration script). ```ts -import { filterValidFields, migrateLegacyFields } from "codejson-core"; +import { filterValidFields, droppedFields, migrateLegacyFields } from "codejson-core"; // Drop any key that isn't part of the schema (stale/unknown fields): const clean = filterValidFields(rawFromDisk); +// Same rule, reported instead of applied — assembly drops these silently, +// so this is how you tell someone what went away: +const removed = droppedFields(rawFromDisk); // e.g. ["legacyField"] + // Reshape legacy shapes so old files still validate // (e.g. contractNumber: string → contractNumber: string[]): const migrated = migrateLegacyFields(clean); ``` -Both are pure and immutable — they return new objects and never mutate the input. +All three are pure and immutable — they return new values and never mutate the input. --- -## 8. Using the CMS variant +## 9. Using the CMS variant -The CMS profile bundles the CMS schema + baseline (extra fields like `fismaLevel`, `maturityModelTier`) with `validate` / `isValid` / `assemble` pre-bound. Same API surface as the default, just CMS-flavored. +The CMS profile bundles the CMS schema + baseline (extra fields like `fismaLevel`, `maturityModelTier`; the CMS organization and CC0 license default) with the whole API pre-bound. Same surface as the top-level exports, just CMS-flavored. ```ts import { cmsProfile, type CMSCodeJSON } from "codejson-core"; const result: CMSCodeJSON = cmsProfile.assemble(observed, existing, { isArchived: false }); +const draft: CMSCodeJSON = cmsProfile.draft(observed, existing); // never throws const problems = cmsProfile.validate(rawFromDisk); // [] means valid if (cmsProfile.isValid(rawFromDisk)) { // rawFromDisk is narrowed to CMSCodeJSON } +// Keyed off the CMS baseline, so CMS-only fields are NOT reported as dropped: +cmsProfile.droppedFields(rawFromDisk); + // The bundled bits are on the profile too: cmsProfile.schema; // the Zod schema cmsProfile.baseline; // the skeleton cmsProfile.SCHEMA_VERSION; // the pinned version ``` +> Reach for the profile rather than the top-level `assembleCodeJSON` / `droppedFields` when you target a variant — the top-level exports are bound to the **neutral** baseline and will strip every CMS-only field. + --- -## 9. Adding your own agency (zero core changes) +## 10. Adding your own agency (zero core changes) Every profile is just `schema + baseline + version`. To add an agency variant, generate a schema, write a baseline skeleton, and bundle them with `createCodeJSONProfile` — no changes to core. @@ -221,7 +268,7 @@ See [`README.md`](./README.md) for how schemas and baselines are structured. --- -## 10. End-to-end: a GitHub Action +## 11. End-to-end: a GitHub Action Putting it together — the caller owns all I/O; core is the pure validated-assembly step in the middle. diff --git a/src/README.md b/src/README.md index 48eef7b..2b4eb31 100644 --- a/src/README.md +++ b/src/README.md @@ -4,7 +4,7 @@ This directory is the whole library. `codejson-core` owns three things about [co 1. **Schema + types** — a version-pinned Zod schema and the `CodeJSON` type inferred from it. 2. **Validation** — turn any unknown value into a list of human-readable errors (or a type guard). -3. **Assembly** — merge freshly-observed fields with an existing file into one complete, validated `CodeJSON` — or throw. +3. **Assembly** — merge freshly-observed fields with an existing file into one `CodeJSON`, either enforcing that the result is complete (`assemble`) or not (`draft`). Everything here is **pure**: no network, no filesystem, no GitHub, no `child_process`. The only runtime dependencies are `zod` and `zod-validation-error`. If a module in here reaches out to the outside world, the design is wrong — that work belongs to the *caller* (e.g. the GitHub Action or a CLI). @@ -15,14 +15,14 @@ Everything here is **pure**: no network, no filesystem, no GitHub, no `child_pro | File | What it owns | |---|---| | `schema/neutral.ts`, `schema/cms.ts` | **Generated + committed.** Each exports a Zod `CodeJSONSchema`, the inferred `CodeJSON` type, and a pinned `SCHEMA_VERSION`. Do not hand-edit — regenerate with `bun run generate-schema`. `neutral` tracks the base `gov-codejson` schema; `cms` tracks the CMS variant (extra fields like `fismaLevel`, `maturityModelTier`). | -| `baselines/neutral.ts`, `baselines/cms.ts` | A `Partial` **skeleton** — every field present at its empty/default value (`""`, `[]`, `undefined`, `0`). The neutral baseline carries **zero** agency content (no organization, no default license). The baseline's key set also doubles as the whitelist for `filterValidFields`. | +| `baselines/neutral.ts`, `baselines/cms.ts` | A `Partial` **skeleton** — every field present at its empty/default value (`""`, `[]`, `0`). **No field is `undefined`**, enums included: a baseline is meant to be written out and filled in, and `JSON.stringify` drops undefined-valued keys. `""` fails enum validation exactly as a missing key does, so this costs `assemble` nothing. The neutral baseline carries **zero** agency content (no organization, no default license); the CMS one carries the CMS organization and the CC0 license default. The baseline's key set also doubles as the whitelist for `filterValidFields`. | | `validation.ts` | `validateWith(schema, input)` → `string[]` (`[]` means valid) and `isValidWith(schema, input)` → type guard. Schema-generic: profiles bind them to a specific variant. | -| `normalize.ts` | `filterValidFields(baseline, input)` drops any key not in the baseline (removes stale/unknown fields). `migrateLegacyFields(input)` reshapes legacy data so it still validates (currently: `contractNumber` string → array). Both are pure and immutable. | -| `assemble.ts` | `assembleWith(schema, baseline, observed, existing, options)` — the heart of the library. Merges everything with precedence, computes derived fields, validates, and returns or throws. | +| `normalize.ts` | `filterValidFields(baseline, input)` drops any key not in the baseline (removes stale/unknown fields). `droppedFields(baseline, input)` reports the same keys instead of dropping them, for callers that need to tell someone what was thrown away. `migrateLegacyFields(input)` reshapes legacy data so it still validates (currently: `contractNumber` string → array). All pure and immutable. | +| `assemble.ts` | The heart of the library, in two layers. `mergeWith(baseline, observed, existing, options)` merges with precedence and computes derived fields — pure, **never throws**, may return an incomplete draft. `assembleWith(schema, baseline, …)` is `mergeWith` plus a validation gate that throws `CodeJSONValidationError`. | | `errors.ts` | `CodeJSONValidationError` — carries a structured `.errors: string[]` plus a readable `.message`, so callers can render or hard-fail as they choose. | -| `profile.ts` | `createCodeJSONProfile(schema, baseline, version)` bundles a variant's schema + baseline + version into one `CodeJSONProfile` object with `.validate` / `.isValid` / `.assemble` pre-bound. This is how a new agency is added with **zero core changes**. | +| `profile.ts` | `createCodeJSONProfile(schema, baseline, version)` bundles a variant's schema + baseline + version into one `CodeJSONProfile` object with `.validate` / `.isValid` / `.assemble` / `.draft` / `.droppedFields` pre-bound. This is how a new agency is added with **zero core changes**. | | `profiles/neutral.ts`, `profiles/cms.ts` | Pre-built profiles for the shipped variants. | -| `index.ts` | The **public barrel**. Re-exports the neutral schema/baseline, a neutral-bound default API (`validateCodeJSON`, `isValidCodeJSON`, `assembleCodeJSON`, `filterValidFields`), the CMS variant (aliased), both profiles, the `createCodeJSONProfile` factory, and `CodeJSONValidationError`. | +| `index.ts` | The **public barrel**. Re-exports the neutral schema/baseline, a neutral-bound default API (`validateCodeJSON`, `isValidCodeJSON`, `assembleCodeJSON`, `draftCodeJSON`, `filterValidFields`, `droppedFields`), the CMS variant (aliased), both profiles, the `createCodeJSONProfile` factory, `AssembleOptions`, and `CodeJSONValidationError`. | --- @@ -44,7 +44,7 @@ Adding an agency: generate a `schema/.ts`, write a `baselines/.t ## The assembly flow (the important part) -`assembleWith` (exposed as `assembleCodeJSON` on a profile) is where observed data and prior state become one valid file. It runs in four steps: +`assembleWith` (exposed as `assembleCodeJSON` / `profile.assemble`) is where observed data and prior state become one valid file. It runs in four steps — the first three are `mergeWith`, the fourth is the gate that separates the two entry points: **Step 1 — Prepare the existing file.** If there's a current `code.json`, run it through `filterValidFields` (drop keys not in the baseline) then `migrateLegacyFields` (fix legacy shapes). If there's no existing file, this is `{}`. @@ -73,6 +73,20 @@ Adding an agency: generate a `schema/.ts`, write a `baselines/.t > **Expected throw:** if no repository URL is available anywhere, `feedbackMechanism`/`SBOM` become `/issues` and `/network/dependencies`, which fail URL validation → it throws. That's intended: a valid code.json can't exist without a repository URL. Supplying `observed.repositoryURL` is the caller's job. +### `assemble` vs. `draft` + +Both run steps 1–3. They differ only in step 4: + +| | `assemble` / `assembleCodeJSON` / `assembleWith` | `draft` / `draftCodeJSON` / `mergeWith` | +|---|---|---| +| Step 4 | validates; **throws** `CodeJSONValidationError` if incomplete | skipped — **never throws** | +| Returns | a finished, schema-valid `CodeJSON` | whatever the merge produced, complete or not | +| Use when | the output has to be a valid file (a CI gate, a finalizer) | the output is deliberately a work in progress | + +The second case is a generator: it can only observe some fields, so the rest come back at their baseline values (`""`, `[]`, `0`) for a human to fill in. That's a legitimate output, not a failure — so merging can't be welded to enforcing. Callers that produce a draft and *also* want to know what's still missing run `validate` on it separately and report the errors instead of throwing. + +Since a draft is written to disk for someone to finish, **every baseline key must survive `JSON.stringify`** — which is why no baseline value is `undefined`. See the baselines row in the table above. + --- ## Examples diff --git a/src/assemble.ts b/src/assemble.ts index 44c3f37..efa53b0 100644 --- a/src/assemble.ts +++ b/src/assemble.ts @@ -21,11 +21,10 @@ interface DerivedView { date?: { created?: string; lastModified?: string; metadataLastUpdated?: string }; } -// merge then validate everything into one valid code.json +// merge everything into one code.json. never throws meaning the result may be an incomplete draft. // baseline -> cleaned existing file -> freshly observed -> derived (later wins) // this is meant to be a pure function with no i/o -export function assembleWith>( - schema: z.ZodType, +export function mergeWith>( baseline: Partial, observed: Partial, existing: T | null, @@ -93,9 +92,21 @@ export function assembleWith>( ...derived, }; - // step 4: validate. + return result as unknown as T; +} + +// merge, then require the result to be a valid, finished code.json. +export function assembleWith>( + schema: z.ZodType, + baseline: Partial, + observed: Partial, + existing: T | null, + options: AssembleOptions = {}, +): T { + const result = mergeWith(baseline, observed, existing, options); + const errors = validateWith(schema, result); if (errors.length > 0) throw new CodeJSONValidationError(errors); - return result as unknown as T; + return result; } diff --git a/src/baselines/cms.ts b/src/baselines/cms.ts index 9172dd5..f5daec0 100644 --- a/src/baselines/cms.ts +++ b/src/baselines/cms.ts @@ -1,21 +1,25 @@ import { type CodeJSON } from "../schema/cms.js"; +// enum-typed fields sit at "" rather than undefined so they dont get dropped. +const blankEnum = "" as never; + // CMS variant skeleton of code.json (gov-codejson CMS schema) export const cmsBaselineCodeJSON: Partial = { name: "", version: "", description: "", longDescription: "", - status: undefined, + status: blankEnum, permissions: { - licenses: [], + // CMS repositories default to CC0; override in observed for anything else + licenses: [{ name: "CC0-1.0", URL: "" }], usageType: [], exemptionText: "", }, organization: "Centers for Medicare & Medicaid Services", repositoryURL: "", - repositoryHost: undefined, - repositoryVisibility: undefined, + repositoryHost: blankEnum, + repositoryVisibility: blankEnum, homepageURL: "", downloadURL: "", disclaimerURL: "", @@ -28,9 +32,9 @@ export const cmsBaselineCodeJSON: Partial = { }, platforms: [], categories: [], - softwareType: undefined, + softwareType: blankEnum, languages: [], - maintenance: undefined, + maintenance: blankEnum, contractNumber: [], SBOM: "", relatedCode: [], @@ -49,9 +53,9 @@ export const cmsBaselineCodeJSON: Partial = { feedbackMechanism: "", AIUseCaseID: "0", localisation: false, - repositoryType: undefined, + repositoryType: blankEnum, userInput: false, - fismaLevel: undefined, + fismaLevel: blankEnum, group: "", projects: [], systems: [], diff --git a/src/baselines/neutral.ts b/src/baselines/neutral.ts index e8f8b7e..2182e9b 100644 --- a/src/baselines/neutral.ts +++ b/src/baselines/neutral.ts @@ -1,11 +1,14 @@ import { type CodeJSON } from "../schema/neutral.js"; +// enum-typed fields sit at "" rather than undefined so they dont get dropped. +const blankEnum = "" as never; + // neutral, agency agnostic skeleton of code.json (gov-codejson base schema) export const baselineCodeJSON: Partial = { name: "", version: "", description: "", - status: undefined, + status: blankEnum, permissions: { licenses: [], usageType: [], @@ -13,7 +16,7 @@ export const baselineCodeJSON: Partial = { }, organization: "", repositoryURL: "", - repositoryVisibility: undefined, + repositoryVisibility: blankEnum, homepageURL: "", downloadURL: "", disclaimerURL: "", @@ -25,7 +28,7 @@ export const baselineCodeJSON: Partial = { clones: 0, }, languages: [], - maintenance: undefined, + maintenance: blankEnum, contractNumber: [], SBOM: "", relatedCode: [], diff --git a/src/index.ts b/src/index.ts index 85e136e..d0db3ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { baselineCodeJSON } from "./baselines/neutral.js"; import { neutralProfile } from "./profiles/neutral.js"; import { filterValidFields as filterValidFieldsWith, + droppedFields as droppedFieldsWith, migrateLegacyFields, } from "./normalize.js"; @@ -20,11 +21,16 @@ export { baselineCodeJSON } from "./baselines/neutral.js"; export const validateCodeJSON = neutralProfile.validate; export const isValidCodeJSON = neutralProfile.isValid; export const assembleCodeJSON = neutralProfile.assemble; +export const draftCodeJSON = neutralProfile.draft; +export { type AssembleOptions } from "./assemble.js"; // ── normalization (neutral-bound; baseline pre-injected) ────────────────── export const filterValidFields = ( input: Record, ): Partial => filterValidFieldsWith(baselineCodeJSON, input); +export const droppedFields = ( + input: Record | null, +): string[] => droppedFieldsWith(baselineCodeJSON, input); export { migrateLegacyFields }; // ── CMS variant (shipped preset, aliased) ───────────────────────────────── diff --git a/src/normalize.ts b/src/normalize.ts index e827879..d38d3a0 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -15,6 +15,17 @@ export function filterValidFields>( return filtered as Partial; } +// the keys filterValidFields would drop so a caller can tell someone what it threw away +export function droppedFields>( + baseline: Partial, + input: Record | null, +): string[] { + if (!input) return []; + + const validKeys = new Set(Object.keys(baseline)); + return Object.keys(input).filter((key) => !validKeys.has(key)); +} + // reshape legacy data so it still validates. any field migration that need to happen can live here export function migrateLegacyFields>( input: Partial, diff --git a/src/profile.ts b/src/profile.ts index 3b988b9..42072ab 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -1,6 +1,7 @@ import { type z } from "zod"; import { validateWith, isValidWith } from "./validation.js"; -import { assembleWith, type AssembleOptions } from "./assemble.js"; +import { assembleWith, mergeWith, type AssembleOptions } from "./assemble.js"; +import { droppedFields } from "./normalize.js"; // a bundled variant. everything a caller needs export interface CodeJSONProfile> { @@ -9,11 +10,19 @@ export interface CodeJSONProfile> { readonly SCHEMA_VERSION: string; validate(input: unknown): string[]; isValid(input: unknown): input is T; + // assemble requires the result to be a valid, finished code.json and throws otherwise. + // draft returns whatever the merge produced, incomplete or not, and never throws. assemble( observed: Partial, existing: T | null, options?: AssembleOptions, ): T; + draft( + observed: Partial, + existing: T | null, + options?: AssembleOptions + ): T; + droppedFields(input: Record | null): string[]; } // build a profile from a variant's schema, baseline, and version. this gives you a packaged object that holds all the functions and types you need with the agency you choose @@ -30,5 +39,8 @@ export function createCodeJSONProfile>( isValid: (input): input is T => isValidWith(schema, input), assemble: (observed, existing, options) => assembleWith(schema, baseline, observed, existing, options), + draft: (observed, existing, options) => + mergeWith(baseline, observed, existing, options), + droppedFields: (input) => droppedFields(baseline, input), }; } diff --git a/tests/assemble.test.ts b/tests/assemble.test.ts index 2072514..4eea835 100644 --- a/tests/assemble.test.ts +++ b/tests/assemble.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { assembleWith } from "../src/assemble.js"; +import { assembleWith, mergeWith } from "../src/assemble.js"; import { CodeJSONSchema, type CodeJSON } from "../src/schema/neutral.js"; import { baselineCodeJSON } from "../src/baselines/neutral.js"; import { CodeJSONValidationError } from "../src/errors.js"; @@ -29,6 +29,16 @@ const assemble = ( ...options, }); +const merge = ( + observed: Partial, + existing: CodeJSON | null = null, + options = {}, +) => + mergeWith(baselineCodeJSON, observed, existing, { + now: fixedNow, + ...options, + }); + describe("assembleWith", () => { test("produces a schema-valid document from minimal observed input", () => { const result = assemble(minimalObserved); @@ -125,3 +135,48 @@ describe("assembleWith", () => { }); }); }); + +describe("mergeWith", () => { + test("returns an incomplete draft where assembleWith throws", () => { + // omit maintenance -> baseline leaves it "" -> invalid, but merging doesn't care. + const { maintenance, ...partial } = minimalObserved; + void maintenance; + + expect(() => assemble(partial)).toThrow(CodeJSONValidationError); + + const draft = merge(partial); + expect(draft.maintenance).toBe("" as never); + expect(CodeJSONSchema.safeParse(draft).success).toBe(false); + }); + + test("leaves every unsupplied enum blank rather than absent", () => { + // the reason "" beats undefined: a draft is written to disk for a human to + // finish, and JSON.stringify would drop the keys they need to fill in. + const draft = merge({}); + const roundTripped = JSON.parse(JSON.stringify(draft)) as Record< + string, + unknown + >; + expect(roundTripped.status).toBe(""); + expect(roundTripped.repositoryVisibility).toBe(""); + expect(roundTripped.maintenance).toBe(""); + }); + + test("matches assembleWith exactly when the input is already valid", () => { + const observed = { ...minimalObserved, name: "same", laborHours: 12 }; + const existing = clone(validNeutral); + + expect(merge(observed, existing)).toEqual(assemble(observed, existing)); + }); + + test("applies the same derived-field and archival rules", () => { + const draft = merge({ ...minimalObserved, tags: ["a"] }, null, { + isArchived: true, + }); + expect(draft.feedbackMechanism).toBe("https://github.com/x/y/issues"); + expect(draft.SBOM).toBe("https://github.com/x/y/network/dependencies"); + expect(draft.date.metadataLastUpdated).toBe(FIXED); + expect(draft.status).toBe("Archival"); + expect(draft.tags).toEqual(["a", "archived"]); + }); +}); diff --git a/tests/baselines.test.ts b/tests/baselines.test.ts new file mode 100644 index 0000000..cdaf734 --- /dev/null +++ b/tests/baselines.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { baselineCodeJSON } from "../src/baselines/neutral.js"; +import { cmsBaselineCodeJSON } from "../src/baselines/cms.js"; +import { CodeJSONSchema as NeutralSchema } from "../src/schema/neutral.js"; +import { CodeJSONSchema as CMSSchema } from "../src/schema/cms.js"; + +const baselines = [ + ["neutral", baselineCodeJSON, NeutralSchema] as const, + ["cms", cmsBaselineCodeJSON, CMSSchema] as const, +]; + +describe.each(baselines)("%s baseline", (_name, baseline, schema) => { + // a baseline is a fillable skeleton. JSON.stringify drops undefined-valued keys, + // so any field left undefined here would vanish from a draft written to disk -- + // which is exactly the set of fields a human still has to fill in. + test("every key survives a JSON round trip", () => { + const roundTripped = JSON.parse(JSON.stringify(baseline)) as Record< + string, + unknown + >; + expect(Object.keys(roundTripped).sort()).toEqual( + Object.keys(baseline).sort(), + ); + }); + + test("holds no undefined values", () => { + const undefinedKeys = Object.entries(baseline) + .filter(([, value]) => value === undefined) + .map(([key]) => key); + expect(undefinedKeys).toEqual([]); + }); + + // the blank enums are a serialization fix, not a loosening of the contract: + // an empty baseline must still fail validation the way it always did. + test("is not itself a valid document", () => { + expect(schema.safeParse(baseline).success).toBe(false); + }); +}); + +describe("cms baseline", () => { + test("carries the CMS organization and the CC0 license default", () => { + expect(cmsBaselineCodeJSON.organization).toBe( + "Centers for Medicare & Medicaid Services", + ); + expect(cmsBaselineCodeJSON.permissions?.licenses).toEqual([ + { name: "CC0-1.0", URL: "" }, + ]); + }); +}); + +describe("neutral baseline", () => { + test("carries no agency content", () => { + expect(baselineCodeJSON.organization).toBe(""); + expect(baselineCodeJSON.permissions?.licenses).toEqual([]); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index ec7239c..91f99ac 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -3,7 +3,9 @@ import { validateCodeJSON, isValidCodeJSON, assembleCodeJSON, + draftCodeJSON, filterValidFields, + droppedFields, neutralProfile, cmsProfile, createCodeJSONProfile, @@ -37,11 +39,24 @@ describe("public neutral-bound API", () => { expect(isValidCodeJSON(result)).toBe(true); }); + test("draftCodeJSON returns an incomplete result instead of throwing", () => { + const draft = draftCodeJSON({ name: "half-finished" }, null); + expect(draft.name).toBe("half-finished"); + expect(isValidCodeJSON(draft)).toBe(false); + }); + test("filterValidFields is pre-bound to the neutral baseline", () => { const filtered = filterValidFields({ name: "ok", notARealField: true }); expect(filtered).toEqual({ name: "ok" }); }); + test("droppedFields reports what filterValidFields would remove", () => { + expect(droppedFields({ name: "ok", notARealField: true })).toEqual([ + "notARealField", + ]); + expect(droppedFields(null)).toEqual([]); + }); + test("exposes the pinned schema version", () => { expect(SCHEMA_VERSION).toBe("2.0.0"); }); @@ -63,6 +78,24 @@ describe("profiles", () => { // the neutral fixture lacks CMS-only fields, so it should not validate as CMS. expect(cmsProfile.validate(clone(validNeutral)).length).toBeGreaterThan(0); }); + + test("cmsProfile.draft produces a CMS draft the CMS schema still rejects", () => { + const draft = cmsProfile.draft({ name: "wip" }, null); + expect(draft.name).toBe("wip"); + expect(draft.organization).toBe("Centers for Medicare & Medicaid Services"); + expect(draft.fismaLevel).toBe("" as never); + expect(cmsProfile.isValid(draft)).toBe(false); + }); + + test("droppedFields on a profile keys off that variant's baseline", () => { + // fismaLevel is CMS-only: dropped by the neutral baseline, kept by the CMS one. + const input = { name: "x", fismaLevel: "low", notARealField: true }; + expect(neutralProfile.droppedFields(input)).toEqual([ + "fismaLevel", + "notARealField", + ]); + expect(cmsProfile.droppedFields(input)).toEqual(["notARealField"]); + }); }); describe("createCodeJSONProfile", () => { diff --git a/tests/normalize.test.ts b/tests/normalize.test.ts index 9ea5b4f..f431956 100644 --- a/tests/normalize.test.ts +++ b/tests/normalize.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { filterValidFields, migrateLegacyFields } from "../src/normalize.js"; +import { + filterValidFields, + droppedFields, + migrateLegacyFields, +} from "../src/normalize.js"; + +const baseline = { name: "", version: "", tags: [] as string[] }; describe("filterValidFields", () => { - const baseline = { name: "", version: "", tags: [] as string[] }; test("keeps only keys present in the baseline", () => { const result = filterValidFields(baseline, { @@ -25,6 +30,22 @@ describe("filterValidFields", () => { }); }); +describe("droppedFields", () => { + test("reports exactly the keys filterValidFields removes", () => { + const input = { name: "keep", bogus: "drop me", anotherUnknown: 42 }; + expect(droppedFields(baseline, input)).toEqual(["bogus", "anotherUnknown"]); + expect(Object.keys(filterValidFields(baseline, input))).toEqual(["name"]); + }); + + test("returns an empty array when everything is known", () => { + expect(droppedFields(baseline, { name: "x", version: "1" })).toEqual([]); + }); + + test("treats a missing file as nothing dropped", () => { + expect(droppedFields(baseline, null)).toEqual([]); + }); +}); + describe("migrateLegacyFields", () => { test("wraps a legacy string contractNumber into an array", () => { expect(migrateLegacyFields({ contractNumber: "ABC-123" } as never)).toEqual({