filegrc 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createResource, updateResource } from "./files.js";
1
+ import { applyResourceBatch } from "./files.js";
2
2
  import { createResourceId } from "./id.js";
3
3
  import { loadWorkspace } from "./workspace.js";
4
4
 
@@ -10,18 +10,30 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
10
10
  const setup = normalizeSetupPayload(payload);
11
11
  validateSetup(loaded, setup);
12
12
  const plan = buildSetupRecords(loaded, setup);
13
+ const updates = [
14
+ ...(plan.existingSystem ? [plan.system] : []),
15
+ plan.workspace,
16
+ ...(plan.renderer ? [plan.renderer] : [])
17
+ ];
18
+ const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, entry.revision]));
13
19
 
14
- await upsertResource(loaded.root, plan.existingSystem, plan.system);
15
- await updateResource(loaded.root, "workspace", plan.workspace.id, plan.workspace);
16
- if (plan.renderer) await updateResource(loaded.root, plan.renderer.type, plan.renderer.id, plan.renderer);
20
+ await applyResourceBatch(loaded.root, {
21
+ create: [
22
+ ...(plan.existingSystem ? [] : [plan.system]),
23
+ ...(plan.commitment ? [plan.commitment] : [])
24
+ ],
25
+ update: updates,
26
+ expectedRevisions: Object.fromEntries(updates.map((record) => [record.id, revisionById.get(record.id)])),
27
+ validateWholeWorkspace: true
28
+ });
17
29
 
18
30
  return {
19
31
  draft: setup.draft,
20
32
  system: plan.system,
21
33
  workspace: plan.workspace,
22
34
  renderer: plan.renderer,
35
+ commitment: plan.commitment,
23
36
  linkedControlIds: [],
24
- evidenceTestDraftIds: [],
25
37
  onboardingComplete: !setup.draft
26
38
  };
27
39
  }
@@ -40,11 +52,12 @@ export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
40
52
  workspace: "update",
41
53
  renderer: plan.renderer ? "update" : "unchanged",
42
54
  controls: 0,
43
- evidenceDrafts: 0
55
+ commitment: plan.commitment ? "create" : "unchanged"
44
56
  },
45
57
  system: setupSystemSummary(plan.system),
46
58
  target: setupTargetSummary(plan.workspace),
47
59
  renderer: plan.renderer ? setupRendererSummary(plan.renderer) : null,
60
+ commitment: plan.commitment || null,
48
61
  onboardingComplete: !setup.draft
49
62
  };
50
63
  }
@@ -58,11 +71,12 @@ export function summarizeSetupResult(result) {
58
71
  system: "saved",
59
72
  workspace: "updated",
60
73
  controls: result.linkedControlIds?.length || 0,
61
- evidenceDrafts: result.evidenceTestDraftIds?.length || 0
74
+ commitment: result.commitment ? "saved" : "unchanged"
62
75
  },
63
76
  system: setupSystemSummary(result.system),
64
77
  target: setupTargetSummary(result.workspace),
65
78
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
79
+ commitment: result.commitment || null,
66
80
  onboardingComplete: result.onboardingComplete
67
81
  };
68
82
  }
@@ -77,7 +91,7 @@ export function normalizeSetupPayload(payload = {}) {
77
91
  boundary: cleanMultilineText(payload.boundary ?? payload.scope, "boundary"),
78
92
  ownerId: cleanText(payload.ownerId ?? payload.owner, "ownerId"),
79
93
  criticality: cleanText(payload.criticality, "criticality"),
80
- dataClassification: cleanText(payload.dataClassification ?? payload.classification, "dataClassification"),
94
+ classificationId: cleanText(payload.classificationId, "classificationId"),
81
95
  internetExposed: booleanValue(payload.internetExposed, "internetExposed"),
82
96
  programGoal: cleanText(payload.programGoal ?? "none", "programGoal"),
83
97
  draft,
@@ -91,7 +105,7 @@ function validateSetup(loaded, setup) {
91
105
  ["boundary", setup.boundary],
92
106
  ["ownerId", setup.ownerId],
93
107
  ["criticality", setup.criticality],
94
- ["dataClassification", setup.dataClassification]
108
+ ["classificationId", setup.classificationId]
95
109
  ]) {
96
110
  if (!value) throw new Error(`Setup field "${name}" is required.`);
97
111
  }
