diff --git a/docs/architecture.md b/docs/architecture.md index a5df74d..ac66163 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -779,3 +779,5 @@ Each Program has an exact-revision Obligation Collection Review. Its complete po Schedule-set and Control collection reviewer eligibility distinguish Obligation queue ownership from personal work assignments. Team membership and chairing a Team-owned review queue do not establish that a Person operates the Controls. Control ownership still resolves Team members and Appointment holders, and personal or Appointment assignments on Obligations remain conflicts. Governed content approval keeps its full owner separation checks, including Team membership. A reviewer who also owns implementation work remains ineligible. Readiness keeps shared prerequisites separate from individual implementation work. A Program schedule-set approval is one action with its selected Controls as dependents. Each Control retains the approval check and cannot pass the readiness gate until it clears, but otherwise complete implementation checks do not produce another action or reduce implementation progress. Mapped evidence-source setup is also one action per source; missing Control mappings remain individual gaps. Workflow findings, recommendations, progress, CLI output, and browser checklists share these bindings. Source-family items remain evidence-map rollups rather than duplicate setup actions. + +Core owns runtime compatibility for hosted and local use. Stable pre-1.0 versions share a minor compatibility line; stable 1.0 and later versions share a major line. Runtime compatibility assessment exposes installed and repository versions, supported line, workspace model support, API contract support, and a named failure reason. CLI, server startup, previews, mutations, and workspace evaluation use this policy. Version files remain authoritative and are never rewritten by compatibility checks. Missing or malformed managed lockfiles and unresolved linked dependencies fail closed. Explicit model migrations retain their unsupported-model preview path. Releases within a line must preserve both existing record reads and generated record interchange; a breaking format or contract belongs in a new line. diff --git a/package-lock.json b/package-lock.json index de42d4d..7651e23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.17.3", + "version": "0.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.17.3", + "version": "0.17.4", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.17.3", + "version": "0.17.4", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.17.3", + "version": "0.17.4", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 3b35992..0bd8b98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.17.3", + "version": "0.17.4", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 457d815..9a980cd 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.17.3", + "version": "0.17.4", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index 938fad5..7eb1f11 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.17.3", + version: "0.17.4", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.17.3", + version: "0.17.4", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/AGENTS.md b/packages/create-filegrc/template/AGENTS.md index 04ae0dd..90bf49a 100644 --- a/packages/create-filegrc/template/AGENTS.md +++ b/packages/create-filegrc/template/AGENTS.md @@ -125,7 +125,7 @@ Use standards terms only when their meanings match. Do not call a general progra If the installed CLI reports that this workspace uses an unsupported model, run the exact `migrate --to-model ... --preview --json` command in its error. Review the preview’s automatic, review-required, and unsupported classifications before applying that version with `--yes`. Repeat one version at a time until the workspace reaches the installed model version. -After changing the installed `filegrc` version, run `npm ci` and validate the workspace. FileGRC rejects CLI, server, and write operations when the installed version differs from `package-lock.json`. Older calculated revision bindings remain valid when the reviewed facts have not changed; do not repeat a management review solely because the engine changed. +After changing the installed `filegrc` version, run `npm ci` and validate the workspace. FileGRC accepts stable installed and locked versions in the same compatibility line, minor before 1.0 and major from 1.0 onward, subject to supported workspace models and API contracts. Hosted adapters can inspect `assessWorkspaceRuntimeCompatibility` and deploy one tested patch per line. Reject unsupported lines, prereleases, and malformed version files; repair local installs with `npm ci` or choose a supported hosted runtime. Do not rewrite customer manifests, lockfiles, or workspace versions to bypass these checks. Older calculated revision bindings remain valid when the reviewed facts have not changed; do not repeat a management review solely because the engine changed. The [model v11 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v11.md) explains owner-recorded Control implementation and periodic Control collection oversight. The [model v10 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v10.md) explains Reporting Channel Sets and the legacy-route review. diff --git a/packages/create-filegrc/template/README.md b/packages/create-filegrc/template/README.md index ad09835..f5145d9 100644 --- a/packages/create-filegrc/template/README.md +++ b/packages/create-filegrc/template/README.md @@ -19,7 +19,7 @@ npm run serve Requires Node.js 20 or newer and Git. -Run FileGRC through the version installed by `npm ci`. The CLI, local server, and write APIs stop when that installed version differs from `package-lock.json`. Older revision bindings remain readable after an engine upgrade, so an unchanged review does not need to be repeated or rewritten just to adopt a new engine. +Run FileGRC through the version installed by `npm ci`. The CLI, local server, and write APIs accept stable versions in the same compatibility line, minor before 1.0 and major from 1.0 onward, subject to workspace model and API support. Hosted applications can run one tested patch in that line without changing repository version files. Unsupported lines, prereleases, and malformed versions are rejected. Older revision bindings remain readable after an engine upgrade, so an unchanged review does not need to be repeated or rewritten just to adopt a new engine. Existing model v10 workspaces must run `npx filegrc migrate --to-model 11 --preview --json` after installing a model v11 package. The migration removes the old per-Control implementation review fields. Owners can record implementation directly, while periodic Control oversight uses a Collection Review. Independent Policy, Document, and Training approvals do not change. Older workspaces migrate one model version at a time. See the [model v11 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v11.md). diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 44312fe..9520d36 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.17.3", + "version": "0.17.4", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/create-filegrc/test/create.test.js b/packages/create-filegrc/test/create.test.js index ab25a0f..7868f84 100644 --- a/packages/create-filegrc/test/create.test.js +++ b/packages/create-filegrc/test/create.test.js @@ -30,11 +30,11 @@ test("creates a complete generic repository with one dependency", async (context policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "America/Chicago", - filegrcVersion: "1.2.3", + filegrcVersion: "0.17.2", install: false, effectiveDate: "2026-07-25" }); - assert.equal(result.engineVersion, "1.2.3"); + assert.equal(result.engineVersion, "0.17.2"); assert.deepEqual(result.resourceCounts, { total: 224, byType: { @@ -72,12 +72,12 @@ test("creates a complete generic repository with one dependency", async (context assert.ok(proposedWork.triggers.length > 0); assert.equal(proposedWork.items.every(({ status }) => status === "proposed"), true); const packageJson = JSON.parse(await readFile(join(target, "package.json"), "utf8")); - assert.deepEqual(packageJson.dependencies, { filegrc: "^1.2.3" }); + assert.deepEqual(packageJson.dependencies, { filegrc: "^0.17.2" }); assert.equal(packageJson.private, true); const readme = await readFile(join(target, "README.md"), "utf8"); assert.equal(readme.includes("{{"), false); assert.match(readme, /# Example "Engineering" SOC 2 Program/); - assert.match(readme, /FileGRC 1\.2\.3/); + assert.match(readme, /FileGRC 0\.17\.2/); assert.match(readme, /npx filegrc setup/); assert.match(readme, /npx filegrc program-path --next --json/); assert.match(readme, /finish Step 1 by adding the real reviewers and operators, finishing the oversight team/); @@ -627,6 +627,12 @@ test("creates a complete generic repository with one dependency", async (context assert.match(ignoredOutput, /packet\.json/); const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: target, encoding: "utf8" }).trim(); assert.equal(await realpath(gitRoot), await realpath(target)); + const incomplete = await validateWorkspace(target); + assert.equal(incomplete.diagnostics.some(({ code }) => code === "runtime-compatibility"), true); + const installedLockPath = join(target, "package-lock.json"); + const installedLock = JSON.parse(await readFile(installedLockPath, "utf8")); + installedLock.packages["node_modules/filegrc"] = { version: "0.17.2" }; + await writeFile(installedLockPath, JSON.stringify(installedLock)); const validation = await validateWorkspace(target); assert.deepEqual(validation.counts, { resources: 224, errors: 0, warnings: 3 }); assert.equal( @@ -820,7 +826,7 @@ test("creates a thirteen-record foundation without selecting a framework", async target, yes: true, starter: "foundation", - filegrcVersion: "1.2.3", + filegrcVersion: "0.17.2", install: false }); assert.equal(result.starter, "foundation"); @@ -851,6 +857,11 @@ test("creates a thirteen-record foundation without selecting a framework", async assert.match(agents, /No framework or assurance program has been selected yet/); assert.match(agents, /does not include framework requirements, policies, governed documents/); assert.doesNotMatch(agents, /The generated workspace starts with the SOC 2 Security category/); + assert.equal((await validateWorkspace(target)).ok, false, "skipped installation leaves an unresolved engine lock"); + const lockPath = join(target, "package-lock.json"); + const lock = JSON.parse(await readFile(lockPath, "utf8")); + lock.packages["node_modules/filegrc"] = { version: "0.17.2" }; + await writeFile(lockPath, JSON.stringify(lock)); assert.equal((await validateWorkspace(target)).ok, true); assert.equal( (await readdir(join(target, "data", "policies"))).some((name) => name.endsWith(".json")), diff --git a/packages/filegrc/README.md b/packages/filegrc/README.md index ca9e310..0a98ee6 100644 --- a/packages/filegrc/README.md +++ b/packages/filegrc/README.md @@ -119,3 +119,13 @@ Core builds dashboard links as `https://app.filegrc.com/connections/ ( applyResourceBatchUnlocked(root, changes, "model-migration") - )); + ), { allowModelMigration: true }); } async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperation = null) { diff --git a/packages/filegrc/src/index.js b/packages/filegrc/src/index.js index 0903359..1d1c714 100644 --- a/packages/filegrc/src/index.js +++ b/packages/filegrc/src/index.js @@ -124,7 +124,7 @@ export { createFilegrcServer, serveWorkspace } from "./server.js"; export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js"; export { createAppState, createResourceDetail } from "./state.js"; export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js"; -export { assertWorkspaceEngineVersion, workspaceEngineVersion } from "./runtime-version.js"; +export { assertWorkspaceEngineVersion, workspaceEngineVersion, assessWorkspaceRuntimeCompatibility, assessRuntimeCompatibility, runtimeCompatibilityLine, RUNTIME_API_CONTRACT_VERSION } from "./runtime-version.js"; export { calculatedRevisionDiagnostic, REVISION_SCHEME_VERSION } from "./revisions.js"; export { validateWorkspace } from "./validate.js"; export { diff --git a/packages/filegrc/src/mutation.js b/packages/filegrc/src/mutation.js index 58469f6..00a4d72 100644 --- a/packages/filegrc/src/mutation.js +++ b/packages/filegrc/src/mutation.js @@ -6,9 +6,9 @@ const mutationQueues = new Map(); const activeMutation = new AsyncLocalStorage(); const deferredValidation = new AsyncLocalStorage(); -export function serializeWorkspaceMutation(input, task) { +export function serializeWorkspaceMutation(input, task, options = {}) { const root = resolveWorkspaceRoot(input); - assertWorkspaceEngineVersion(root); + assertWorkspaceEngineVersion(root, options); if (activeMutation.getStore() === root) return task(root); const previous = mutationQueues.get(root) ?? Promise.resolve(); const run = previous.catch(() => {}).then(() => activeMutation.run(root, () => task(root))); diff --git a/packages/filegrc/src/runtime-version.js b/packages/filegrc/src/runtime-version.js index 95cd333..302f08c 100644 --- a/packages/filegrc/src/runtime-version.js +++ b/packages/filegrc/src/runtime-version.js @@ -1,65 +1,120 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { SUPPORTED_MODEL_VERSIONS } from "../model/index.js"; import { resolveWorkspaceRoot } from "./paths.js"; export const installedVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; +export const RUNTIME_API_CONTRACT_VERSION = 1; + +// Stable SemVer only. Build metadata does not change the compatibility line. +export function runtimeCompatibilityLine(version) { + if (typeof version !== "string") return null; + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version); + if (!match || match.slice(1, 4).some((part) => !Number.isSafeInteger(Number(part)))) return null; + return match[1] === "0" ? `0.${match[2]}.x` : `${match[1]}.x`; +} + +export function assessRuntimeCompatibility(runtimeVersion, repositoryVersion, options = {}) { + const supportedLine = runtimeCompatibilityLine(runtimeVersion); + const repositoryLine = runtimeCompatibilityLine(repositoryVersion); + const dataModelVersion = options.dataModelVersion ?? null; + const requiredApiContractVersion = Object.hasOwn(options, "requiredApiContractVersion") ? options.requiredApiContractVersion : RUNTIME_API_CONTRACT_VERSION; + const reason = !supportedLine ? "invalid-runtime-version" + : repositoryVersion !== null && !repositoryLine ? "invalid-repository-version" + : repositoryLine && repositoryLine !== supportedLine ? "unsupported-compatibility-line" + : dataModelVersion !== null && !SUPPORTED_MODEL_VERSIONS.includes(dataModelVersion) ? "unsupported-workspace-model" + : requiredApiContractVersion !== RUNTIME_API_CONTRACT_VERSION ? "unsupported-api-contract" : null; + return { compatible: reason === null, installedVersion: runtimeVersion, repositoryVersion, + supportedLine, repositoryLine, dataModelVersion, apiContractVersion: RUNTIME_API_CONTRACT_VERSION, + supportedModelVersions: [...SUPPORTED_MODEL_VERSIONS], reason }; +} export function workspaceEngineVersion(input = process.cwd()) { let root; try { root = resolveWorkspaceRoot(input); } catch (error) { - if (/No filegrc workspace was found/.test(error.message)) { - return { installedVersion, lockedVersion: null, lockfilePath: null }; - } + if (/No filegrc workspace was found/.test(error.message)) return { installedVersion, lockedVersion: null, lockfilePath: null }; throw error; } - const manifestPath = join(root, "package.json"); let packageManaged = false; try { - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + if (!manifest || Array.isArray(manifest) || typeof manifest !== "object") throw new Error("Expected a JSON object."); packageManaged = Boolean(manifest.dependencies?.filegrc || manifest.devDependencies?.filegrc); } catch (error) { - if (error.code !== "ENOENT") throw new Error(`Cannot read the workspace package.json: ${error.message}`); + if (error.code !== "ENOENT") throw versionDriftError(`Cannot read the workspace package.json: ${error.message}`, "invalid-manifest"); } let lock; try { lock = JSON.parse(readFileSync(join(root, "package-lock.json"), "utf8")); + if (!lock || Array.isArray(lock) || typeof lock !== "object") throw new Error("Expected a JSON object."); } catch (error) { if (error.code === "ENOENT" && !packageManaged) return { installedVersion, lockedVersion: null, lockfilePath: null }; - if (error.code === "ENOENT") throw versionDriftError("The workspace package-lock.json is missing. Run npm install to create it, then npm ci."); - throw new Error(`Cannot read the workspace package-lock.json: ${error.message}`); + if (error.code === "ENOENT") throw versionDriftError("The workspace package-lock.json is missing. Run npm install to create it, then npm ci.", "missing-lockfile"); + throw versionDriftError(`Cannot read the workspace package-lock.json: ${error.message}`, "invalid-lockfile"); } const installedEntry = lock.packages?.["node_modules/filegrc"]; - const linkedEntry = installedEntry?.link && installedEntry.resolved - ? lock.packages?.[installedEntry.resolved] - : null; - const lockedVersion = linkedEntry?.version - || installedEntry?.version - || lock.dependencies?.filegrc?.version - || null; - if (packageManaged && !lockedVersion) { - throw versionDriftError("The workspace package-lock.json has no filegrc version. Run npm install to repair it, then npm ci."); + const linkedEntry = installedEntry?.link && installedEntry.resolved ? lock.packages?.[installedEntry.resolved] : null; + const lockedVersion = installedEntry?.link ? linkedEntry?.version : installedEntry?.version ?? lock.dependencies?.filegrc?.version ?? null; + if ((packageManaged || installedEntry) && !lockedVersion) { + throw versionDriftError("The workspace package-lock.json has no filegrc version. Run npm install to repair it, then npm ci.", "missing-repository-version"); } - return { installedVersion, lockedVersion, lockfilePath: join(root, "package-lock.json") }; + return { installedVersion, lockedVersion: lockedVersion ?? null, lockfilePath: join(root, "package-lock.json") }; } -function versionDriftError(message) { +function versionDriftError(message, reason) { const error = new Error(message); error.code = "FILEGRC_VERSION_DRIFT"; + error.reason = reason; return error; } -export function assertWorkspaceEngineVersion(input = process.cwd()) { - const result = workspaceEngineVersion(input); - if (result.lockedVersion && result.lockedVersion !== result.installedVersion) { - const error = new Error( - `Installed filegrc ${result.installedVersion} does not match package-lock.json (${result.lockedVersion}). ` - + "Run npm ci in the workspace, then retry." - ); - error.code = "FILEGRC_VERSION_DRIFT"; - error.installedVersion = result.installedVersion; - error.lockedVersion = result.lockedVersion; +export function assessWorkspaceRuntimeCompatibility(input = process.cwd(), options = {}) { + let versions; + try { + versions = workspaceEngineVersion(input); + } catch (error) { + return { ...assessRuntimeCompatibility(installedVersion, null, options), compatible: false, + lockedVersion: null, lockfilePath: null, reason: error.reason || "invalid-version-files", message: error.message }; + } + const versionCompatibility = { ...versions, ...assessRuntimeCompatibility(installedVersion, versions.lockedVersion, options) }; + if (!versionCompatibility.compatible) return { ...versionCompatibility, message: compatibilityMessage(versionCompatibility) }; + let dataModelVersion = null; + try { + const root = resolveWorkspaceRoot(input); + const workspace = JSON.parse(readFileSync(join(root, "data/workspace.json"), "utf8")); + dataModelVersion = workspace?.dataModelVersion; + if (!workspace || Array.isArray(workspace) || typeof workspace !== "object" || typeof dataModelVersion !== "string") { + return { ...versions, ...assessRuntimeCompatibility(installedVersion, versions.lockedVersion, options), compatible: false, + reason: "invalid-workspace-model", message: "data/workspace.json must declare a supported string dataModelVersion. Restore the authoritative model declaration or preview an explicit migration; do not guess a version." }; + } + } catch (error) { + // Structural workspace validation reports missing or malformed records. + if (!/No filegrc workspace was found/.test(error.message)) { + return { ...versions, ...assessRuntimeCompatibility(installedVersion, versions.lockedVersion, options), compatible: false, + reason: "invalid-workspace", message: `Cannot read data/workspace.json: ${error.message}` }; + } + } + const result = { ...versions, ...assessRuntimeCompatibility(installedVersion, versions.lockedVersion, { ...options, dataModelVersion }) }; + if (!result.compatible) result.message = compatibilityMessage(result); + return result; +} + +function compatibilityMessage(result) { + if (result.reason === "unsupported-workspace-model") return `Workspace model ${result.dataModelVersion} is unsupported by filegrc ${result.installedVersion}. Use a runtime supporting this model or preview an explicit model migration${result.dataModelVersion === "1" ? " with npx filegrc migrate --to-model 2 --preview --json" : ""}; do not change the workspace version to bypass validation.`; + if (result.reason === "unsupported-api-contract") return `Requested API contract is unsupported by filegrc ${result.installedVersion}; use API contract ${result.apiContractVersion} or a runtime supporting the requested contract.`; + return `Installed filegrc ${result.installedVersion} cannot use package-lock.json (${result.repositoryVersion}). Supported compatibility line: ${result.supportedLine || "none"}; reason: ${result.reason}. Deploy a tested stable hosted runtime supporting the repository compatibility line and model, or run npm ci locally. Repair malformed version files through normal dependency installation; do not rewrite customer versions to bypass this check.`; +} + +export function assertWorkspaceEngineVersion(input = process.cwd(), options = {}) { + const result = assessWorkspaceRuntimeCompatibility(input, options); + // Explicit migrations retain their existing unsupported-model preview path. + const modelFailure = ["unsupported-workspace-model", "invalid-workspace-model", "invalid-workspace"].includes(result.reason); + const allowDiagnostics = options.allowModelDiagnostics === true && modelFailure; + if (!result.compatible && !allowDiagnostics && !(options.allowModelMigration && result.reason === "unsupported-workspace-model")) { + const error = versionDriftError(result.message, result.reason); + Object.assign(error, { installedVersion: result.installedVersion, lockedVersion: result.repositoryVersion, compatibility: result }); throw error; } return result; diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index 8eb5dfd..18a1a89 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -88,7 +88,7 @@ const RESOURCE_DETAIL_GIT_DEADLINE_MS = 10_000; const STATE_SECTION_GIT_DEADLINE_MS = 10_000; export function createFilegrcServer(input = process.cwd(), options = {}) { - assertWorkspaceEngineVersion(input); + assertWorkspaceEngineVersion(input, { allowModelDiagnostics: true }); const stateSessions = new Map(); const fileDigestCache = new Map(); let bootstrapSnapshotPromise = null; diff --git a/packages/filegrc/src/state.js b/packages/filegrc/src/state.js index becfdbc..6ecb8f1 100644 --- a/packages/filegrc/src/state.js +++ b/packages/filegrc/src/state.js @@ -35,7 +35,7 @@ export async function createAppState(input = process.cwd(), options = {}) { options.programId ?? null ]); if (!options.validationProof && appStatePromises.has(key)) return appStatePromises.get(key); - const promise = serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options)).finally(() => { + const promise = serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options), { allowModelDiagnostics: true }).finally(() => { if (appStatePromises.get(key) === promise) appStatePromises.delete(key); }); if (!options.validationProof) appStatePromises.set(key, promise); @@ -390,7 +390,7 @@ export async function createResourceDetail(input, type, id, options = {}) { return serializeWorkspaceMutation(input, async (root) => { const validation = await validateWorkspace(root); return createResourceDetailFromLoaded(validation.loaded, type, id, options); - }); + }, { allowModelDiagnostics: true }); } async function createResourceDetailFromLoaded(loaded, type, id, options) { @@ -415,7 +415,7 @@ export async function createResourceHistory(input, type, id, options = {}) { return serializeWorkspaceMutation(input, async (root) => { const validation = await validateWorkspace(root); return createResourceHistoryFromLoaded(validation.loaded, type, id, options); - }); + }, { allowModelDiagnostics: true }); } function createResourceHistoryFromLoaded(loaded, type, id, options) { diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index 15e3087..d9f0df0 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -1,3 +1,4 @@ +import { assertWorkspaceEngineVersion } from "./runtime-version.js"; import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; @@ -266,6 +267,7 @@ function contextualAction(item, loaded) { } export async function previewWorkflowMutation(input, mutation) { + assertWorkspaceEngineVersion(input); const loaded = await loadWorkspace(input); const previewCheckout = await mkdtemp(join(tmpdir(), "filegrc-workflow-preview-")); try { @@ -300,6 +302,13 @@ export async function previewWorkflowMutation(input, mutation) { await writeFile(marker, "The source workspace has uncommitted files.\n"); runGitCommandSync(loaded.root, ["-C", previewRoot, "add", "-N", "-f", "--", "preview-source-dirty.marker"]); } + // Preview current version files alongside current data, including uncommitted + // dependency repairs. The clone may contain older committed metadata. + for (const name of ["package.json", "package-lock.json"]) { + await rm(join(previewRoot, name), { force: true }); + try { await cp(join(loaded.root, name), join(previewRoot, name)); } + catch (error) { if (error.code !== "ENOENT") throw error; } + } const before = await assessWorkflow(loaded.root, mutation?.assessment || {}); const record = mutation?.record; const existing = record?.id diff --git a/packages/filegrc/src/workspace.js b/packages/filegrc/src/workspace.js index bff9fdc..58afcdb 100644 --- a/packages/filegrc/src/workspace.js +++ b/packages/filegrc/src/workspace.js @@ -1,5 +1,6 @@ import { readFile, readdir } from "node:fs/promises"; import { join, relative, sep } from "node:path"; +import { assessWorkspaceRuntimeCompatibility } from "./runtime-version.js"; import { loadModel } from "../model/index.js"; import { resolveWorkspaceRoot } from "./paths.js"; import { assessHostedAutomation } from "./hosted-automation.js"; @@ -12,7 +13,8 @@ export async function loadWorkspace(input = process.cwd()) { async function loadWorkspaceUnmeasured(input) { const root = resolveWorkspaceRoot(input); const dataRoot = join(root, "data"); - const diagnostics = []; + const compatibility = assessWorkspaceRuntimeCompatibility(root); + const diagnostics = compatibility.compatible || compatibility.reason === "unsupported-workspace-model" ? [] : [{ severity: "error", code: "runtime-compatibility", path: "package-lock.json", message: compatibility.message }]; const files = await collectJsonFiles(dataRoot); const resources = []; const sourceEntries = []; @@ -69,6 +71,7 @@ async function loadWorkspaceUnmeasured(input) { diagnostics.push(...hostedAutomationConnection.diagnostics); return { + runtimeCompatibility: compatibility, hostedAutomationConnection, root, dataRoot, diff --git a/packages/filegrc/test/model-migration.test.js b/packages/filegrc/test/model-migration.test.js index 7056290..061d75a 100644 --- a/packages/filegrc/test/model-migration.test.js +++ b/packages/filegrc/test/model-migration.test.js @@ -227,7 +227,7 @@ test("previews and atomically migrates every model v1 compatibility field", asyn await writeJson(personPath, changedPerson); await assert.rejects( applyResourceBatch(root, preview.changes), - /Resource "person-example" changed after you opened it/ + /Workspace model 1 is unsupported.*migrate --to-model 2/ ); const result = await migrateModel(root, { startsOn: "2026-08-02" }); diff --git a/packages/filegrc/test/runtime-version.test.js b/packages/filegrc/test/runtime-version.test.js index f9b9c7f..377641a 100644 --- a/packages/filegrc/test/runtime-version.test.js +++ b/packages/filegrc/test/runtime-version.test.js @@ -21,7 +21,7 @@ test("CLI, server, and direct writes reject an installed engine that differs fro } }, null, 2)}\n`); - const expected = /Installed filegrc .* does not match package-lock\.json \(99\.0\.0\).*npm ci/; + const expected = /Installed filegrc .* cannot use package-lock\.json \(99\.0\.0\).*npm ci/; await assert.rejects(runCli(["validate", "--root", root]), expected); for (const command of ["validate", "build", "serve"]) { await assert.rejects(runCli([command, root]), expected); @@ -52,3 +52,176 @@ test("package-managed workspaces reject missing and incomplete lockfiles", async assert.throws(() => createFilegrcServer(root), /no filegrc version/); await assert.rejects(createResource(root, { id: "person-new", type: "person", title: "New person" }), /no filegrc version/); }); + +import { readFile, cp, mkdir } from "node:fs/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { assessRuntimeCompatibility, assessWorkspaceRuntimeCompatibility, assertWorkspaceEngineVersion, runtimeCompatibilityLine } from "../src/runtime-version.js"; +import { createObligationEvent } from "../src/obligations.js"; +import { previewWorkflowMutation } from "../src/workflow.js"; +import { validateWorkspace } from "../src/validate.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { updateResource, deleteResource, applyResourceBatch } from "../src/files.js"; + +test("stable runtime compatibility lines use minor before 1.0 and major thereafter", () => { + for (const [runtime, repository, compatible] of [ + ["0.17.0", "0.17.2", true], ["0.17.3", "0.17.0", true], + ["0.17.3", "0.18.0", false], ["0.17.3", "0.16.9", false], + ["1.0.0", "1.99.9", true], ["1.9.0", "1.0.0", true], ["1.0.0", "2.0.0", false], + ["0.17.0+build.1", "0.17.9", true] + ]) assert.equal(assessRuntimeCompatibility(runtime, repository).compatible, compatible); + for (const version of ["0.17.2-beta.1", "v0.17.2", "0.17", "00.17.2", "0.17.2 ", "^0.17.2", 17, "0.17.9007199254740992"]) { + assert.equal(runtimeCompatibilityLine(version), null); + assert.equal(assessRuntimeCompatibility("0.17.0", version).reason, "invalid-repository-version"); + } + assert.equal(assessRuntimeCompatibility("0.17.0-beta", "0.17.0").reason, "invalid-runtime-version"); + assert.equal(assessRuntimeCompatibility("0.17.0", "0.17.2", { dataModelVersion: "13" }).reason, "unsupported-workspace-model"); + assert.equal(assessRuntimeCompatibility("0.17.0", "0.17.2", { requiredApiContractVersion: 2 }).reason, "unsupported-api-contract"); +}); + +async function lockWorkspace(root, version) { + await writeFile(join(root, "package.json"), JSON.stringify({ private: true, dependencies: { filegrc: `^${version}` } })); + await writeFile(join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { "node_modules/filegrc": { version } } })); +} + +async function eventWorkspace(root) { + await makeWorkspace(root); + await createResource(root, { id: "obligation-onboarding", type: "obligation", title: "Register device", status: "active", + activityType: "asset-registration", recurrence: { mode: "event", eventType: "person-started" }, + window: { precision: "date", startsAfter: 0, dueAfter: 2 }, ownerIds: ["person-owner"] }); +} + +test("both patch directions preview and save events and tasks without changing version files", async (context) => { + const base = await mkdtemp(join(tmpdir(), "filegrc-patch-directions-")); + context.after(() => rm(base, { recursive: true, force: true })); + // Load the same policy in independently installed runtime versions. This + // exercises the real mutation guard, not an injected version comparison. + for (const [runtimeVersion, repositoryVersion] of [["0.17.0", "0.17.2"], ["0.17.4", "0.17.0"]]) { + const packageRoot = join(base, runtimeVersion, "engine"); + await cp(fileURLToPath(new URL("..", import.meta.url)), packageRoot, { recursive: true, filter: (path) => !path.includes("/test") }); + const manifestPath = join(packageRoot, "package.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + await writeFile(manifestPath, JSON.stringify({ ...manifest, version: runtimeVersion })); + const api = await import(pathToFileURL(join(packageRoot, "src/index.js"))); + const root = join(base, runtimeVersion, "workspace"); + await mkdir(root, { recursive: true }); + await eventWorkspace(root); + await lockWorkspace(root, repositoryVersion); + const paths = ["package.json", "package-lock.json", "data/workspace.json"]; + const before = await Promise.all(paths.map(path => readFile(join(root, path), "utf8"))); + const options = { operation: "trigger-event", eventType: "person-started", occurredOn: "2026-07-01", subjectResourceIds: ["person-owner"], title: "Start engineer" }; + const compatibility = api.assessWorkspaceRuntimeCompatibility(root); + assert.equal(compatibility.compatible, true); + assert.equal(compatibility.installedVersion, runtimeVersion); + assert.equal(compatibility.repositoryVersion, repositoryVersion); + const preview = await api.previewWorkflowMutation(root, options); + assert.equal((await loadWorkspace(root)).resources.some(r => r.type === "obligation-event"), false); + const created = await api.createObligationEvent(root, options); + assert.equal(created.actions.length, 1); + assert.equal(created.actions[0].sourceResourceId, created.event.id); + assert.equal(created.actions[0].completionWindow.dueOn, "2026-07-03"); + assert.equal((await api.validateWorkspace(root)).ok, true); + assert.ok(preview); + await api.createResource(root, { id: "person-patch", type: "person", title: "Patch worker", status: "active", affiliation: "internal", jobTitle: "Engineer" }); + await api.deleteResource(root, "person", "person-patch"); + assert.deepEqual(await Promise.all(paths.map(path => readFile(join(root, path), "utf8"))), before); + } +}); + +test("compatibility failures reach evaluations, previews, and guarded mutations", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-incompatible-runtime-")); + context.after(() => rm(root, { recursive: true, force: true })); + await eventWorkspace(root); + for (const version of ["0.18.0", "0.17.2-beta", "garbage"]) { + await lockWorkspace(root, version); + const result = assessWorkspaceRuntimeCompatibility(root); + assert.equal(result.compatible, false); + assert.equal(result.repositoryVersion, version); + assert.equal((await validateWorkspace(root)).ok, false); + for (const operation of [ + () => previewWorkflowMutation(root, { operation: "trigger-event" }), + () => createObligationEvent(root, {}), + () => updateResource(root, "person", "person-owner", {}), + () => deleteResource(root, "person", "person-owner"), + () => applyResourceBatch(root, []) + ]) await assert.rejects(async () => operation(), error => error.code === "FILEGRC_VERSION_DRIFT" && error.compatibility.reason === result.reason); + } + await lockWorkspace(root, "0.17.2"); + const workspacePath = join(root, "data/workspace.json"); + const workspace = JSON.parse(await readFile(workspacePath)); + await writeFile(workspacePath, JSON.stringify({ ...workspace, dataModelVersion: "13" })); + assert.equal(assessWorkspaceRuntimeCompatibility(root).reason, "unsupported-workspace-model"); + assert.throws(() => assertWorkspaceEngineVersion(root), /Workspace model 13 is unsupported/); +}); + +test("linked dependencies resolve their target version and cannot fall back around a broken link", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-linked-runtime-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + await lockWorkspace(root, "0.17.2"); + const lock = { packages: { "node_modules/filegrc": { link: true, resolved: "../engine", version: "0.17.2" }, "../engine": { version: "0.17.0" } } }; + await writeFile(join(root, "package-lock.json"), JSON.stringify(lock)); + assert.equal(assessWorkspaceRuntimeCompatibility(root).repositoryVersion, "0.17.0"); + delete lock.packages["../engine"]; + await writeFile(join(root, "package-lock.json"), JSON.stringify(lock)); + assert.equal(assessWorkspaceRuntimeCompatibility(root).reason, "missing-repository-version"); + await writeFile(join(root, "package-lock.json"), "null"); + assert.equal(assessWorkspaceRuntimeCompatibility(root).reason, "invalid-lockfile"); +}); + +test("malformed model declarations and explicit invalid API contracts fail hosted preflight", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-invalid-model-declaration-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + for (const workspace of [{}, { dataModelVersion: null }, [], { dataModelVersion: 12 }]) { + await writeFile(join(root, "data/workspace.json"), JSON.stringify(workspace)); + assert.equal(assessWorkspaceRuntimeCompatibility(root).reason, "invalid-workspace-model"); + assert.throws(() => assertWorkspaceEngineVersion(root), /must declare a supported string dataModelVersion/); + } + for (const requiredApiContractVersion of [null, 0, "1", {}, 2]) { + assert.equal(assessRuntimeCompatibility("0.17.0", "0.17.2", { requiredApiContractVersion }).reason, "unsupported-api-contract"); + } +}); + +import { initializeGitWorkspace } from "./helpers.js"; + +test("previews use current version files after an uncommitted dependency repair", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-preview-version-repair-")); + context.after(() => rm(root, { recursive: true, force: true })); + await eventWorkspace(root); + await lockWorkspace(root, "0.16.0"); + await initializeGitWorkspace(root); + await lockWorkspace(root, "0.17.2"); + const before = await readFile(join(root, "package-lock.json"), "utf8"); + await previewWorkflowMutation(root, { operation: "trigger-event", eventType: "person-started", occurredOn: "2026-07-01", subjectResourceIds: ["person-owner"] }); + assert.equal(await readFile(join(root, "package-lock.json"), "utf8"), before); + assert.equal((await loadWorkspace(root)).resources.some(record => record.type === "obligation-event"), false); +}); + +import { captureCli } from "./helpers.js"; + +test("diagnostic CLI and server reads preserve model recovery while mutations reject", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-model-recovery-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + await lockWorkspace(root, "0.17.2"); + await writeFile(join(root, "data/workspace.json"), "{"); + const server = createFilegrcServer(root); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + context.after(() => new Promise(resolve => server.close(resolve))); + const address = `http://127.0.0.1:${server.address().port}`; + const response = await fetch(`${address}/api/state/bootstrap`); + assert.equal(response.status, 200); + for (const suffix of ["", "?history=only"]) { + assert.equal((await fetch(`${address}/api/resource/person/person-owner${suffix}`)).status, 200); + } + const validation = await captureCli(runCli, ["validate", root, "--json"]); + assert.equal(validation.exitCode, 1); + assert.equal(JSON.parse(validation.stdout).ok, false); + assert.ok(JSON.parse(validation.stdout).diagnostics.some(({ code }) => code === "invalid-json")); + const build = await captureCli(runCli, ["build", root]); + assert.equal(build.exitCode, 0); + await assert.rejects(createResource(root, { id: "person-recovery", type: "person", title: "Recovery person" }), /Cannot read data\/workspace.json/); + await lockWorkspace(root, "0.18.0"); + assert.throws(() => createFilegrcServer(root), /unsupported-compatibility-line/); + await assert.rejects(captureCli(runCli, ["validate", root, "--json"]), /unsupported-compatibility-line/); +});