Skip to content
Open
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
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.2.0",
"version": "0.3.0",
"description": "Schema, validation, and assembly logic for code.json.",
"repository": {
"type": "git",
Expand Down
41 changes: 22 additions & 19 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ Everything here is **pure**: no network, no filesystem, no GitHub, no `child_pro
| `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). `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`. |
| `assemble.ts` | The heart of the library, in two layers. `mergeWith(baseline, observed, existing, options)` deep-merges field by field under per-field rules, then fills in computed 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` / `.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`, `draftCodeJSON`, `filterValidFields`, `droppedFields`), the CMS variant (aliased), both profiles, the `createCodeJSONProfile` factory, `AssembleOptions`, and `CodeJSONValidationError`. |
| `profiles/index.ts` | The `profiles` registry (`{ neutral, cms }`) and its `ProfileName` key type, so callers can pick a profile by name. |
| `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 `profiles` registry and `ProfileName`, the `createCodeJSONProfile` factory, `AssembleOptions`, and `CodeJSONValidationError`. |

---

Expand All @@ -46,28 +47,30 @@ Adding an agency: generate a `schema/<agency>.ts`, write a `baselines/<agency>.t

`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 `{}`.
**Step 1 — Clean the inputs.** 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 `{}`. `observed` also goes through `filterValidFields`, so keys the profile doesn't define (e.g. `repositoryHost` on neutral) are dropped silently rather than reported.

**Step 2 — Compute derived fields.** Some fields aren't a simple override; they have selection logic. With `repoURL = observed.repositoryURL ?? existing.repositoryURL ?? ""`:
**Step 2 — Deep-merge baseline, existing and observed.** Wherever any of the three is a plain object, the merge recurses into it over the union of their keys (baseline keys first, then existing, then observed, so key order is stable). Nested objects are never replaced wholesale, so a manual `permissions.licenses` survives an observed `permissions.usageType`. At each leaf the field's dotted path picks a rule:

| Field path | Rule | Result |
|---|---|---|
| `repositoryURL`, `repositoryVisibility`, `laborHours`, `reuseFrequency.forks`, `date.created`, `date.lastModified` | **observed** | observed if set, else existing, else baseline |
| `tags`, `reusedCode` | **union** | existing items in order, then each observed item not already present |
| everything else, including `name` and `description` | **existing** | existing if set, else observed, else baseline |
Comment on lines +54 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a review of all the code.json fields and I agree with the rule classifications you made here 🙌


A value is **unset** when it's `undefined`, `null`, or deep-equal to the baseline value at that path. So a field left at its baseline default (`""`, `[]`, `maturityModelTier: 0`) is treated as blank and detection can fill it. Arrays are leaves: outside `tags`/`reusedCode`, an existing `languages` list wins whole, it isn't unioned.

For **union**, strings match on exact equality. Objects match when their `URL` or their `name` is equal, ignoring case and empty values, so a manual `reusedCode` entry isn't duplicated by a detected one. Union keeps no record of removals: delete a detected tag or dependency by hand and the next run adds it back.

**Step 3 — Fill in computed fields** on the merged result, with `repoURL = result.repositoryURL ?? ""`:

