From f3b2bba8d76058a896b5bd0af48e4574e67eb2b3 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Wed, 30 Sep 2026 14:58:53 -0400 Subject: [PATCH 1/4] adding a profile registry --- src/index.ts | 1 + src/profiles/index.ts | 5 +++++ tests/index.test.ts | 9 +++++++++ 3 files changed, 15 insertions(+) create mode 100644 src/profiles/index.ts diff --git a/src/index.ts b/src/index.ts index d0db3ea..646f14f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/profiles/index.ts b/src/profiles/index.ts new file mode 100644 index 0000000..33057e2 --- /dev/null +++ b/src/profiles/index.ts @@ -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; diff --git a/tests/index.test.ts b/tests/index.test.ts index 91f99ac..e1f394d 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -8,6 +8,7 @@ import { droppedFields, neutralProfile, cmsProfile, + profiles, createCodeJSONProfile, SCHEMA_VERSION, CMS_SCHEMA_VERSION, @@ -98,6 +99,14 @@ describe("profiles", () => { }); }); +describe("profiles registry", () => { + test("holds the neutral and cms profiles by name", () => { + expect(Object.keys(profiles)).toEqual(["neutral", "cms"]); + expect(profiles.neutral).toBe(neutralProfile); + expect(profiles.cms).toBe(cmsProfile); + }); +}); + describe("createCodeJSONProfile", () => { test("bundles schema, baseline, and version into one object", () => { const profile = createCodeJSONProfile( From 3d5d199d1d7d55f1a659bd2b07f76deb6e17c530 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Wed, 30 Sep 2026 15:18:00 -0400 Subject: [PATCH 2/4] filter oberved fields against baseline --- src/assemble.ts | 10 ++++++++-- tests/assemble.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/assemble.ts b/src/assemble.ts index efa53b0..45e7b38 100644 --- a/src/assemble.ts +++ b/src/assemble.ts @@ -39,8 +39,14 @@ export function mergeWith>( ) : {}; + // observed keys the variant doesn't define are dropped silently + const cleanedObserved = filterValidFields( + baseline, + observed as Record, + ); + // step 2: compute the derived fields. - const obs = observed as unknown as DerivedView; + const obs = cleanedObserved as unknown as DerivedView; const ex = (existing ?? {}) as unknown as DerivedView; const repoURL = obs.repositoryURL ?? ex.repositoryURL ?? ""; @@ -88,7 +94,7 @@ export function mergeWith>( const result = { ...baseline, ...cleanedExisting, - ...observed, + ...cleanedObserved, ...derived, }; diff --git a/tests/assemble.test.ts b/tests/assemble.test.ts index 4eea835..1cbc41b 100644 --- a/tests/assemble.test.ts +++ b/tests/assemble.test.ts @@ -77,6 +77,15 @@ describe("assembleWith", () => { const result = assemble(minimalObserved, existing) as Record; expect(result.legacyGarbage).toBeUndefined(); }); + + test("drops observed keys the baseline does not define", () => { + const observed = { + ...minimalObserved, + repositoryHost: "github", + } as never; + const result = assemble(observed) as Record; + expect(result).not.toHaveProperty("repositoryHost"); + }); }); describe("derived fields", () => { From 10ac4bb219c0ab7269d0edee9fe20295115aa2f5 Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Mon, 5 Oct 2026 11:15:51 -0400 Subject: [PATCH 3/4] porting over merge logic from ACG and additionnal --- src/assemble.ts | 157 +++++++++++++++++++++++++---------------- tests/assemble.test.ts | 157 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 249 insertions(+), 65 deletions(-) diff --git a/src/assemble.ts b/src/assemble.ts index 45e7b38..ec6f658 100644 --- a/src/assemble.ts +++ b/src/assemble.ts @@ -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 = { + repositoryURL: "observed", + repositoryVisibility: "observed", + laborHours: "observed", + "reuseFrequency.forks": "observed", + "date.created": "observed", + "date.lastModified": "observed", + tags: "union", + reusedCode: "union", +}; + +const isPlainObject = (value: unknown): value is Record => + 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 = {}; + + 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>( baseline: Partial, @@ -45,60 +111,31 @@ export function mergeWith>( observed as Record, ); - // step 2: compute the derived fields. - const obs = cleanedObserved 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; + // step 2: merge field by field. + const result = mergeValue( + "", + baseline, + cleanedExisting, + cleanedObserved, + ) as Record; - const reuseFrequency = { - forks: obs.reuseFrequency?.forks ?? ex.reuseFrequency?.forks ?? 0, - clones: ex.reuseFrequency?.clones ?? 0, - }; + // 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`; - const date = { - created: obs.date?.created ?? ex.date?.created ?? "", - lastModified: obs.date?.lastModified ?? ex.date?.lastModified ?? "", + result.date = { + ...(isPlainObject(result.date) ? result.date : {}), metadataLastUpdated: (now?.() ?? new Date()).toISOString(), }; - const derived: Record = { - feedbackMechanism, - SBOM, - description, - tags, - reuseFrequency, - date, - }; - - if (isArchived) derived.status = "Archival"; - - // step 3: merge with precedence (later wins). - const result = { - ...baseline, - ...cleanedExisting, - ...cleanedObserved, - ...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. diff --git a/tests/assemble.test.ts b/tests/assemble.test.ts index 1cbc41b..9afb9fe 100644 --- a/tests/assemble.test.ts +++ b/tests/assemble.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { assembleWith, mergeWith } from "../src/assemble.js"; import { CodeJSONSchema, type CodeJSON } from "../src/schema/neutral.js"; import { baselineCodeJSON } from "../src/baselines/neutral.js"; +import { type CodeJSON as CMSCodeJSON } from "../src/schema/cms.js"; +import { cmsProfile } from "../src/profiles/cms.js"; import { CodeJSONValidationError } from "../src/errors.js"; import { validNeutral, clone } from "./fixtures.js"; @@ -57,12 +59,12 @@ describe("assembleWith", () => { expect(result.date.metadataLastUpdated).toBe(FIXED); }); - describe("field precedence (baseline < existing < observed)", () => { - test("observed overrides existing for plain fields", () => { + describe("field precedence", () => { + test("observed overrides existing for observed fields", () => { const existing = clone(validNeutral); - existing.name = "old name"; - const result = assemble({ ...minimalObserved, name: "new name" }, existing); - expect(result.name).toBe("new name"); + existing.repositoryURL = "https://github.com/old/repo"; + const result = assemble(minimalObserved, existing); + expect(result.repositoryURL).toBe("https://github.com/x/y"); }); test("existing supplies fields the observed input omits", () => { @@ -189,3 +191,148 @@ describe("mergeWith", () => { expect(draft.tags).toEqual(["a", "archived"]); }); }); + +describe("merge rules (cms)", () => { + const cmsMerge = ( + existing: Record, + observed: Record, + options = {}, + ) => + cmsProfile.draft( + observed as Partial, + existing as CMSCodeJSON, + { now: fixedNow, ...options }, + ); + + const mit = { name: "MIT" as const, URL: "https://example.com/license" }; + const uswds = { name: "uswds", URL: "https://github.com/uswds/uswds" }; + const manualDep = { name: "internal-lib", URL: "https://example.com/lib" }; + + test("keeps an existing license while observed fills usageType", () => { + const result = cmsMerge( + { permissions: { licenses: [mit], usageType: [], exemptionText: "" } }, + { permissions: { usageType: ["openSource"] } }, + ); + expect(result.permissions).toEqual({ + licenses: [mit], + usageType: ["openSource"], + exemptionText: "", + }); + }); + + test("fills nested keys missing from the existing file", () => { + const result = cmsMerge({ permissions: { licenses: [mit] } }, {}); + expect(result.permissions).toEqual({ + licenses: [mit], + usageType: [], + exemptionText: "", + }); + }); + + test("keeps existing languages", () => { + const result = cmsMerge( + { languages: ["TypeScript", "HCL"] }, + { languages: ["TypeScript"] }, + ); + expect(result.languages).toEqual(["TypeScript", "HCL"]); + }); + + test("unions manual tags with observed topics", () => { + const result = cmsMerge({ tags: ["manual-tag"] }, { tags: ["topic"] }); + expect(result.tags).toEqual(["manual-tag", "topic"]); + }); + + test("keeps a manual name", () => { + const result = cmsMerge( + { name: "Pretty Project Name" }, + { name: "pretty-project" }, + ); + expect(result.name).toBe("Pretty Project Name"); + }); + + test("keeps a manual description", () => { + const result = cmsMerge( + { description: "Manual" }, + { description: "From GitHub" }, + ); + expect(result.description).toBe("Manual"); + }); + + test("fills a blank description from observed", () => { + const result = cmsMerge( + { description: "" }, + { description: "From GitHub" }, + ); + expect(result.description).toBe("From GitHub"); + }); + + test("takes laborHours and forks from observed and keeps the rest", () => { + const result = cmsMerge( + { + laborHours: 500, + reuseFrequency: { forks: 3, clones: 40, downloads: 7 }, + }, + { laborHours: 812, reuseFrequency: { forks: 9 } }, + ); + expect(result.laborHours).toBe(812); + expect(result.reuseFrequency).toEqual({ + forks: 9, + clones: 40, + downloads: 7, + }); + }); + + test("replaces a value still at its baseline default", () => { + const result = cmsMerge({ maturityModelTier: 0 }, { maturityModelTier: 3 }); + expect(result.maturityModelTier).toBe(3); + }); + + test("keeps a value that differs from the baseline default", () => { + const result = cmsMerge({ maturityModelTier: 2 }, { maturityModelTier: 3 }); + expect(result.maturityModelTier).toBe(2); + }); + + test("does not append reusedCode that matches an existing entry", () => { + const result = cmsMerge( + { reusedCode: [uswds, manualDep] }, + { reusedCode: [{ ...uswds, name: "USWDS" }] }, + ); + expect(result.reusedCode).toEqual([uswds, manualDep]); + }); + + test("merging its own output again changes only metadataLastUpdated", () => { + const observed = { + name: "pretty-project", + repositoryURL: "https://github.com/x/y", + laborHours: 812, + reuseFrequency: { forks: 9 }, + tags: ["topic"], + reusedCode: [uswds], + maturityModelTier: 3, + }; + const first = cmsMerge( + { name: "Pretty Project Name", tags: ["manual-tag"] }, + observed, + ); + const later = "2027-01-01T00:00:00.000Z"; + const second = cmsMerge(first, observed, { now: () => new Date(later) }); + + expect(second.date.metadataLastUpdated).toBe(later); + expect(first).toEqual({ + ...second, + date: { ...second.date, metadataLastUpdated: FIXED }, + }); + }); + + test("archiving sets Archival status and adds the archived tag once", () => { + const first = cmsMerge( + { tags: ["manual-tag"] }, + { tags: ["topic"] }, + { isArchived: true }, + ); + const second = cmsMerge(first, { tags: ["topic"] }, { isArchived: true }); + + expect(second.status).toBe("Archival"); + expect(second.tags).toEqual(["manual-tag", "topic", "archived"]); + }); +}); From b47d0872fb0ed8a28c2b1411e789e1f710a4b91b Mon Sep 17 00:00:00 2001 From: Sachin Panayil Date: Mon, 5 Oct 2026 11:29:21 -0400 Subject: [PATCH 4/4] updating readme for release v0.3.0 --- package.json | 2 +- src/README.md | 41 ++++++++++++++++++++++------------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 72e0ea9..e66de7b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/README.md b/src/README.md index 2b4eb31..9634446 100644 --- a/src/README.md +++ b/src/README.md @@ -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` **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`. | --- @@ -46,28 +47,30 @@ Adding an agency: generate a `schema/.ts`, write a `baselines/.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 | + +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`.