Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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);
"
57 changes: 57 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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 }}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
87 changes: 67 additions & 20 deletions src/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@

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.

---

## Contents

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)

---

Expand Down Expand Up @@ -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.

Expand All @@ -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`.

Expand All @@ -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).

Expand All @@ -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`.

Expand All @@ -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.

Expand All @@ -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.

Expand Down
28 changes: 21 additions & 7 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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<CodeJSON>` **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<CodeJSON>` **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`. |

---

Expand All @@ -44,7 +44,7 @@ Adding an agency: generate a `schema/<agency>.ts`, write a `baselines/<agency>.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 `{}`.

Expand Down Expand Up @@ -73,6 +73,20 @@ Adding an agency: generate a `schema/<agency>.ts`, write a `baselines/<agency>.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
Expand Down
Loading
Loading