| Field | Rule |
|---|---|
| `feedbackMechanism` | keep existing if truthy, else `` `${repoURL}/issues` `` |
| `SBOM` | keep existing if truthy, else `` `${repoURL}/network/dependencies` `` |
| `description` | observed if non-empty (trimmed), else existing, else `""` |
| `tags` | observed if non-empty, else existing; if `isArchived` and `"archived"` absent, append it once |
| `status` | set to `"Archival"` only when `isArchived` |
| `reuseFrequency` | `forks` from observed → existing → 0; `clones` preserved from existing (callers usually can't observe clones) |
| `date` | `created`/`lastModified` from observed → existing → `""`; `metadataLastUpdated` = `now()` (injectable clock) as ISO string |

**Step 3 — Merge with precedence (later wins):**
| `feedbackMechanism` | if empty, `` `${repoURL}/issues` `` |
| `SBOM` | if empty, `` `${repoURL}/network/dependencies` `` |
| `date.metadataLastUpdated` | always `now()` (injectable clock) as ISO string |
| `status`, `tags` | only when `isArchived`: `status` becomes `"Archival"` and `"archived"` is appended to `tags` once |

```
{ ...baseline, // 1. neutral floor — every field present
...cleanedExisting, // 2. current committed values (filtered + migrated)
...observed, // 3. freshly-acquired fields
...derived } // 4. computed fields — authoritative, applied last
```
Re-running the merge on its own output with the same observations gives the same document, apart from `metadataLastUpdated`.

**Step 4 — Validate.** Run the result through the schema. If there are any errors, **throw** `CodeJSONValidationError` (with the structured list). Otherwise return the complete `CodeJSON`.

Expand Down
167 changes: 105 additions & 62 deletions src/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,87 @@ export interface AssembleOptions {
now?: () => Date;
}

// fields whose final value needs selection/synthesis logic, not a plain override.
// everything a caller sends that IS a plain override just flows through the spread below and does NOT belong here.
interface DerivedView {
repositoryURL?: string;
feedbackMechanism?: string;
SBOM?: string;
description?: string;
tags?: string[];
status?: string;
reuseFrequency?: { forks?: number; clones?: number };
date?: { created?: string; lastModified?: string; metadataLastUpdated?: string };
type Policy = "existing" | "observed" | "union";

// who wins when both sides have a value. anything unlisted is "existing"
const POLICY: Record<string, Policy> = {
repositoryURL: "observed",
repositoryVisibility: "observed",
laborHours: "observed",
"reuseFrequency.forks": "observed",
"date.created": "observed",
"date.lastModified": "observed",
tags: "union",
reusedCode: "union",
};
Comment on lines +13 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this, this is a very clever and understandable way of codifying merge logic 🔥


const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

// a value still sitting at its baseline default counts as unset, so detection can fill it
const isUnset = (value: unknown, base: unknown): boolean =>
value === undefined ||
value === null ||
JSON.stringify(value) === JSON.stringify(base);

const sameText = (a: unknown, b: unknown): boolean =>
typeof a === "string" &&
typeof b === "string" &&
a !== "" &&
a.toLowerCase() === b.toLowerCase();

// objects like reusedCode entries match on URL or name
const sameItem = (a: unknown, b: unknown): boolean =>
isPlainObject(a) && isPlainObject(b)
? sameText(a.URL, b.URL) || sameText(a.name, b.name)
: a === b;

function union(existing: unknown, observed: unknown): unknown[] {
const result = Array.isArray(existing) ? [...existing] : [];
for (const item of Array.isArray(observed) ? observed : []) {
if (!result.some((kept) => sameItem(kept, item))) result.push(item);
}
return result;
}

function mergeValue(
path: string,
base: unknown,
existing: unknown,
observed: unknown,
): unknown {
const objects = [base, existing, observed].filter(isPlainObject);

if (objects.length > 0) {
const child = (value: unknown, key: string) =>
isPlainObject(value) ? value[key] : undefined;
const result: Record<string, unknown> = {};

for (const key of new Set(objects.flatMap(Object.keys))) {
const value = mergeValue(
path ? `${path}.${key}` : key,
child(base, key),
child(existing, key),
child(observed, key),
);
if (value !== undefined) result[key] = value;
}

return result;
}

const policy = POLICY[path] ?? "existing";
if (policy === "union") return union(existing, observed);

const [first, second] =
policy === "existing" ? [existing, observed] : [observed, existing];
if (!isUnset(first, base)) return first;
if (!isUnset(second, base)) return second;
return base;
}

// 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)
// manual values in the existing file win unless POLICY says the field is observed or unioned.
// this is meant to be a pure function with no i/o
export function mergeWith<T extends Record<string, unknown>>(
baseline: Partial<T>,
Expand All @@ -39,60 +105,37 @@ export function mergeWith<T extends Record<string, unknown>>(
)
: {};

// step 2: compute the derived fields.
const obs = observed as unknown as DerivedView;
const ex = (existing ?? {}) as unknown as DerivedView;

const repoURL = obs.repositoryURL ?? ex.repositoryURL ?? "";

const feedbackMechanism = ex.feedbackMechanism
? ex.feedbackMechanism
: `${repoURL}/issues`;

const SBOM = ex.SBOM ? ex.SBOM : `${repoURL}/network/dependencies`;

const description =
obs.description && obs.description.trim() !== ""
? obs.description
: (ex.description ?? "");

const baseTags = obs.tags && obs.tags.length > 0 ? obs.tags : (ex.tags ?? []);
const tags =
isArchived && !baseTags.includes("archived")
? [...baseTags, "archived"]
: baseTags;

const reuseFrequency = {
forks: obs.reuseFrequency?.forks ?? ex.reuseFrequency?.forks ?? 0,
clones: ex.reuseFrequency?.clones ?? 0,
};

const date = {
created: obs.date?.created ?? ex.date?.created ?? "",
lastModified: obs.date?.lastModified ?? ex.date?.lastModified ?? "",
// observed keys the variant doesn't define are dropped silently
const cleanedObserved = filterValidFields(
baseline,
observed as Record<string, unknown>,
);

// step 2: merge field by field.
const result = mergeValue(
"",
baseline,
cleanedExisting,
cleanedObserved,
) as Record<string, unknown>;

// step 3: fill in what can be computed from the merged result.
const repoURL = result.repositoryURL ?? "";
if (!result.feedbackMechanism) result.feedbackMechanism = `${repoURL}/issues`;
if (!result.SBOM) result.SBOM = `${repoURL}/network/dependencies`;

result.date = {
...(isPlainObject(result.date) ? result.date : {}),
metadataLastUpdated: (now?.() ?? new Date()).toISOString(),
};

const derived: Record<string, unknown> = {
feedbackMechanism,
SBOM,
description,
tags,
reuseFrequency,
date,
};

if (isArchived) derived.status = "Archival";

// step 3: merge with precedence (later wins).
const result = {
...baseline,
...cleanedExisting,
...observed,
...derived,
};
if (isArchived) {
const tags = Array.isArray(result.tags) ? result.tags : [];
result.status = "Archival";
result.tags = tags.includes("archived") ? tags : [...tags, "archived"];
}

return result as unknown as T;
return result as T;
}

// merge, then require the result to be a valid, finished code.json.
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export { cmsBaselineCodeJSON } from "./baselines/cms.js";
// ── profiles + factory (add an agency, zero core changes) ─────────────────
export { neutralProfile } from "./profiles/neutral.js";
export { cmsProfile } from "./profiles/cms.js";
export { profiles, type ProfileName } from "./profiles/index.js";
export {
createCodeJSONProfile,
type CodeJSONProfile,
Expand Down
5 changes: 5 additions & 0 deletions src/profiles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { neutralProfile } from "./neutral.js";
import { cmsProfile } from "./cms.js";

export const profiles = { neutral: neutralProfile, cms: cmsProfile } as const;
export type ProfileName = keyof typeof profiles;
Loading
Loading