diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..e16b0010 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,66 @@ + + +## module-name: One line description of your change (less than 72 characters) + +## Problem + +Explain the context and why you're making that change. What is the problem +you're trying to solve? In some cases there is not a problem and this can be +thought of being the motivation for your change. + +## Solution + +Describe the modifications you've done. + +## AI Usage +- [ ] Generated AI was used in this contribution + +If checked, please provide an explanation on how AI was used in the development of this pull request: + +- **Description:** + - _Include a high level description of Gen AI utilization_ +- **Type of Assistance:** + - [ ] Code generation + - [ ] Documentation + - [ ] Debugging + - [ ] Testing + - [ ] Refactoring + - [ ] Other: +- **AI System Used:** + - [ ] ChatGPT + - [ ] Claude + - [ ] Gemini + - [ ] GitHub Copilot +- **Level of Modification:** + - [ ] As-is + - [ ] Modified + - [ ] Used as inspiration + +## Result + +What will change as a result of your pull request? Note that sometimes this +section is unnecessary because it is self-explanatory based on the solution. + +Some important notes regarding the summary line: + +* Describe what was done; not the result +* Use the active voice +* Use the present tense +* Capitalize properly +* Do not end in a period — this is a title/subject +* Prefix the subject with its scope + +## Test Plan + +(Write your test plan here. If you changed any code, please provide us with +clear instructions on how you verified your changes work.) \ No newline at end of file diff --git a/.github/workflows/update-codejson-schema.yml b/.github/workflows/update-codejson-schema.yml deleted file mode 100644 index c81442cd..00000000 --- a/.github/workflows/update-codejson-schema.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Update CodeJSON Schema - -on: - workflow_dispatch: - schedule: - - cron: "0 0 1 * *" - -jobs: - update-schema: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "20" - cache: "npm" - - - name: Install dependencies - run: npm install - - - name: Generate schema - run: npm run generate-schema - - - name: Check for changes - id: changes - run: | - if git diff --quiet; then - echo "changed=false" >> $GITHUB_OUTPUT - else - echo "changed=true" >> $GITHUB_OUTPUT - fi - - - name: Create Pull Request - if: steps.changes.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v6 - with: - commit-message: "chore: update code.json schema" - title: "chore: update code.json schema" - body: | - - This PR was automatically generated because a new schema version was detected. - - Please review the changes to `src/types/CodeJSONSchema.ts` before merging. - branch: automated/update-schema - delete-branch: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23f9a906..56ce78e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,17 +38,27 @@ npm run bundle npm test ``` -## Validation +## Schema and Validation -The action uses [Zod](https://zod.dev/) for schema validation, automatically validating code.json in two scenarios: +The code.json schema, its validation rules, and the logic that merges freshly observed metadata into an existing file are owned by [codejson-core](https://github.com/DSACMS/codejson-core). This repository binds to that library's CMS profile in a single module, `src/codejson.ts`, and owns nothing else about the schema. -### 1. Before Generation +That means there is no schema to regenerate here. When `gov-codejson` publishes a new schema version, `codejson-core` cuts a release and Dependabot opens the bump. -Every time the action generates or updates code.json (via schedule or workflow_dispatch), it validates the output before creating a PR or pushing. If validation fails, no changes are made. +`src/codejson.ts` exposes three things to the rest of the action: + +- `assembleDraft` — merges observed metadata over the existing file. It runs against a permissive schema so it never rejects an incomplete file (see below). +- `validateCodeJSON` — strict validation against the full CMS schema. An empty array means valid. +- `draftBaseline` — the skeleton written for a repository that has no code.json yet. + +Validation runs in two scenarios: + +### 1. After Generation + +Every time the action generates or updates code.json (via schedule or workflow_dispatch), it validates the result and logs anything still missing as a warning. It **does not** fail the run: a newly generated file is a draft, with unobservable fields such as `status` and `longDescription` left blank on purpose for a human to complete from the pull request diff. Failing here would mean no repository could ever be bootstrapped. ### 2. On PR Edits -When the `pull_request` trigger is configured, the action validates code.json whenever it's edited in a PR. This ensures users cannot accidentally merge invalid JSON. +When the `pull_request` trigger is configured, the action validates code.json whenever it's edited in a PR and **fails the check** if it is invalid. This is the gate that keeps invalid files off your main branch. ### Workflow and Branching diff --git a/README.md b/README.md index 8a29ae58..da092f28 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This project provides a GitHub Action that helps federal agencies maintain their **Automatic Generation** - The action calculates metadata and creates a PR or pushes directly +- Fields that cannot be observed are left blank and reported in the action log - Users can then fill in manual fields by editing the PR **PR Validation** @@ -231,6 +232,12 @@ The automated code.json generator calculates specific fields by analyzing your r **reusedCode**: The generator scans your `package.json` and `requirements.txt` for dependencies published by federal agencies and lists them here, each linked to the agency repository it comes from. It matches against a curated list of federal packages (see below). Entries already in your code.json are preserved. No configuration needed. +## Schema and Validation + +The code.json schema, its validation rules, and the logic that merges newly observed metadata into an existing file all live in [codejson-core](https://github.com/DSACMS/codejson-core), a standalone library shared by every tool that produces or validates code.json. This action binds to its CMS variant (`cmsProfile`) and owns only the parts core deliberately leaves out: reading your repository through GitHub's API, running SCC, scanning dependency manifests, and opening the pull request. + +The schema is version-pinned by the `codejson-core` release, so schema updates reach this action as a dependency bump rather than a code change. Dependabot opens those automatically. + ## Federal Dependency List The `reusedCode` field is matched against a curated list of federal npm and PyPI packages in `src/gov-dependencies.data.ts`, each mapped to the agency and repository it comes from. @@ -304,14 +311,16 @@ An up-to-date list of core team members can be found in [MAINTAINERS.md](MAINTAI ``` . ├── src/ -│ ├── model.ts # TypeScript interfaces for code.json schema -│ ├── validation.ts # Zod schema definitions and validation logic -│ ├── main.ts # Main action logic -│ ├── helper.ts # Helper functions for GitHub API interactions -│ └── index.ts # Action entrypoint +│ ├── index.ts # Action entrypoint +│ ├── main.ts # Main action logic +│ ├── codejson.ts # codejson-core bindings: schema, validation, assembly +│ ├── helper.ts # GitHub API, SCC, manifest reads, PR and push +│ ├── create-deps.ts # Wires the production dependencies +│ ├── gov-dependencies.ts # Lookup table of government-made dependencies +│ └── types/ # Shared interfaces ├── .github/ -│ └── workflows/ # GitHub Actions workflow definitions -└── action.yml # Action metadata file +│ └── workflows/ # GitHub Actions workflow definitions +└── action.yml # Action metadata file ``` ## Development and Software Delivery Lifecycle diff --git a/package-lock.json b/package-lock.json index 57ca6813..9ede0a21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,9 @@ "dependencies": { "@actions/core": "^3.0.0", "@octokit/action": "^7.0.0", - "json-schema-to-zod": "^2.7.0", + "codejson-core": "^0.1.1", "octokit-plugin-create-pull-request": "^6.0.1", - "zod": "^4.2.1", - "zod-validation-error": "^5.0.0" + "zod": "^4.2.1" }, "devDependencies": { "@github/local-action": "^7.0.0", @@ -6448,6 +6447,18 @@ "node": ">= 0.12.0" } }, + "node_modules/codejson-core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/codejson-core/-/codejson-core-0.1.1.tgz", + "integrity": "sha512-OyCcvQ/CWgcg5wC68WNE1IZmDmG7kY+Jbl2i5M4paBcf5+Vit35FTKySc04ntTFfZhFiqW6Pqhi0wixlL/37Yw==", + "dependencies": { + "zod": "^4.2.1", + "zod-validation-error": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -12068,14 +12079,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "node_modules/json-schema-to-zod": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.8.1.tgz", - "integrity": "sha512-fRr1mHgZ7hboLKBUdR428gd9dIHUFGivUqOeiDcSmyXkNZCtB1uGaZLvsjZ4GaN5pwBIs+TGIOf6s+Rp5/R/zA==", - "bin": { - "json-schema-to-zod": "dist/cjs/cli.js" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index 686490c7..7af587e6 100644 --- a/package.json +++ b/package.json @@ -34,17 +34,15 @@ "package:watch": "npm run package -- --watch", "test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest", "all": "npm run format:write && npm run lint && npm run test && npm run coverage && npm run package", - "generate-schema": "npx tsx src/scripts/generate-schema.ts", "update-gov-dependencies": "npx tsx src/gov-update/run.ts" }, "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", "@octokit/action": "^7.0.0", - "json-schema-to-zod": "^2.7.0", + "codejson-core": "^0.1.1", "octokit-plugin-create-pull-request": "^6.0.1", - "zod": "^4.2.1", - "zod-validation-error": "^5.0.0" + "zod": "^4.2.1" }, "devDependencies": { "@github/local-action": "^7.0.0", diff --git a/src/__tests__/unit/codejson.test.ts b/src/__tests__/unit/codejson.test.ts new file mode 100644 index 00000000..8edf247f --- /dev/null +++ b/src/__tests__/unit/codejson.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from "@jest/globals"; +import { + CodeJSON, + assembleDraft, + draftBaseline, + droppedFields, + validateCodeJSON, +} from "../../codejson.js"; +import validCodeJSON from "../fixtures/test-code.json"; + +// The schema itself is owned and exhaustively tested by codejson-core. What follows +// pins the contract this action depends on: that we're bound to the CMS variant, that +// assembly never rejects an incomplete draft, and that the merge rules we handed over +// to core still behave the way this action needs them to. + +const FIXED_NOW = new Date("2026-01-01T00:00:00.000Z"); +const existing = validCodeJSON as unknown as CodeJSON; + +const observed: Partial = { + name: "test-repo", + repositoryURL: "https://github.com/test-owner/test-repo", + repositoryVisibility: "public", +}; + +describe("validateCodeJSON", () => { + it("accepts a complete CMS code.json", () => { + expect(validateCodeJSON(validCodeJSON)).toEqual([]); + }); + + it("reports a missing required field", () => { + const { maturityModelTier: _omitted, ...withoutTier } = validCodeJSON; + + const errors = validateCodeJSON(withoutTier); + + expect(errors.length).toBeGreaterThan(0); + expect(errors.join("\n")).toContain("maturityModelTier"); + }); + + it("enforces the CMS-only fields, so it is not bound to the neutral schema", () => { + const { fismaLevel: _omitted, ...withoutFismaLevel } = validCodeJSON; + + expect(validateCodeJSON(withoutFismaLevel).join("\n")).toContain( + "fismaLevel", + ); + }); + + it("requires exemptionText when usageType contains an exemption", () => { + const exempt = { + ...validCodeJSON, + permissions: { + ...validCodeJSON.permissions, + usageType: ["exemptByAgencySystem"], + exemptionText: null, + }, + }; + + expect(validateCodeJSON(exempt).join("\n")).toContain("exemptionText"); + }); +}); + +describe("draftBaseline", () => { + it("keeps enum fields present so they survive JSON.stringify", () => { + const serialized = JSON.parse(JSON.stringify(draftBaseline)); + + for (const field of [ + "status", + "repositoryHost", + "repositoryVisibility", + "softwareType", + "maintenance", + "repositoryType", + "fismaLevel", + ]) { + expect(serialized).toHaveProperty(field, ""); + } + }); + + it("defaults to CMS as the organization and CC0 as the license", () => { + expect(draftBaseline.organization).toBe( + "Centers for Medicare & Medicaid Services", + ); + expect(draftBaseline.permissions?.licenses).toEqual([ + { name: "CC0-1.0", URL: "" }, + ]); + }); + + it("carries the CMS-only fields", () => { + expect(draftBaseline).toHaveProperty("longDescription"); + expect(draftBaseline).toHaveProperty("maturityModelTier"); + expect(draftBaseline).toHaveProperty("subsetInHealthcare"); + }); +}); + +describe("assembleDraft", () => { + it("does not throw on an incomplete draft", () => { + const result = assembleDraft(observed, null, { now: () => FIXED_NOW }); + + expect(result.name).toBe("test-repo"); + expect(result.status).toBe(""); + expect(validateCodeJSON(result).length).toBeGreaterThan(0); + }); + + it("derives feedbackMechanism and SBOM from the repository URL", () => { + const result = assembleDraft(observed, null); + + expect(result.feedbackMechanism).toBe( + "https://github.com/test-owner/test-repo/issues", + ); + expect(result.SBOM).toBe( + "https://github.com/test-owner/test-repo/network/dependencies", + ); + }); + + it("keeps an existing feedbackMechanism and SBOM", () => { + const result = assembleDraft(observed, existing); + + expect(result.feedbackMechanism).toBe(existing.feedbackMechanism); + expect(result.SBOM).toBe(existing.SBOM); + }); + + it("stamps metadataLastUpdated from the injected clock", () => { + const result = assembleDraft(observed, existing, { now: () => FIXED_NOW }); + + expect(result.date.metadataLastUpdated).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("drops fields that are no longer part of the schema", () => { + const stale = { ...existing, retiredField: "stale" }; + + const result = assembleDraft(observed, stale as unknown as CodeJSON); + + expect(result).not.toHaveProperty("retiredField"); + }); + + it("migrates a legacy string contractNumber to an array", () => { + const legacy = { ...existing, contractNumber: "CONTRACT-001" }; + + const result = assembleDraft(observed, legacy as unknown as CodeJSON); + + expect(result.contractNumber).toEqual(["CONTRACT-001"]); + }); + + it("preserves clones from the existing file and takes forks from observation", () => { + const result = assembleDraft( + { ...observed, reuseFrequency: { forks: 42 } }, + existing, + ); + + expect(result.reuseFrequency).toEqual({ forks: 42, clones: 50 }); + }); + + it("marks the repository archived without duplicating the tag", () => { + const archived = { ...existing, tags: ["archived"] }; + + const result = assembleDraft(observed, archived as unknown as CodeJSON, { + isArchived: true, + }); + + expect(result.status).toBe("Archival"); + expect(result.tags.filter((tag) => tag === "archived")).toHaveLength(1); + }); +}); + +describe("droppedFields", () => { + it("reports keys that are not part of the schema", () => { + expect(droppedFields({ name: "test", retiredField: "stale" })).toEqual([ + "retiredField", + ]); + }); + + it("returns nothing when there is no existing file", () => { + expect(droppedFields(null)).toEqual([]); + }); +}); diff --git a/src/__tests__/unit/main.test.ts b/src/__tests__/unit/main.test.ts index a063c6fa..cab0408e 100644 --- a/src/__tests__/unit/main.test.ts +++ b/src/__tests__/unit/main.test.ts @@ -6,54 +6,20 @@ import { beforeEach, afterEach, } from "@jest/globals"; -import { runWithDeps, filterValidFields, getMetaData } from "../../main.js"; +import { runWithDeps, getMetaData } from "../../main.js"; import { createHelpers } from "../../helper.js"; +import { Dependencies } from "../../types/Dependencies.js"; import { createMockDeps, createMockOctokit } from "../fixtures/mock-deps.js"; import validCodeJSON from "../fixtures/test-code.json"; -describe("filterValidFields", () => { - it("keeps known fields", () => { - const result = filterValidFields({ - name: "test", - version: "1.0", - description: "hi", - }); - expect(result).toHaveProperty("name", "test"); - expect(result).toHaveProperty("version", "1.0"); - }); - - it("strips unknown fields", () => { - const result = filterValidFields({ name: "test", unknownField: "bad" }); - expect(result).toHaveProperty("name"); - expect(result).not.toHaveProperty("unknownField"); - }); -}); +// reads back the code.json this action actually shipped in its pull request +function generatedCodeJSON(deps: Dependencies): any { + const createPullRequestMock = deps.octokit.createPullRequest as jest.Mock; + const pullRequestArgs = createPullRequestMock.mock.calls[0][0] as any; + return JSON.parse(pullRequestArgs.changes[0].files["code.json"]); +} describe("getMetaData", () => { - it("preserves existing feedbackMechanism", async () => { - const deps = createMockDeps(); - const helpers = createHelpers(deps); - - const existing = { - ...validCodeJSON, - feedbackMechanism: "https://custom.example.com/feedback", - } as any; - const result = await getMetaData(helpers, deps, existing); - - expect(result.feedbackMechanism).toBe( - "https://custom.example.com/feedback", - ); - }); - - it("defaults feedbackMechanism to issues URL", async () => { - const deps = createMockDeps(); - const helpers = createHelpers(deps); - - const result = await getMetaData(helpers, deps, null); - - expect(result.feedbackMechanism).toContain("/issues"); - }); - it("preserves an existing version when the latest release is unavailable", async () => { const releaseOctokit = createMockOctokit({ rest: { @@ -72,11 +38,33 @@ describe("getMetaData", () => { ...validCodeJSON, version: "7.8.9", } as any; - const result = await getMetaData(helpers, deps, existing); + const result = await getMetaData(helpers, existing); expect(result.version).toBe("7.8.9"); }); + it("uses the latest release version when available", async () => { + const releaseOctokit = createMockOctokit({ + rest: { + repos: { + getLatestRelease: jest.fn().mockResolvedValue({ + data: { + tag_name: "v2.4.6", + name: "Release 2.4.6", + }, + }), + }, + }, + }); + + const deps = createMockDeps({ octokit: releaseOctokit }); + const helpers = createHelpers(deps); + + const result = await getMetaData(helpers, null); + + expect(result.version).toBe("2.4.6"); + }); + it("preserves existing languages over GitHub-detected languages", async () => { const deps = createMockDeps(); const helpers = createHelpers(deps); @@ -85,7 +73,7 @@ describe("getMetaData", () => { ...validCodeJSON, languages: ["TypeScript", "Markdown"], } as any; - const result = await getMetaData(helpers, deps, existing); + const result = await getMetaData(helpers, existing); expect(result.languages).toEqual(["TypeScript", "Markdown"]); }); @@ -94,31 +82,11 @@ describe("getMetaData", () => { const deps = createMockDeps(); const helpers = createHelpers(deps); - const result = await getMetaData(helpers, deps, null); + const result = await getMetaData(helpers, null); expect(result.languages).toEqual(["TypeScript", "JavaScript"]); }); - it("sets Archival status when isArchived", async () => { - const deps = createMockDeps({ isArchived: true }); - const helpers = createHelpers(deps); - - const result = await getMetaData(helpers, deps, validCodeJSON as any); - - expect(result.status).toBe("Archival"); - expect(result.tags).toContain("archived"); - }); - - it("converts legacy string contractNumber to array", async () => { - const deps = createMockDeps(); - const helpers = createHelpers(deps); - - const existing = { ...validCodeJSON, contractNumber: "LEGACY-001" } as any; - const result = await getMetaData(helpers, deps, existing); - - expect(result.contractNumber).toEqual(["LEGACY-001"]); - }); - it("adds the fork upstream to reusedCode", async () => { const forkOctokit = createMockOctokit({ rest: { @@ -147,7 +115,7 @@ describe("getMetaData", () => { const deps = createMockDeps({ octokit: forkOctokit }); const helpers = createHelpers(deps); - const result = await getMetaData(helpers, deps, null); + const result = await getMetaData(helpers, null); expect(result.reusedCode).toContainEqual({ name: "upstream-owner/upstream-repo", @@ -155,28 +123,6 @@ describe("getMetaData", () => { }); }); - it("uses the latest release version when available", async () => { - const releaseOctokit = createMockOctokit({ - rest: { - repos: { - getLatestRelease: jest.fn().mockResolvedValue({ - data: { - tag_name: "v2.4.6", - name: "Release 2.4.6", - }, - }), - }, - }, - }); - - const deps = createMockDeps({ octokit: releaseOctokit }); - const helpers = createHelpers(deps); - - const result = await getMetaData(helpers, deps, null); - - expect(result.version).toBe("2.4.6"); - }); - it("preserves existing tags that are not repository topics", async () => { const deps = createMockDeps(); const helpers = createHelpers(deps); @@ -186,7 +132,7 @@ describe("getMetaData", () => { tags: ["featured"], } as any; - const result = await getMetaData(helpers, deps, existing); + const result = await getMetaData(helpers, existing); expect(result.tags).toEqual(["test", "automation", "featured"]); }); @@ -200,7 +146,7 @@ describe("getMetaData", () => { tags: ["test", "featured"], } as any; - const result = await getMetaData(helpers, deps, existing); + const result = await getMetaData(helpers, existing); expect(result.tags).toEqual(["test", "automation", "featured"]); }); @@ -209,7 +155,7 @@ describe("getMetaData", () => { const deps = createMockDeps(); const helpers = createHelpers(deps); - const result = await getMetaData(helpers, deps, null); + const result = await getMetaData(helpers, null); expect(result.tags).toEqual(["test", "automation"]); }); @@ -266,18 +212,113 @@ describe("runWithDeps", () => { await runWithDeps(deps); - const createPullRequestMock = deps.octokit.createPullRequest as jest.Mock; - const pullRequestArgs = createPullRequestMock.mock.calls[0][0] as any; - const codeJSONContent = pullRequestArgs.changes[0].files["code.json"]; - const generatedCodeJSON = JSON.parse(codeJSONContent); - - expect(generatedCodeJSON).toHaveProperty("status"); - expect(generatedCodeJSON).toHaveProperty("repositoryHost"); - expect(generatedCodeJSON).toHaveProperty("repositoryVisibility"); - expect(generatedCodeJSON).toHaveProperty("softwareType"); - expect(generatedCodeJSON).toHaveProperty("maintenance"); - expect(generatedCodeJSON).toHaveProperty("repositoryType"); - expect(generatedCodeJSON).toHaveProperty("fismaLevel"); + const generated = generatedCodeJSON(deps); + + expect(generated).toHaveProperty("status"); + expect(generated).toHaveProperty("repositoryHost"); + expect(generated).toHaveProperty("repositoryVisibility"); + expect(generated).toHaveProperty("softwareType"); + expect(generated).toHaveProperty("maintenance"); + expect(generated).toHaveProperty("repositoryType"); + expect(generated).toHaveProperty("fismaLevel"); + }); + + it("reports what is still missing but ships the draft anyway", async () => { + process.env.GITHUB_EVENT_NAME = "schedule"; + + const deps = createMockDeps({ + readFile: jest.fn().mockRejectedValue(new Error("no file")), + }); + + await runWithDeps(deps); + + expect(deps.log.warning).toHaveBeenCalledWith( + expect.stringContaining("still needs manual input"), + ); + expect(deps.setFailed).not.toHaveBeenCalled(); + expect(deps.octokit.createPullRequest).toHaveBeenCalled(); + }); + + it("defaults feedbackMechanism and SBOM to the repository URL", async () => { + process.env.GITHUB_EVENT_NAME = "schedule"; + + const deps = createMockDeps({ + readFile: jest.fn().mockRejectedValue(new Error("no file")), + }); + + await runWithDeps(deps); + + const generated = generatedCodeJSON(deps); + + expect(generated.feedbackMechanism).toBe( + "https://github.com/test-owner/test-repo/issues", + ); + expect(generated.SBOM).toBe( + "https://github.com/test-owner/test-repo/network/dependencies", + ); + }); + + it("preserves an existing feedbackMechanism", async () => { + process.env.GITHUB_EVENT_NAME = "schedule"; + + const existing = { + ...validCodeJSON, + feedbackMechanism: "https://custom.example.com/feedback", + }; + const deps = createMockDeps({ + readFile: jest.fn().mockResolvedValue(JSON.stringify(existing)), + }); + + await runWithDeps(deps); + + expect(generatedCodeJSON(deps).feedbackMechanism).toBe( + "https://custom.example.com/feedback", + ); + }); + + it("converts a legacy string contractNumber to an array", async () => { + process.env.GITHUB_EVENT_NAME = "schedule"; + + const existing = { ...validCodeJSON, contractNumber: "LEGACY-001" }; + const deps = createMockDeps({ + readFile: jest.fn().mockResolvedValue(JSON.stringify(existing)), + }); + + await runWithDeps(deps); + + expect(generatedCodeJSON(deps).contractNumber).toEqual(["LEGACY-001"]); + }); + + it("drops and reports fields that are no longer part of the schema", async () => { + process.env.GITHUB_EVENT_NAME = "schedule"; + + const existing = { ...validCodeJSON, retiredField: "stale" }; + const deps = createMockDeps({ + readFile: jest.fn().mockResolvedValue(JSON.stringify(existing)), + }); + + await runWithDeps(deps); + + expect(deps.log.info).toHaveBeenCalledWith( + expect.stringContaining("Removing outdated field"), + ); + expect(generatedCodeJSON(deps)).not.toHaveProperty("retiredField"); + }); + + it("sets Archival status and tags the repository when archived", async () => { + process.env.GITHUB_EVENT_NAME = "workflow_dispatch"; + + const deps = createMockDeps({ + readFile: jest.fn().mockResolvedValue(JSON.stringify(validCodeJSON)), + isArchived: true, + }); + + await runWithDeps(deps); + + const generated = generatedCodeJSON(deps); + + expect(generated.status).toBe("Archival"); + expect(generated.tags).toContain("archived"); }); it("attempts direct push when skipPR is true with admin token", async () => { @@ -350,4 +391,4 @@ describe("runWithDeps", () => { expect.stringContaining("Action failed"), ); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/unit/zod-validation.test.ts b/src/__tests__/unit/zod-validation.test.ts deleted file mode 100644 index 1d14c690..00000000 --- a/src/__tests__/unit/zod-validation.test.ts +++ /dev/null @@ -1,1348 +0,0 @@ -import validCodeJSON from "../fixtures/test-code.json"; -import { CodeJSONSchema } from "../../types/CodeJSONSchema"; -import { describe, it, expect } from "@jest/globals"; - -const createCodeJSON = (overrides: Record = {}) => ({ - ...validCodeJSON, - ...overrides, -}); - -// ============================================================================= -// FIXTURE VALIDATION -// ============================================================================= -describe("CodeJSONSchema - fixture validation", () => { - it("accepts the valid test fixture", () => { - const result = CodeJSONSchema.safeParse(validCodeJSON); - if (!result.success) { - console.error("Validation errors:", JSON.stringify(result.error, null, 2)); - } - expect(result.success).toBe(true); - }); - - it("returns typed data on successful parse", () => { - const result = CodeJSONSchema.safeParse(validCodeJSON); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.name).toBe("automated-codejson-generator"); - expect(result.data.status).toBe("Production"); - expect(result.data.organization).toBe("Centers for Medicare & Medicaid Services"); - } - }); -}); - -// ============================================================================= -// REQUIRED FIELDS -// ============================================================================= -describe("CodeJSONSchema - required fields", () => { - it("rejects missing name", () => { - const { name, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); - - it("rejects missing description", () => { - const { description, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); - - it("rejects missing longDescription", () => { - const { longDescription, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); - - it("rejects missing status", () => { - const { status, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); - - it("rejects missing permissions", () => { - const { permissions, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); - - it("rejects missing organization", () => { - const { organization, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); - - it("rejects missing repositoryURL", () => { - const { repositoryURL, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); - - it("rejects missing feedbackMechanism", () => { - const { feedbackMechanism, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); - - it("rejects missing maturityModelTier", () => { - const { maturityModelTier, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// ENUM VALIDATIONS -// ============================================================================= -describe("CodeJSONSchema - status enum", () => { - const validStatuses = [ - "Ideation", - "Development", - "Alpha", - "Beta", - "Release Candidate", - "Production", - "Archival", - ]; - - validStatuses.forEach((status) => { - it(`accepts status: ${status}`, () => { - const input = createCodeJSON({ status }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid status", () => { - const input = createCodeJSON({ status: "InvalidStatus" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -describe("CodeJSONSchema - repositoryHost enum", () => { - const validHosts = [ - "github.com/CMSgov", - "github.com/CMS-Enterprise", - "github.com/Enterprise-CMCS", - "github.com/DSACMS", - "github.cms.gov", - "CCSQ GitHub", - ]; - - validHosts.forEach((host) => { - it(`accepts repositoryHost: ${host}`, () => { - const input = createCodeJSON({ repositoryHost: host }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid repositoryHost", () => { - const input = createCodeJSON({ repositoryHost: "github" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -describe("CodeJSONSchema - repositoryVisibility enum", () => { - it("accepts public visibility", () => { - const input = createCodeJSON({ repositoryVisibility: "public" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts private visibility", () => { - const input = createCodeJSON({ repositoryVisibility: "private" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid visibility", () => { - const input = createCodeJSON({ repositoryVisibility: "hidden" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -describe("CodeJSONSchema - vcs enum", () => { - const validVcs = ["git", "hg", "svn", "rcs", "bzr", "none"]; - - validVcs.forEach((vcs) => { - it(`accepts vcs: ${vcs}`, () => { - const input = createCodeJSON({ vcs }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid vcs", () => { - const input = createCodeJSON({ vcs: "perforce" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -describe("CodeJSONSchema - softwareType enum", () => { - const validTypes = [ - "standalone/mobile", - "standalone/iot", - "standalone/desktop", - "standalone/web", - "standalone/backend", - "standalone/other", - "addon", - "library", - "configurationFiles", - ]; - - validTypes.forEach((type) => { - it(`accepts softwareType: ${type}`, () => { - const input = createCodeJSON({ softwareType: type }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid softwareType", () => { - const input = createCodeJSON({ softwareType: "tool" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -describe("CodeJSONSchema - maintenance enum", () => { - const validMaintenance = ["internal", "contract", "community", "none"]; - - validMaintenance.forEach((maintenance) => { - it(`accepts maintenance: ${maintenance}`, () => { - const input = createCodeJSON({ maintenance }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid maintenance", () => { - const input = createCodeJSON({ maintenance: "active" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -describe("CodeJSONSchema - repositoryType enum", () => { - const validTypes = [ - "package", - "website", - "standards", - "libraries", - "data", - "application", - "tools", - "APIs", - ]; - - validTypes.forEach((type) => { - it(`accepts repositoryType: ${type}`, () => { - const input = createCodeJSON({ repositoryType: type }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid repositoryType", () => { - const input = createCodeJSON({ repositoryType: "source" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -describe("CodeJSONSchema - fismaLevel enum", () => { - const validLevels = ["low", "moderate", "high"]; - - validLevels.forEach((level) => { - it(`accepts fismaLevel: ${level}`, () => { - const input = createCodeJSON({ fismaLevel: level }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid fismaLevel", () => { - const input = createCodeJSON({ fismaLevel: "critical" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -describe("CodeJSONSchema - maturityModelTier enum", () => { - [0, 1, 2, 3, 4].forEach((tier) => { - it(`accepts maturityModelTier: ${tier}`, () => { - const input = createCodeJSON({ maturityModelTier: tier }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid maturityModelTier", () => { - const input = createCodeJSON({ maturityModelTier: 5 }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects negative maturityModelTier", () => { - const input = createCodeJSON({ maturityModelTier: -1 }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// PLATFORMS ENUM ARRAY -// ============================================================================= -describe("CodeJSONSchema - platforms enum array", () => { - const validPlatforms = ["web", "windows", "mac", "linux", "ios", "android", "other"]; - - it("accepts all valid platform values", () => { - const input = createCodeJSON({ platforms: validPlatforms }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid platform value", () => { - const input = createCodeJSON({ platforms: ["github-actions"] }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects duplicate platforms", () => { - const input = createCodeJSON({ platforms: ["web", "web"] }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// SUBSET IN HEALTHCARE ENUM ARRAY -// ============================================================================= -describe("CodeJSONSchema - subsetInHealthcare enum array", () => { - const validSubsets = ["policy", "operational", "medicare", "medicaid"]; - - it("accepts all valid subset values", () => { - const input = createCodeJSON({ subsetInHealthcare: validSubsets }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts single subset value", () => { - const input = createCodeJSON({ subsetInHealthcare: ["medicare"] }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid subset value", () => { - const input = createCodeJSON({ subsetInHealthcare: ["invalid"] }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects duplicate subsets", () => { - const input = createCodeJSON({ subsetInHealthcare: ["medicare", "medicare"] }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// USER TYPE ENUM ARRAY -// ============================================================================= -describe("CodeJSONSchema - userType enum array", () => { - const validUserTypes = ["providers", "patients", "government"]; - - it("accepts all valid userType values", () => { - const input = createCodeJSON({ userType: validUserTypes }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid userType value", () => { - const input = createCodeJSON({ userType: ["developer"] }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects duplicate userTypes", () => { - const input = createCodeJSON({ userType: ["providers", "providers"] }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// LICENSE NAME ENUM -// ============================================================================= -describe("CodeJSONSchema - license name enum", () => { - const validLicenses = [ - "CC0-1.0", - "Apache-2.0", - "MIT", - "MPL-2.0", - "GPL-2.0-only", - "GPL-3.0-only", - "GPL-3.0-or-later", - "LGPL-2.1-only", - "LGPL-3.0-only", - "BSD-2-Clause", - "BSD-3-Clause", - "EPL-2.0", - "Other", - "None", - ]; - - validLicenses.forEach((license) => { - it(`accepts license name: ${license}`, () => { - const input = createCodeJSON({ - permissions: { - licenses: [ - { - name: license, - URL: "https://opensource.org/licenses/MIT", - }, - ], - usageType: ["openSource"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid license name", () => { - const input = createCodeJSON({ - permissions: { - licenses: [ - { - name: "CC0 1.0 Universal", - URL: "https://creativecommons.org/publicdomain/zero/1.0/", - }, - ], - usageType: ["openSource"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// USAGE TYPE ENUM -// ============================================================================= -describe("CodeJSONSchema - usageType enum", () => { - const validUsageTypes = [ - "openSource", - "governmentWideReuse", - "exemptByNationalSecurity", - "exemptByNationalIntelligence", - "exemptByFOIA", - "exemptByEAR", - "exemptByITAR", - "exemptByTSA", - "exemptByClassifiedInformation", - "exemptByPrivacyRisk", - "exemptByIPRestriction", - "exemptByAgencySystem", - "exemptByAgencyMission", - "exemptByCIO", - "exemptByPolicyDate", - ]; - - it("accepts openSource without exemptionText", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["openSource"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts governmentWideReuse without exemptionText", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["governmentWideReuse"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - // Test each exemption type requires exemptionText - const exemptionTypes = validUsageTypes.filter((t) => t.startsWith("exemptBy")); - exemptionTypes.forEach((exemption) => { - it(`requires exemptionText for ${exemption}`, () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: [exemption], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it(`accepts ${exemption} with valid exemptionText`, () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: [exemption], - exemptionText: "Valid exemption justification text", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - }); - - it("rejects invalid usageType", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["invalidType"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// ORGANIZATION LITERAL -// ============================================================================= -describe("CodeJSONSchema - organization literal", () => { - it("accepts exact organization value", () => { - const input = createCodeJSON({ - organization: "Centers for Medicare & Medicaid Services", - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects incorrect organization", () => { - const input = createCodeJSON({ organization: "DSACMS" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects similar but incorrect organization", () => { - const input = createCodeJSON({ organization: "CMS" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// LONG DESCRIPTION LENGTH VALIDATION -// ============================================================================= -describe("CodeJSONSchema - longDescription length", () => { - it("rejects longDescription under 150 characters", () => { - const input = createCodeJSON({ longDescription: "Too short" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts longDescription at exactly 150 characters", () => { - const input = createCodeJSON({ longDescription: "a".repeat(150) }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts longDescription at 10000 characters", () => { - const input = createCodeJSON({ longDescription: "a".repeat(10000) }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects longDescription over 10000 characters", () => { - const input = createCodeJSON({ longDescription: "a".repeat(10001) }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// URL VALIDATIONS -// ============================================================================= -describe("CodeJSONSchema - URL validations", () => { - it("accepts valid repositoryURL", () => { - const input = createCodeJSON({ - repositoryURL: "https://github.com/DSACMS/repo", - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid repositoryURL", () => { - const input = createCodeJSON({ repositoryURL: "not-a-url" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts valid feedbackMechanism URL", () => { - const input = createCodeJSON({ - feedbackMechanism: "https://github.com/org/repo/issues", - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid feedbackMechanism URL", () => { - const input = createCodeJSON({ feedbackMechanism: "not-a-url" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts valid homepageURL", () => { - const input = createCodeJSON({ homepageURL: "https://example.com" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid homepageURL", () => { - const input = createCodeJSON({ homepageURL: "not-a-url" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts valid downloadURL", () => { - const input = createCodeJSON({ - downloadURL: "https://github.com/org/repo/releases", - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts valid disclaimerURL", () => { - const input = createCodeJSON({ - disclaimerURL: "https://example.com/disclaimer", - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); -}); - -// ============================================================================= -// NUMERIC VALIDATIONS -// ============================================================================= -describe("CodeJSONSchema - numeric validations", () => { - it("accepts zero laborHours", () => { - const input = createCodeJSON({ laborHours: 0 }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts positive laborHours", () => { - const input = createCodeJSON({ laborHours: 10000 }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects negative laborHours", () => { - const input = createCodeJSON({ laborHours: -100 }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// BOOLEAN FIELDS -// ============================================================================= -describe("CodeJSONSchema - boolean fields", () => { - it("accepts localisation true", () => { - const input = createCodeJSON({ localisation: true }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts localisation false", () => { - const input = createCodeJSON({ localisation: false }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts userInput true", () => { - const input = createCodeJSON({ userInput: true }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts userInput false", () => { - const input = createCodeJSON({ userInput: false }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); -}); - -// ============================================================================= -// STRICT MODE - Extra properties rejected -// ============================================================================= -describe("CodeJSONSchema - strict mode", () => { - it("rejects unknown top-level properties", () => { - const input = createCodeJSON({ unknownField: "value" }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects unknown properties in permissions", () => { - const input = createCodeJSON({ - permissions: { - ...validCodeJSON.permissions, - unknownField: "value", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects unknown properties in date", () => { - const input = createCodeJSON({ - date: { - ...validCodeJSON.date, - unknownField: "value", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects unknown properties in contact", () => { - const input = createCodeJSON({ - contact: { - ...validCodeJSON.contact, - unknownField: "value", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// OPTIONAL FIELDS -// ============================================================================= -describe("CodeJSONSchema - optional fields", () => { - it("accepts missing version", () => { - const { version, ...rest } = validCodeJSON; - const input = { ...rest }; - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts missing homepageURL", () => { - const { homepageURL, ...rest } = validCodeJSON as any; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(true); - }); - - it("accepts missing downloadURL", () => { - const { downloadURL, ...rest } = validCodeJSON as any; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(true); - }); - - it("accepts missing relatedCode", () => { - const { relatedCode, ...rest } = validCodeJSON as any; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(true); - }); - - it("accepts missing reusedCode", () => { - const { reusedCode, ...rest } = validCodeJSON as any; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(true); - }); - - it("accepts missing partners", () => { - const { partners, ...rest } = validCodeJSON as any; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(true); - }); - - it("accepts missing systems", () => { - const { systems, ...rest } = validCodeJSON; - const result = CodeJSONSchema.safeParse(rest); - expect(result.success).toBe(true); - }); -}); - -// ============================================================================= -// LICENSES VALIDATION (through permissions) -// ============================================================================= -describe("CodeJSONSchema - licenses validation", () => { - it("accepts valid license with name and URL", () => { - const input = createCodeJSON({ - permissions: { - licenses: [ - { - name: "MIT", - URL: "https://opensource.org/licenses/MIT", - }, - ], - usageType: ["openSource"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts multiple licenses", () => { - const input = createCodeJSON({ - permissions: { - licenses: [ - { - name: "MIT", - URL: "https://opensource.org/licenses/MIT", - }, - { - name: "Apache-2.0", - URL: "https://www.apache.org/licenses/LICENSE-2.0", - }, - ], - usageType: ["openSource"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid license URL", () => { - const input = createCodeJSON({ - permissions: { - licenses: [ - { - name: "MIT", - URL: "not-a-url", - }, - ], - usageType: ["openSource"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// PERMISSIONS - exemptionText conditional logic -// ============================================================================= -describe("CodeJSONSchema - permissions exemptionText logic", () => { - it("accepts openSource usageType with null exemptionText", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["openSource"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts exemption usageType with valid exemptionText", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["exemptByFOIA"], - exemptionText: "Exempted because of FOIA status", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects exemption usageType with empty exemptionText", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["exemptByFOIA"], - exemptionText: "", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects exemption usageType with null exemptionText", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["exemptByNationalSecurity"], - exemptionText: null, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts multiple usageTypes including exemption with valid exemptionText", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["openSource", "exemptByPrivacyRisk"], - exemptionText: "Contains PII that cannot be shared", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects whitespace-only exemptionText for exemption usageType", () => { - const input = createCodeJSON({ - permissions: { - licenses: [{ name: "MIT", URL: "https://opensource.org/licenses/MIT" }], - usageType: ["exemptByAgencyMission"], - exemptionText: " ", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// CONTACT VALIDATION -// ============================================================================= -describe("CodeJSONSchema - contact validation", () => { - it("accepts valid contact with email and name", () => { - const input = createCodeJSON({ - contact: { - email: "opensource@cms.hhs.gov", - name: "CMS Open Source Team", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid email format", () => { - const input = createCodeJSON({ - contact: { - email: "not-an-email", - name: "CMS Open Source Team", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts contact with only email", () => { - const input = createCodeJSON({ - contact: { - email: "opensource@cms.hhs.gov", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts contact with only name", () => { - const input = createCodeJSON({ - contact: { - name: "CMS Open Source Team", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts empty contact object", () => { - const input = createCodeJSON({ - contact: {}, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); -}); - -// ============================================================================= -// DATE VALIDATION -// ============================================================================= -describe("CodeJSONSchema - date validation", () => { - it("accepts valid ISO datetime strings", () => { - const input = createCodeJSON({ - date: { - created: "2024-01-15T00:00:00Z", - lastModified: "2024-06-20T12:30:00Z", - metadataLastUpdated: "2024-06-21T08:00:00Z", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts date with only created field", () => { - const input = createCodeJSON({ - date: { - created: "2024-01-15T00:00:00Z", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts empty date object", () => { - const input = createCodeJSON({ - date: {}, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid datetime format", () => { - const input = createCodeJSON({ - date: { - created: "not-a-date", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects date-only format (requires datetime)", () => { - const input = createCodeJSON({ - date: { - created: "2024-01-15", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts datetime with timezone offset", () => { - const input = createCodeJSON({ - date: { - created: "2024-01-15T00:00:00+05:00", - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); -}); - -// ============================================================================= -// REUSE FREQUENCY VALIDATION -// ============================================================================= -describe("CodeJSONSchema - reuseFrequency validation", () => { - it("accepts valid forks and clones", () => { - const input = createCodeJSON({ - reuseFrequency: { - forks: 25, - clones: 150, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts forks without clones", () => { - const input = createCodeJSON({ - reuseFrequency: { - forks: 25, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts zero values", () => { - const input = createCodeJSON({ - reuseFrequency: { - forks: 0, - clones: 0, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects negative forks", () => { - const input = createCodeJSON({ - reuseFrequency: { - forks: -5, - clones: 150, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects negative clones", () => { - const input = createCodeJSON({ - reuseFrequency: { - forks: 5, - clones: -150, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts empty reuseFrequency object", () => { - const input = createCodeJSON({ - reuseFrequency: {}, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts additional properties in reuseFrequency (catchall)", () => { - const input = createCodeJSON({ - reuseFrequency: { - forks: 25, - clones: 150, - downloads: 1000, - }, - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); -}); - -// ============================================================================= -// RELATED CODE VALIDATION -// ============================================================================= -describe("CodeJSONSchema - relatedCode validation", () => { - it("accepts valid relatedCode array", () => { - const input = createCodeJSON({ - relatedCode: [ - { - name: "gov-codejson", - URL: "https://github.com/DSACMS/gov-codejson", - isGovernmentRepo: true, - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts relatedCode with partial fields", () => { - const input = createCodeJSON({ - relatedCode: [ - { - name: "gov-codejson", - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts empty relatedCode object", () => { - const input = createCodeJSON({ - relatedCode: [{}], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid URL in relatedCode", () => { - const input = createCodeJSON({ - relatedCode: [ - { - name: "gov-codejson", - URL: "not-a-valid-url", - isGovernmentRepo: true, - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("rejects unknown properties in relatedCode (strict)", () => { - const input = createCodeJSON({ - relatedCode: [ - { - name: "gov-codejson", - unknownField: "value", - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// REUSED CODE VALIDATION -// ============================================================================= -describe("CodeJSONSchema - reusedCode validation", () => { - it("accepts valid reusedCode array", () => { - const input = createCodeJSON({ - reusedCode: [ - { - name: "octokit", - URL: "https://github.com/octokit/octokit.js", - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts reusedCode with only name", () => { - const input = createCodeJSON({ - reusedCode: [ - { - name: "octokit", - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects invalid URL in reusedCode", () => { - const input = createCodeJSON({ - reusedCode: [ - { - name: "octokit", - URL: "not-a-url", - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); - -// ============================================================================= -// PARTNERS VALIDATION -// ============================================================================= -describe("CodeJSONSchema - partners validation", () => { - it("accepts valid partners array", () => { - const input = createCodeJSON({ - partners: [ - { - name: "CMS Digital Service", - email: "digitalservice@cms.hhs.gov", - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts partners with only name", () => { - const input = createCodeJSON({ - partners: [ - { - name: "CMS Digital Service", - }, - ], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("accepts empty partners array", () => { - const input = createCodeJSON({ - partners: [], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); -}); - -// ============================================================================= -// UNIQUE ARRAYS VALIDATION -// ============================================================================= -describe("CodeJSONSchema - unique array validation", () => { - it("accepts unique languages", () => { - const input = createCodeJSON({ - languages: ["TypeScript", "JavaScript", "Python"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects duplicate languages", () => { - const input = createCodeJSON({ - languages: ["TypeScript", "JavaScript", "TypeScript"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts unique tags", () => { - const input = createCodeJSON({ - tags: ["healthcare", "government", "open-source"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects duplicate tags", () => { - const input = createCodeJSON({ - tags: ["healthcare", "government", "healthcare"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts unique contractNumbers", () => { - const input = createCodeJSON({ - contractNumber: ["CONTRACT-001", "CONTRACT-002"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects duplicate contractNumbers", () => { - const input = createCodeJSON({ - contractNumber: ["CONTRACT-001", "CONTRACT-001"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts unique categories", () => { - const input = createCodeJSON({ - categories: ["compliance", "automation", "healthcare"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects duplicate categories", () => { - const input = createCodeJSON({ - categories: ["compliance", "compliance"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("accepts unique projects", () => { - const input = createCodeJSON({ - projects: ["ProjectA", "ProjectB"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(true); - }); - - it("rejects duplicate projects", () => { - const input = createCodeJSON({ - projects: ["ProjectA", "ProjectA"], - }); - const result = CodeJSONSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); \ No newline at end of file diff --git a/src/codejson.ts b/src/codejson.ts new file mode 100644 index 00000000..6a677f12 --- /dev/null +++ b/src/codejson.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import { + CMS_SCHEMA_VERSION, + cmsBaselineCodeJSON, + cmsProfile, + createCodeJSONProfile, + type CMSCodeJSON, +} from "codejson-core"; + +// this action targets the CMS variant of the schema, so everything here binds to cmsProfile. +// we could make this generic / agency agnostic in the future but for now, the CMS variant is the only one that has been implemented in codejson-core. +export type CodeJSON = CMSCodeJSON; + +// sort of a hack since codejson-core validates against the CMS schema, but we want to generate a draft that is inherently not valid. +// this allows us to use the same assemble and validate functions without having to create a separate profile for invalid drafts. +const blankEnumValue = "" as never; + +export const draftBaseline: Partial = { + ...cmsBaselineCodeJSON, + status: blankEnumValue, + repositoryHost: blankEnumValue, + repositoryVisibility: blankEnumValue, + softwareType: blankEnumValue, + maintenance: blankEnumValue, + repositoryType: blankEnumValue, + fismaLevel: blankEnumValue, + // core's CMS baseline ships an empty license list; CMS repositories default to CC0 + permissions: { + licenses: [ + { + name: "CC0-1.0", + URL: "", + }, + ], + usageType: [], + exemptionText: "", + }, +}; + +// again, a hack to support drafts but we should change this upstream so that it can accept drafts. +// we need this because the CMS schema is strict and does not allow blank enum values, but we want to generate a draft that is inherently invalid. +const draftProfile = createCodeJSONProfile( + z.custom(), + draftBaseline, + CMS_SCHEMA_VERSION, +); + +// after the refactor, all we need is cmsProfile.assemble but for now we need to wrap it so that we can pass in the draft baseline and not have to worry about the strict CMS schema. +export const assembleDraft: ( + observed: Partial, + existing: CodeJSON | null, + options?: { isArchived?: boolean; now?: () => Date }, +) => CodeJSON = draftProfile.assemble; + +// validates against the full CMS schema. an empty array means valid +export const validateCodeJSON = cmsProfile.validate; + +// core drops unknown fields silently, but this action has always reported them +export function droppedFields( + existingCodeJSON: Record | null, +): string[] { + if (!existingCodeJSON) { + return []; + } + + const validKeys = new Set(Object.keys(draftBaseline)); + return Object.keys(existingCodeJSON).filter((key) => !validKeys.has(key)); +} diff --git a/src/helper.ts b/src/helper.ts index 898c5292..cd4b4039 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,6 +1,5 @@ -import { CodeJSON } from "./types/CodeJSONSchema.js"; +import { CodeJSON, validateCodeJSON } from "./codejson.js"; import { BasicRepoInfo } from "./types/BasicRepoInfo.js"; -import { validateCodeJSON } from "./zod-validation.js"; import { Dependencies } from "./types/Dependencies.js"; import { ReusedCodeEntry, @@ -10,6 +9,11 @@ import { const HOURS_PER_MONTH = 730.001; +// both write paths go through here so the committed file is byte-identical either way +function serializeCodeJSON(codeJSON: CodeJSON): string { + return JSON.stringify(codeJSON, null, 2) + "\n"; +} + export function createHelpers(deps: Dependencies) { const { owner, repo, octokit, adminOctokit, log, setOutput, isArchived } = deps; @@ -41,7 +45,6 @@ export function createHelpers(deps: Dependencies) { date: { created: basicInfo.date.created, lastModified: basicInfo.date.lastModified, - metadataLastUpdated: basicInfo.date.metadataLastUpdated, }, }; } catch (error) { @@ -110,7 +113,6 @@ export function createHelpers(deps: Dependencies) { date: { created: repoData.data.created_at, lastModified: repoData.data.updated_at, - metadataLastUpdated: new Date().toISOString(), }, }; } catch (error) { @@ -255,7 +257,7 @@ export function createHelpers(deps: Dependencies) { async function sendPR(updatedCodeJSON: CodeJSON, baseBranchName: string) { try { - const formattedContent = JSON.stringify(updatedCodeJSON, null, 2) + "\n"; + const formattedContent = serializeCodeJSON(updatedCodeJSON); const headBranchName = `code-json-${new Date().getTime()}`; const PR = await octokit.createPullRequest({ @@ -303,7 +305,7 @@ export function createHelpers(deps: Dependencies) { } try { - const formattedContent = JSON.stringify(updatedCodeJSON, null, 2); + const formattedContent = serializeCodeJSON(updatedCodeJSON); let currentFileSha: string | undefined; try { diff --git a/src/main.ts b/src/main.ts index d771e9f1..70beac0e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,119 +1,22 @@ -import { CodeJSON } from "./types/CodeJSONSchema.js"; +import { + CodeJSON, + assembleDraft, + droppedFields, + validateCodeJSON, +} from "./codejson.js"; import { Dependencies } from "./types/Dependencies.js"; import { createHelpers, Helpers } from "./helper.js"; import { createProductionDeps } from "./create-deps.js"; -const blankEnumValue = "" as never; - -const baselineCodeJSON: Partial = { - name: "", - version: "", - description: "", - longDescription: "", - status: blankEnumValue, - permissions: { - licenses: [ - { - name: "CC0-1.0", - URL: "", - }, - ], - usageType: [], - exemptionText: "", - }, - organization: "Centers for Medicare & Medicaid Services", - repositoryURL: "", - repositoryHost: blankEnumValue, - repositoryVisibility: blankEnumValue, - homepageURL: "", - downloadURL: "", - disclaimerURL: "", - disclaimerText: "", - vcs: "git", - laborHours: 0, - reuseFrequency: { - forks: 0, - clones: 0, - }, - platforms: [], - categories: [], - softwareType: blankEnumValue, - languages: [], - maintenance: blankEnumValue, - contractNumber: [], - SBOM: "", - relatedCode: [], - reusedCode: [], - partners: [], - date: { - created: "", - lastModified: "", - metadataLastUpdated: "", - }, - tags: [], - contact: { - email: "", - name: "", - }, - feedbackMechanism: "", - AIUseCaseID: "0", - localisation: false, - repositoryType: blankEnumValue, - userInput: false, - fismaLevel: blankEnumValue, - group: "", - projects: [], - systems: [], - subsetInHealthcare: [], - userType: [], - maturityModelTier: 0, -}; - -export { baselineCodeJSON }; - -function filterValidFields( - existingCodeJSON: Record, -): Partial { - const validKeys = new Set(Object.keys(baselineCodeJSON)); - const filtered: Record = {}; - - for (const key of Object.keys(existingCodeJSON)) { - if (validKeys.has(key)) { - filtered[key] = existingCodeJSON[key]; - } else { - console.log(`Removing outdated field from current code.json: ${key}`); - } - } - - return filtered as Partial; -} - -export { filterValidFields }; - +// gathers what can be observed about the repository right now so anything not an observation belongs to codejson-core async function getMetaData( helpers: Helpers, - deps: Dependencies, existingCodeJSON?: CodeJSON | null, ): Promise> { const partialCodeJSON = await helpers.calculateMetaData(); - const version = existingCodeJSON?.version || partialCodeJSON.version; - - // preserve existing feedback mechanisms if they exist, otherwise default to GitHub Issues - const feedbackMechanism = - existingCodeJSON?.feedbackMechanism || - `${partialCodeJSON.repositoryURL}/issues`; - // preserve existing SBOM link if they exist, otherwise default to GitHub SBOM link - const SBOM = - existingCodeJSON?.SBOM || - `${partialCodeJSON.repositoryURL}/network/dependencies`; - - // only use the calculated description if its not empty, otherwise keep existing - const shouldUpdateDescription = - partialCodeJSON.description && partialCodeJSON.description.trim() !== ""; - const description = shouldUpdateDescription - ? partialCodeJSON.description - : existingCodeJSON?.description || ""; + // preserve a manually set version, only fall back to the latest release + const version = existingCodeJSON?.version || partialCodeJSON.version; // preserve manually curated languages when they already exist in code.json, // and only fall back to GitHub detected languages for new repositories. @@ -128,25 +31,6 @@ async function getMetaData( existingCodeJSON?.tags ?? [], ); - // handling legacy contractNumber that turned from string to array which caused validation errors - let contractNumber: string[] = []; - const existingContract: unknown = existingCodeJSON?.contractNumber; - if (existingContract) { - if (typeof existingContract === "string") { - contractNumber = existingContract.trim() ? [existingContract.trim()] : []; - } else if (Array.isArray(existingContract)) { - contractNumber = existingContract; - } - } - - // handling archive option - let status = existingCodeJSON?.status || undefined; - - if (deps.isArchived) { - status = "Archival"; - tags.push("archived"); - } - // detect the fork upstream and government-made dependencies, then merge with any existing reusedCode const [forkParent, detectedDeps] = await Promise.all([ helpers.detectForkParent(), @@ -160,26 +44,19 @@ async function getMetaData( return { name: partialCodeJSON.name, version: version, - description: description, - status: status ?? blankEnumValue, + description: partialCodeJSON.description, repositoryURL: partialCodeJSON.repositoryURL, repositoryVisibility: partialCodeJSON.repositoryVisibility, laborHours: partialCodeJSON.laborHours, languages: languages, reuseFrequency: { forks: partialCodeJSON.reuseFrequency?.forks ?? 0, - clones: existingCodeJSON?.reuseFrequency?.clones ?? 0, }, tags: tags, date: { created: partialCodeJSON.date?.created ?? "", lastModified: partialCodeJSON.date?.lastModified ?? "", - metadataLastUpdated: - partialCodeJSON.date?.metadataLastUpdated ?? new Date().toISOString(), }, - feedbackMechanism, - SBOM, - contractNumber, reusedCode, }; } @@ -201,26 +78,27 @@ export async function runWithDeps(deps: Dependencies): Promise { const currentCodeJSON = await helpers.readJSON( "/github/workspace/code.json", ); - const metaData = await getMetaData(helpers, deps, currentCodeJSON); - let finalCodeJSON = {} as CodeJSON; - - if (currentCodeJSON) { - // filter out outdated fields before merging - const filteredExisting = filterValidFields(currentCodeJSON); - - finalCodeJSON = { - ...baselineCodeJSON, - ...filteredExisting, - ...metaData, - } as CodeJSON; - } else { - finalCodeJSON = { - ...baselineCodeJSON, - ...metaData, - } as CodeJSON; + + for (const field of droppedFields(currentCodeJSON)) { + deps.log.info(`Removing outdated field from current code.json: ${field}`); } - deps.log.info("Generated code.json successfully!"); + const metaData = await getMetaData(helpers, currentCodeJSON); + const finalCodeJSON = assembleDraft(metaData, currentCodeJSON, { + isArchived: deps.isArchived, + }); + + // a generated code.json is a draft so we must report what fields are missing + const validationErrors = validateCodeJSON(finalCodeJSON); + + if (validationErrors.length > 0) { + deps.log.warning( + "Generated code.json still needs manual input before it will validate:", + ); + validationErrors.forEach((error) => deps.log.warning(error)); + } else { + deps.log.info("Generated code.json successfully!"); + } const baseBranchName = await helpers.getBaseBranch(); diff --git a/src/scripts/generate-schema.ts b/src/scripts/generate-schema.ts deleted file mode 100644 index f3021c23..00000000 --- a/src/scripts/generate-schema.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs from "fs"; -import path from "path"; -import prettier from "prettier"; -import { JsonSchema, jsonSchemaToZod } from "json-schema-to-zod"; -import { getLatestSchemaVersion } from "./get-latest-schema.js"; - -const SCHEMA_BASE_URL = "https://raw.githubusercontent.com/DSACMS/gov-codejson/refs/heads/main/schemas/cms"; -const filePath = "src/types/CodeJSONSchema.ts"; - -function allowEmptyUrls(zodCode: string): string { - // allow empty strings for all URL fields while still validating non-empty values - return zodCode.replace(/\.url\(\)/g, '.url().or(z.literal(""))'); - } - -function fixUniqueArrays(zodCode: string): string { - // replace .unique() with .refine() pattern since Zod doesn't have .unique() for arrays but converter adds it in there - return zodCode.replace( - /\.unique\(\)/g, - ".refine((items) => new Set(items).size === items.length, { message: 'Array must contain unique values' })" - ); -} - -function addAdditionalRefinements(): string { - const permissionRefinement = ` - .refine( - (data) => { - const usageTypes = data.permissions?.usageType ?? []; - const hasExemption = usageTypes.some( - (type) => typeof type === "string" && type.startsWith("exemptBy") - ); - - if (hasExemption) { - return ( - data.permissions?.exemptionText != null && - data.permissions.exemptionText.trim().length > 0 - ); - } - - return true; - }, - { - message: "exemptionText is required when usageType contains an exemption", - path: ["permissions", "exemptionText"], - } - ); - `; - - const refinements = permissionRefinement - return refinements -} - -async function formatFile(filePath: string) { - const absolutePath = path.resolve(filePath); - const source = fs.readFileSync(absolutePath, "utf8"); - const config = await prettier.resolveConfig(absolutePath); - - const formatted = await prettier.format(source, { - ...config, - filepath: absolutePath, - }); - - fs.writeFileSync(absolutePath, formatted); -} - -async function generateSchema() { - const schemaVersion = await getLatestSchemaVersion(); - console.log(`Latest schema version: ${schemaVersion}`); - - const schemaURL = `${SCHEMA_BASE_URL}/schema-${schemaVersion}.json`; - console.log(`Fetching JSON schema from GitHub...`); - const response = await fetch(schemaURL); - - if (!response.ok) { - throw new Error(`Failed to fetch schema: ${response.status} ${response.statusText}`); - } - - const jsonSchema = (await response.json()) as JsonSchema; - - console.log(`Converting JSON schema to Zod...`); - let zodSourceCode = jsonSchemaToZod(jsonSchema); - - zodSourceCode = fixUniqueArrays(zodSourceCode); - zodSourceCode = allowEmptyUrls(zodSourceCode) - - const fileContent = ` - // DO NOT EDIT - AUTOMATICALLY GENERATED FILE!!! - // Schema Version: ${schemaVersion} - - import { z } from "zod"; - - export const CodeJSONSchema = (${zodSourceCode})${addAdditionalRefinements()} - - export type CodeJSON = z.infer; - `; - - fs.writeFileSync(filePath, fileContent); - console.log(`File written to ${filePath}...`); - - await formatFile(filePath); - console.log(`Schema generation complete!`); -} - -try { - await generateSchema(); -} catch (error) { - console.error(`Schema generation failed!`); - - if (error instanceof Error) { - console.error(`Error: ${error.message}`); - } else { - console.error(`Unknown error:`, error); - } - process.exit(1); -} \ No newline at end of file diff --git a/src/scripts/get-latest-schema.ts b/src/scripts/get-latest-schema.ts deleted file mode 100644 index b1d7ded8..00000000 --- a/src/scripts/get-latest-schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -const GITHUB_API_URL = - "https://api.github.com/repos/DSACMS/gov-codejson/contents/schemas/cms"; - -export async function getLatestSchemaVersion(): Promise { - const response = await fetch(GITHUB_API_URL, { - headers: { - Accept: "application/vnd.github.v3+json", - ...(process.env.GITHUB_TOKEN && { - Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, - }), - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch schema list: ${response.status}`); - } - - const files = (await response.json()) as Array<{ name: string }>; - - const versions = files - .map((f) => f.name.match(/^schema-(\d+\.\d+\.\d+)\.json$/)?.[1]) - .filter(Boolean) as string[]; - - if (versions.length === 0) { - throw new Error("No schema versions found"); - } - - const versionNumber = versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0]; -// console.log(versionNumber) - return versionNumber -} - -// try { -// await getLatestSchemaVersion(); -// } catch (error) { -// console.error(`GET operation failed!`); - -// if (error instanceof Error) { -// console.error(`Error: ${error.message}`); -// } else { -// console.error(`Unknown error:`, error); -// } -// process.exit(1); -// } \ No newline at end of file diff --git a/src/types/BasicRepoInfo.ts b/src/types/BasicRepoInfo.ts index 61a7ffd2..48812a83 100644 --- a/src/types/BasicRepoInfo.ts +++ b/src/types/BasicRepoInfo.ts @@ -6,13 +6,13 @@ export interface BasicRepoInfo { languages: string[]; forks: number; tags: string[]; - date: Date; + date: RepoDates; } -interface Date { +// metadataLastUpdated is stamped by codejson-core during assembly, not observed here +interface RepoDates { created: string; lastModified: string; - metadataLastUpdated: string } type RepositoryVisibility = "public" | "private" | undefined; diff --git a/src/types/CodeJSONSchema.ts b/src/types/CodeJSONSchema.ts deleted file mode 100644 index fd4f2c5f..00000000 --- a/src/types/CodeJSONSchema.ts +++ /dev/null @@ -1,465 +0,0 @@ -// DO NOT EDIT - AUTOMATICALLY GENERATED FILE!!! -// Schema Version: 2.1.0 - -import { z } from "zod"; - -export const CodeJSONSchema = z - .object({ - name: z.string().describe("Name of the project or software"), - version: z - .string() - .describe("The version for this release. For example, '1.0.0'.") - .optional(), - description: z - .string() - .describe("A one or two sentence description of the software."), - longDescription: z - .string() - .min(150) - .max(10000) - .describe( - "Provide longer description of the software, between 150 and 10000 chars. It is meant to provide an overview of the capabilities of the software for a potential user.", - ), - status: z - .enum([ - "Ideation", - "Development", - "Alpha", - "Beta", - "Release Candidate", - "Production", - "Archival", - ]) - .describe("Development status of the project"), - permissions: z - .object({ - licenses: z - .array( - z - .object({ - name: z - .enum([ - "CC0-1.0", - "Apache-2.0", - "MIT", - "MPL-2.0", - "GPL-2.0-only", - "GPL-3.0-only", - "GPL-3.0-or-later", - "LGPL-2.1-only", - "LGPL-3.0-only", - "BSD-2-Clause", - "BSD-3-Clause", - "EPL-2.0", - "Other", - "None", - ]) - .describe("An abbreviation for the name of the license"), - URL: z - .string() - .url() - .or(z.literal("")) - .describe("The URL of the release license in the repository"), - }) - .strict(), - ) - .describe("License(s) for the release"), - usageType: z - .array( - z.enum([ - "openSource", - "governmentWideReuse", - "exemptByNationalSecurity", - "exemptByNationalIntelligence", - "exemptByFOIA", - "exemptByEAR", - "exemptByITAR", - "exemptByTSA", - "exemptByClassifiedInformation", - "exemptByPrivacyRisk", - "exemptByIPRestriction", - "exemptByAgencySystem", - "exemptByAgencyMission", - "exemptByCIO", - "exemptByPolicyDate", - ]), - ) - .describe( - "A list of enumerated values which describes the usage permissions for the release: (1) openSource: Open source; (2) governmentWideReuse: Government-wide reuse; (3) exemptByNationalSecurity: The source code is primarily for use in national security system as defined in section 11103 of title 40, USC; (4) exemptByNationalIntelligence: The source code is developed by an agency or part of an agency that is an element of the intelligence community, as defined in section 3(4) of the National Security Act of 1947; (5) exemptByFOIA: The source code is exempt under the Freedom of Information Act; (6) exemptByEAR: The source code is exempt under the Export Administration Regulations; (7) exemptByITAR: The source code is exempt under the the International Traffic in Arms Regulations; (8) exemptByTSA: The source code is exempt under the regulations of the Transportation Security Administration relating to the protection of Sensitive Security Information; (9) exemptByClassifiedInformation: The source code is exempt under the Federal laws and regulations governing the sharing of classified information not covered by exemptByNationalSecurity, exemptByNationalIntelligence, exemptbyFOIA, exemptByEAR, exemptByITAR, and exemptByTSA; (10) exemptByPrivacyRisk: The sharing or public accessibility of the source code would create an identifiable risk to the privacy of an individual; (11) exemptByIPRestriction: The sharing of the source code is limited by patent or intellectual property restrictions; (12) exemptByAgencySystem: The sharing of the source code would create an identifiable risk to the stability, security, or integrity of the agency's systems or personnel; (13) exemptByAgencyMission: The sharing of the source code would create an identifiable risk to agency mission, programs, or operations; (14) exemptByCIO: The CIO believes it is in the national interest to exempt sharing the source code; (15) exemptByPolicyDate: The release was created prior to the M-16-21 policy (August 8, 2016)", - ), - exemptionText: z - .union([ - z - .string() - .describe( - "If an exemption is listed in the 'usageType' field, this field should include a one- or two- sentence justification for the exemption used.", - ), - z - .null() - .describe( - "If an exemption is listed in the 'usageType' field, this field should include a one- or two- sentence justification for the exemption used.", - ), - ]) - .describe( - "If an exemption is listed in the 'usageType' field, this field should include a one- or two- sentence justification for the exemption used.", - ) - .optional(), - }) - .strict() - .describe( - "An object containing description of the usage/restrictions regarding the release", - ), - organization: z - .literal("Centers for Medicare & Medicaid Services") - .describe( - "The organization or component within the agency to which the releases listed belong.", - ), - repositoryURL: z - .string() - .url() - .or(z.literal("")) - .describe( - "The URL of the public release repository for open source repositories. This field is not required for repositories that are only available as government-wide reuse or are closed (pursuant to one of the exemptions). It can be listed as 'private' for repositories that are closed.", - ), - repositoryHost: z - .enum([ - "github.com/CMSgov", - "github.com/CMS-Enterprise", - "github.com/Enterprise-CMCS", - "github.com/DSACMS", - "github.com/MeasureAuthoringTool", - "github.cms.gov", - "CCSQ GitHub", - ]) - .describe("Location where source code is hosted"), - repositoryVisibility: z - .enum(["public", "private"]) - .describe("Visibility of repository"), - homepageURL: z - .string() - .url() - .or(z.literal("")) - .describe("The URL of the public release homepage.") - .optional(), - downloadURL: z - .string() - .url() - .or(z.literal("")) - .describe("The URL where a distribution of the release can be found.") - .optional(), - disclaimerURL: z - .string() - .url() - .or(z.literal("")) - .describe( - "The URL where disclaimer language regarding the release can be found.", - ) - .optional(), - disclaimerText: z - .string() - .describe( - "Short paragraph that includes disclaimer language to accompany the release.", - ) - .optional(), - vcs: z - .enum(["git", "hg", "svn", "rcs", "bzr", "none"]) - .describe("Version control system used"), - laborHours: z - .number() - .gte(0) - .describe( - "Labor hours invested in the project. Calculated using COCOMO measured by the SCC tool: https://github.com/boyter/scc?tab=readme-ov-file#cocomo", - ), - reuseFrequency: z - .object({ - forks: z.number().int().gte(0).optional(), - clones: z.number().int().gte(0).optional(), - }) - .catchall(z.any()) - .describe( - "Measures frequency of code reuse in various forms. (e.g. forks, downloads, clones)", - ), - platforms: z - .array( - z.enum(["web", "windows", "mac", "linux", "ios", "android", "other"]), - ) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe("Platforms supported by the project"), - categories: z - .array(z.string()) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe( - "Categories the project belongs to. Select from: https://yml.publiccode.tools/categories-list.html", - ), - softwareType: z - .enum([ - "standalone/mobile", - "standalone/iot", - "standalone/desktop", - "standalone/web", - "standalone/backend", - "standalone/other", - "addon", - "library", - "configurationFiles", - ]) - .describe("Type of software"), - languages: z - .array(z.string()) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe("Programming languages that make up the codebase"), - maintenance: z - .enum(["internal", "contract", "community", "none"]) - .describe( - "The dedicated staff that keeps the software up-to-date, if any", - ), - contractNumber: z - .array(z.string()) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe("Contract number(s) under which the project was developed"), - SBOM: z - .string() - .describe( - "Link of the upstream repositories and dependencies used, in the form of a Software Bill of Materials/SBOM. If the software does not have a SBOM, enter 'None'. (i.e. Github provides an SBOM: https://github.com/$ORG_NAME/$REPO_NAME/network/dependencies)", - ), - relatedCode: z - .array( - z - .object({ - name: z - .string() - .describe( - "The name of the code repository, project, library or release.", - ) - .optional(), - URL: z - .string() - .url() - .or(z.literal("")) - .describe( - "The URL where the code repository, project, library or release can be found.", - ) - .optional(), - isGovernmentRepo: z - .boolean() - .describe( - "True or False. Is the code repository owned or managed by a federal agency?", - ) - .optional(), - }) - .strict(), - ) - .describe( - "An array of affiliated government repositories that may be a part of the same project. For example, relatedCode for 'code-gov-front-end' would include 'code-gov-api' and 'code-gov-api-client'.", - ) - .optional(), - reusedCode: z - .array( - z - .object({ - name: z - .string() - .describe("The name of the software used in this release.") - .optional(), - URL: z - .string() - .url() - .or(z.literal("")) - .describe("The URL where the software can be found.") - .optional(), - }) - .strict(), - ) - .describe( - "An array of government source code, libraries, frameworks, APIs, platforms or other software used in this release. For example, US Web Design Standards, cloud.gov, Federalist, Digital Services Playbook, Analytics Reporter.", - ) - .optional(), - partners: z - .array( - z - .object({ - name: z - .string() - .describe("The acronym describing the partner agency.") - .optional(), - email: z - .string() - .describe( - "The email address for the point of contact at the partner agency.", - ) - .optional(), - }) - .strict(), - ) - .describe( - "An array of objects including an acronym for each agency partnering on the release and the contact email at such agency.", - ) - .optional(), - date: z - .object({ - created: z - .string() - .datetime({ offset: true }) - .describe("Creation date of project.") - .optional(), - lastModified: z - .string() - .datetime({ offset: true }) - .describe("Date when the project was last modified") - .optional(), - metadataLastUpdated: z - .string() - .datetime({ offset: true }) - .describe("Date when metadata was last updated") - .optional(), - }) - .strict() - .describe("A date object describing the release"), - tags: z - .array(z.string()) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe( - "Topics and keywords associated with the project to improve search and discoverability", - ), - contact: z - .object({ - email: z - .string() - .email() - .describe("Email address of the point of contact") - .optional(), - name: z.string().describe("Name of the point of contact").optional(), - }) - .strict() - .describe("Point of contact for the release"), - feedbackMechanism: z - .string() - .url() - .or(z.literal("")) - .describe( - "Method a repository receives feedback from the community (i.e. URL to GitHub repository issues page)", - ), - AIUseCaseID: z - .string() - .describe( - "The software's ID in the AI Use Case Inventory. If the software is not currently listed in the inventory, enter '0'.", - ), - localisation: z - .boolean() - .describe("Indicates if the project supports multiple languages"), - repositoryType: z - .enum([ - "package", - "website", - "standards", - "libraries", - "data", - "application", - "tools", - "APIs", - ]) - .describe("Purpose and functionality of the repository"), - userInput: z.boolean().describe("Does the software accept user input?"), - fismaLevel: z - .enum(["low", "moderate", "high"]) - .describe( - "Level of security categorization assigned to an information system under the Federal Information Security Modernization Act (FISMA): https://security.cms.gov/learn/federal-information-security-modernization-act-fisma", - ), - group: z - .string() - .describe("Home Department / Org / Group associated with the project"), - projects: z - .array(z.string()) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe( - "Project(s) that is associated or related to the repository, if any (e.g. Bluebutton, MPSM)", - ), - systems: z - .array(z.string()) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe( - "CMS systems that the repository interfaces with or depends on, if any (e.g. IDR, PECOS)", - ) - .optional(), - subsetInHealthcare: z - .array( - z.enum([ - "policy", - "operational", - "medicare", - "medicaid", - "SNAP", - "TANF", - "human-benefit-services", - ]), - ) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe("Healthcare-related subset"), - userType: z - .array( - z.enum([ - "providers", - "patients", - "government", - "applicants", - "beneficiaries", - "enrollees", - ]), - ) - .refine((items) => new Set(items).size === items.length, { - message: "Array must contain unique values", - }) - .describe("Types of users who interact with the software"), - maturityModelTier: z - .union([ - z.literal(0), - z.literal(1), - z.literal(2), - z.literal(3), - z.literal(4), - ]) - .describe( - "Maturity model tier according to the CMS Open Source Repository Maturity Model Framework: https://github.com/DSACMS/repo-scaffolder/blob/main/maturity-model-tiers.md", - ), - }) - .strict() - .describe("A metadata standard for software repositories of CMS") - .refine( - (data) => { - const usageTypes = data.permissions?.usageType ?? []; - const hasExemption = usageTypes.some( - (type) => typeof type === "string" && type.startsWith("exemptBy"), - ); - - if (hasExemption) { - return ( - data.permissions?.exemptionText != null && - data.permissions.exemptionText.trim().length > 0 - ); - } - - return true; - }, - { - message: "exemptionText is required when usageType contains an exemption", - path: ["permissions", "exemptionText"], - }, - ); - -export type CodeJSON = z.infer; diff --git a/src/zod-validation.ts b/src/zod-validation.ts deleted file mode 100644 index 75a7017d..00000000 --- a/src/zod-validation.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from "zod"; -import { createErrorMap } from "zod-validation-error"; -import { CodeJSONSchema } from "./types/CodeJSONSchema.js"; - -z.config({ - customError: createErrorMap({ - displayInvalidFormatDetails: true - }), -}); - -export function validateCodeJSON(codeJSON: unknown): string[] { - const result = CodeJSONSchema.safeParse(codeJSON); - - if (result.success) { - return []; - } - - return [z.prettifyError(result.error)] -} -