@@ -113,23 +127,24 @@ function validateSetup(loaded, setup) {
113
127
  }
114
128
  }
115
129
  const classifications = Object.keys(loaded.workspace.classificationDefinitions || {});
116
- if (classifications.length && !classifications.includes(setup.dataClassification)) {
117
- throw new Error(`dataClassification must be one of ${classifications.join(", ")}.`);
130
+ if (classifications.length && !classifications.includes(setup.classificationId)) {
131
+ throw new Error(`classificationId must be one of ${classifications.join(", ")}.`);
118
132
  }
119
133
  }
120
134
 
121
- function findSetupSystem(resources, setup) {
135
+ function findSetupSystem(resources, workspace, setup) {
136
+ const scopedSystemIds = new Set(workspace.systemIds || []);
122
137
  return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
123
- || resources.find(({ type, title, inScope, status }) => (
138
+ || resources.find(({ type, id, title, status }) => (
124
139
  type === "system"
125
- && inScope === true
140
+ && scopedSystemIds.has(id)
126
141
  && status !== "retired"
127
142
  && title.trim().toLowerCase() === setup.serviceName.toLowerCase()
128
143
  ));
129
144
  }
130
145
 
131
146
  function buildSetupRecords(loaded, setup) {
132
- const existingSystem = findSetupSystem(loaded.resources, setup);
147
+ const existingSystem = findSetupSystem(loaded.resources, loaded.workspace, setup);
133
148
  const systemId = existingSystem?.id || createResourceId(
134
149
  "system",
135
150
  setup.serviceName,
@@ -137,7 +152,6 @@ function buildSetupRecords(loaded, setup) {
137
152
  );
138
153
  const system = {
139
154
  ...(existingSystem || {}),
140
- schemaVersion: 1,
141
155
  id: systemId,
142
156
  type: "system",
143
157
  title: setup.serviceName,
@@ -150,9 +164,8 @@ function buildSetupRecords(loaded, setup) {
150
164
  ownerIds: [setup.ownerId],
151
165
  description: setup.boundary,
152
166
  systemKind: existingSystem?.systemKind || "service",
153
- dataClassification: setup.dataClassification,
154
- internetExposed: setup.internetExposed,
155
- inScope: true
167
+ classificationId: setup.classificationId,
168
+ internetExposed: setup.internetExposed
156
169
  };
157
170
  const existingWorkspace = loaded.resources.find(({ type }) => type === "workspace");
158
171
  if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
@@ -163,13 +176,31 @@ function buildSetupRecords(loaded, setup) {
163
176
  };
164
177
  const existingRenderer = loaded.resources.find(({ type }) => type === "renderer-settings");
165
178
  const renderer = existingRenderer ? { ...existingRenderer, showOnboarding: setup.draft } : null;
166
- return { existingSystem, system, workspace, renderer };
167
- }
168
-
169
- async function upsertResource(root, existing, record) {
170
- return existing
171
- ? updateResource(root, record.type, record.id, record)
172
- : createResource(root, record);
179
+ const existingCommitment = loaded.resources.find((record) => (
180
+ record.type === "commitment"
181
+ && !["superseded", "retired"].includes(record.status)
182
+ && (record.systemIds || []).includes(systemId)
183
+ ));
184
+ const commitment = String(loaded.model.modelVersion) === "3" && !existingCommitment
185
+ ? {
186
+ id: createResourceId(
187
+ "commitment",
188
+ `${setup.serviceName} service commitment`,
189
+ loaded.resources.map(({ id }) => id)
190
+ ),
191
+ type: "commitment",
192
+ title: `${setup.serviceName} service commitment`,
193
+ status: "planned",
194
+ commitmentKind: "service",
195
+ statement: "Replace this starter with the actual customer promise or approved service requirement before activation.",
196
+ systemIds: [systemId],
197
+ ownerIds: [setup.ownerId],
198
+ customerFacing: true,
199
+ ...(workspace.requirementIds?.length ? { requirementIds: [...workspace.requirementIds] } : {}),
200
+ ...(workspace.controlIds?.length ? { controlIds: [...workspace.controlIds] } : {})
201
+ }
202
+ : null;
203
+ return { existingSystem, system, workspace, renderer, commitment };
173
204
  }
174
205
 
175
206
  function assuranceGoalFromSetup(goal) {
@@ -0,0 +1,61 @@
1
+ export function sourceCoverageComplete(record, loaded) {
2
+ if (!record?.validFrom || !record.collectionCadence || !record.retention || !record.reconciliationMethod) {
3
+ return false;
4
+ }
5
+ if (record.coverageKind === "external-system" && (!record.systemId || !(record.retrieverIds || []).length)) {
6
+ return false;
7
+ }
8
+ if (["not-applicable", "zero-population"].includes(record.coverageKind) && !record.applicabilityReview) {
9
+ return false;
10
+ }
11
+ if (loaded.workspace?.candidateCoverage && !(record.readinessTestEvidenceIds || []).length) {
12
+ return false;
13
+ }
14
+ if (loaded.workspace?.candidateCoverage) {
15
+ const tests = (record.readinessTestEvidenceIds || []).map((id) => (
16
+ loaded.resources.find((resource) => resource.id === id && resource.type === "evidence")
17
+ ));
18
+ if (tests.some((test) => (
19
+ !test
20
+ || test.readinessTest !== true
21
+ || test.retrievalResult !== "passed"
22
+ || test.accessConfirmed !== true
23
+ || !(test.coveredSourceFamilyIds || []).includes(record.sourceFamilyId)
24
+ || (record.coverageKind === "external-system" && !(
25
+ test.sourceSystemId === record.systemId
26
+ || (test.systemIds || []).includes(record.systemId)
27
+ ))
28
+ ))) return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ export function assessSourceCoverageReadiness(loaded, selectedControlIds = []) {
34
+ if (!loaded.model.resources["source-coverage"]) return [];
35
+ const selected = new Set(selectedControlIds);
36
+ const selectedControlCodes = new Set(loaded.resources
37
+ .filter((record) => (
38
+ record.type === "control"
39
+ && selected.has(record.id)
40
+ && !["not-applicable", "retired"].includes(record.status)
41
+ ))
42
+ .map(({ code }) => code)
43
+ .filter(Boolean));
44
+ return (loaded.model.evidenceSourceFamilies || [])
45
+ .filter((family) => family.controlCodes.some((code) => selectedControlCodes.has(code)))
46
+ .map((family) => {
47
+ const records = loaded.resources.filter((record) => (
48
+ record.type === "source-coverage"
49
+ && record.sourceFamilyId === family.id
50
+ && record.status !== "retired"
51
+ ));
52
+ const record = records.find(({ status }) => status === "active")
53
+ || records.find(({ status }) => status === "planned")
54
+ || null;
55
+ return {
56
+ family,
57
+ record,
58
+ complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded))
59
+ };
60
+ });
61
+ }
package/src/state.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { assessAuditPreparation } from "./audit-preparation.js";
4
+ import { assessCollectionReviews } from "./collection-review.js";
4
5
  import { getBrowserRepositoryState, getGitSummary, getWorkspaceHistories } from "./git.js";
5
6
  import { renderMarkdown } from "./markdown.js";
6
7
  import { planObligations } from "./obligations.js";
@@ -8,36 +9,39 @@ import { resolveDataPath } from "./paths.js";
8
9
  import { assessProgramReadiness } from "./program-readiness.js";
9
10
  import { markdownEntries } from "./resource-markdown.js";
10
11
  import { currentCalendarDate } from "./time.js";
11
- import { validateWorkspace } from "./validate.js";
12
+ import { serializeWorkspaceMutation } from "./mutation.js";
13
+ import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
14
+ import { assessWorkflow } from "./workflow.js";
15
+
16
+ const renderedMarkdownCache = new Map();
17
+ const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
12
18
 
13
19
  export async function createAppState(input = process.cwd(), options = {}) {
14
- const validation = await validateWorkspace(input);
20
+ return serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options));
21
+ }
22
+
23
+ async function createAppStateUnlocked(input, options) {
24
+ let validation;
25
+ if (options.validationProof) {
26
+ const current = await fingerprintWorkspace(input);
27
+ validation = current.fingerprint === options.validationProof.fingerprint
28
+ ? { ...options.validationProof.validation, loaded: current.loaded }
29
+ : await validateWorkspace(current.loaded);
30
+ } else {
31
+ validation = await validateWorkspace(input);
32
+ }
15
33
  const { loaded } = validation;
16
34
  const entries = [];
17
- const relativePaths = loaded.entries.map((entry) => `data/${entry.relativePath}`);
18
- const histories = getWorkspaceHistories(loaded.root, relativePaths, 12);
35
+ const includeDetails = options.includeDetails !== false;
36
+ const histories = includeDetails
37
+ ? getWorkspaceHistories(loaded.root, loaded.entries.map((entry) => `data/${entry.relativePath}`), 12)
38
+ : new Map();
19
39
 
20
40
  for (const entry of loaded.entries) {
21
- const record = structuredClone(entry.record);
22
- const content = {};
23
- if (loaded.model.resources[record.type]) {
24
- for (const item of markdownEntries(loaded.model, record)) {
25
- try {
26
- const path = resolveDataPath(loaded.root, item.path);
27
- const source = await readFile(path, "utf8");
28
- content[item.name] = { source, html: renderMarkdown(source), path: item.path, revision: contentRevision(source) };
29
- } catch {
30
- // Validation reports missing required Markdown.
31
- }
32
- }
33
- }
34
- entries.push({
35
- record,
36
- relativePath: `data/${entry.relativePath}`,
37
- revision: contentRevision(entry.source),
38
- content,
41
+ entries.push(await createStateEntry(loaded, entry, {
42
+ includeDetails,
39
43
  history: histories.get(`data/${entry.relativePath}`) ?? []
40
- });
44
+ }));
41
45
  }
42
46
 
43
47
  const git = getGitSummary(loaded.root);
@@ -47,7 +51,6 @@ export async function createAppState(input = process.cwd(), options = {}) {
47
51
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
48
52
  });
49
53
  const workspace = loaded.workspace ?? {
50
- schemaVersion: 1,
51
54
  dataModelVersion: loaded.model.modelVersion,
52
55
  id: "workspace",
53
56
  type: "workspace",
@@ -72,8 +75,41 @@ export async function createAppState(input = process.cwd(), options = {}) {
72
75
  return [audit?.id || "none", preparation];
73
76
  })
74
77
  ));
78
+ const obligations = planObligations(entries, {
79
+ asOf,
80
+ now: options.now ?? generatedAt,
81
+ model: loaded.model
82
+ });
83
+ const workflow = await assessWorkflow(loaded, {
84
+ asOf,
85
+ evaluatedAt: generatedAt,
86
+ programReadiness,
87
+ auditPreparations: Object.fromEntries(
88
+ Object.entries(auditPreparations).filter(([id]) => id !== "none")
89
+ ),
90
+ obligations,
91
+ git,
92
+ validation
93
+ });
94
+ const collectionReviews = Object.fromEntries(
95
+ assessCollectionReviews(loaded).map((assessment) => [
96
+ assessment.resourceType,
97
+ {
98
+ resourceType: assessment.resourceType,
99
+ configuration: assessment.configuration,
100
+ recordCount: assessment.recordCount,
101
+ review: assessment.review,
102
+ reviewRevision: assessment.reviewRevision,
103
+ collectionRevision: assessment.collectionRevision,
104
+ status: assessment.status,
105
+ complete: assessment.complete,
106
+ message: assessment.message
107
+ }
108
+ ])
109
+ );
75
110
  return {
76
111
  generatedAt,
112
+ asOf,
77
113
  readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
78
114
  repository,
79
115
  workspace,
@@ -84,13 +120,74 @@ export async function createAppState(input = process.cwd(), options = {}) {
84
120
  counts: validation.counts,
85
121
  diagnostics: validation.diagnostics
86
122
  },
87
- obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt }),
123
+ obligations,
124
+ collectionReviews,
88
125
  programReadiness,
89
126
  auditPreparations,
127
+ workflow,
90
128
  git
91
129
  };
92
130
  }
93
131
 
132
+ export async function createResourceDetail(input, type, id) {
133
+ return serializeWorkspaceMutation(input, async (root) => {
134
+ const validation = await validateWorkspace(root);
135
+ const entry = validation.loaded.entries.find(({ record }) => record.type === type && record.id === id);
136
+ if (!entry) return null;
137
+ const relativePath = `data/${entry.relativePath}`;
138
+ const histories = getWorkspaceHistories(validation.loaded.root, [relativePath], 12);
139
+ return createStateEntry(validation.loaded, entry, {
140
+ includeDetails: true,
141
+ history: histories.get(relativePath) ?? []
142
+ });
143
+ });
144
+ }
145
+
146
+ async function createStateEntry(loaded, entry, options) {
147
+ const record = structuredClone(entry.record);
148
+ const content = {};
149
+ if (loaded.model.resources[record.type]) {
150
+ for (const item of markdownEntries(loaded.model, record)) {
151
+ try {
152
+ const path = resolveDataPath(loaded.root, item.path);
153
+ const source = await readFile(path, "utf8");
154
+ content[item.name] = {
155
+ source,
156
+ ...(options.includeDetails ? { html: renderMarkdownCached(source) } : {}),
157
+ path: item.path,
158
+ revision: contentRevision(source)
159
+ };
160
+ } catch {
161
+ // Validation reports missing required Markdown.
162
+ }
163
+ }
164
+ }
165
+ return {
166
+ record,
167
+ relativePath: `data/${entry.relativePath}`,
168
+ revision: contentRevision(entry.source),
169
+ content,
170
+ history: options.includeDetails ? options.history : undefined,
171
+ detailsLoaded: options.includeDetails
172
+ };
173
+ }
174
+
175
+ function renderMarkdownCached(source) {
176
+ const revision = contentRevision(source);
177
+ const cached = renderedMarkdownCache.get(revision);
178
+ if (cached !== undefined) {
179
+ renderedMarkdownCache.delete(revision);
180
+ renderedMarkdownCache.set(revision, cached);
181
+ return cached;
182
+ }
183
+ const html = renderMarkdown(source);
184
+ renderedMarkdownCache.set(revision, html);
185
+ if (renderedMarkdownCache.size > MAX_RENDERED_MARKDOWN_CACHE_ENTRIES) {
186
+ renderedMarkdownCache.delete(renderedMarkdownCache.keys().next().value);
187
+ }
188
+ return html;
189
+ }
190
+
94
191
  function contentRevision(source) {
95
192
  return createHash("sha256").update(source).digest("hex");
96
193
  }
package/src/timing.js ADDED
@@ -0,0 +1,41 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ const timingContext = new AsyncLocalStorage();
5
+
6
+ export async function collectTimings(task) {
7
+ const timings = new Map();
8
+ const result = await timingContext.run(timings, task);
9
+ return { result, timings: Object.fromEntries(timings) };
10
+ }
11
+
12
+ export async function measureTiming(name, task) {
13
+ const started = performance.now();
14
+ try {
15
+ return await task();
16
+ } finally {
17
+ recordTiming(name, performance.now() - started);
18
+ }
19
+ }
20
+
21
+ export function measureTimingSync(name, task) {
22
+ const started = performance.now();
23
+ try {
24
+ return task();
25
+ } finally {
26
+ recordTiming(name, performance.now() - started);
27
+ }
28
+ }
29
+
30
+ export function recordTiming(name, durationMs) {
31
+ const timings = timingContext.getStore();
32
+ if (!timings) return;
33
+ const current = timings.get(name) ?? { count: 0, durationMs: 0 };
34
+ current.count += 1;
35
+ current.durationMs += durationMs;
36
+ timings.set(name, current);
37
+ }
38
+
39
+ export function timingEnabled() {
40
+ return process.env.FILEGRC_TIMING === "1";
41
+ }