filegrc 0.16.20 → 0.16.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.20",
3
+ "version": "0.16.22",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/retention.js CHANGED
@@ -167,16 +167,17 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
167
167
  const entry = effectiveReviewEntry(loaded, entries.get(id));
168
168
  if (!entry || reviewing.has(key)) return null;
169
169
  reviewing.add(key);
170
- const parts = [reviewSource(loaded, entry, scopeHashInput, false, heading, reviewer)];
171
- let matchedSection = !heading;
172
- for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
170
+ const decisionHeading = heading || retentionPolicyHeading(loaded, entry, reviewer);
171
+ const parts = [reviewSource(loaded, entry, scopeHashInput, false, decisionHeading, reviewer)];
172
+ let matchedSection = !decisionHeading;
173
+ for (const markdown of reviewMarkdownEntries(loaded, entry, false, reviewer)) {
173
174
  try {
174
175
  const source = entry.reviewCommit
175
176
  ? entry.reviewFiles?.get(`data/${markdown.path}`)
176
177
  ?? getFileAtRevision(loaded.root, entry.reviewCommit, `data/${markdown.path}`)
177
178
  : await readFile(resolveDataPath(loaded.root, markdown.path), "utf8");
178
179
  if (source !== null) {
179
- const section = reviewedMarkdownSection(source, heading);
180
+ const section = reviewedMarkdownSection(source, decisionHeading);
180
181
  if (section !== null) { parts.push(section); matchedSection = true; }
181
182
  }
182
183
  } catch (error) {
@@ -214,9 +215,10 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
214
215
  const entry = historicalCommit ? entries.get(id) : effectiveReviewEntry(loaded, entries.get(id));
215
216
  if (!entry || reviewing.has(key)) return null;
216
217
  reviewing.add(key);
217
- const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, heading, reviewer)];
218
- let matchedSection = !heading;
219
- for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource)) {
218
+ const decisionHeading = legacySource ? heading : heading || retentionPolicyHeading(loaded, entry, reviewer);
219
+ const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, decisionHeading, reviewer)];
220
+ let matchedSection = !decisionHeading;
221
+ for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource, reviewer)) {
220
222
  try {
221
223
  const source = (historicalCommit || entry.reviewCommit)
222
224
  ? loaded.historicalFiles?.get(`data/${markdown.path}`)
@@ -224,7 +226,7 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
224
226
  ?? getFileAtRevision(loaded.root, historicalCommit || entry.reviewCommit, `data/${markdown.path}`)
225
227
  : readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8");
226
228
  if (source !== null) {
227
- const section = legacySource ? source : reviewedMarkdownSection(source, heading);
229
+ const section = legacySource ? source : reviewedMarkdownSection(source, decisionHeading);
228
230
  if (section !== null) { parts.push(section); matchedSection = true; }
229
231
  }
230
232
  } catch (error) {
@@ -368,11 +370,16 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f
368
370
  )), scopeHashInput);
369
371
  }
370
372
 
371
- function reviewMarkdownEntries(loaded, entry, legacySource) {
373
+ function reviewMarkdownEntries(loaded, entry, legacySource, reviewer = null) {
372
374
  // Control Markdown records the implementation procedure. Retention and
373
375
  // mapping decisions depend on the structured Control design instead.
374
376
  if (!legacySource && modelSupports(loaded.model, "retention-schedule-approval")
375
377
  && entry.record.type === "control") return [];
378
+ // A Component's Markdown explains how to retrieve evidence. Retention
379
+ // decisions depend on its information uses and scope, not those steps.
380
+ // Keep the Markdown in legacy hashes so committed approvals can be found.
381
+ if (!legacySource && reviewer?.type === "retention-schedule-item"
382
+ && entry.record.type === "component") return [];
376
383
  return markdownEntries(loaded.model, entry.record);
377
384
  }
378
385
 
@@ -460,6 +467,32 @@ function sourceHeading(record, sourceId) {
460
467
  ? headings[sourceId] : null;
461
468
  }
462
469
 
470
+ function retentionPolicyHeading(loaded, entry, reviewer) {
471
+ if (reviewer?.type !== "retention-schedule-item" || entry.record.type !== "policy") return null;
472
+ // Older rows often bind a whole Policy. A single retention section gives
473
+ // that decision a narrower basis while an explicit heading still wins.
474
+ const headings = new Set();
475
+ for (const markdown of markdownEntries(loaded.model, entry.record)) {
476
+ let source;
477
+ try {
478
+ source = loaded.historicalFiles?.get(`data/${markdown.path}`)
479
+ ?? (entry.reviewCommit
480
+ ? entry.reviewFiles?.get(`data/${markdown.path}`)
481
+ ?? getFileAtRevision(loaded.root, entry.reviewCommit, `data/${markdown.path}`)
482
+ : readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
483
+ } catch (error) {
484
+ if (error.code === "ENOENT") continue;
485
+ throw error;
486
+ }
487
+ for (const line of (source || "").split("\n")) {
488
+ const heading = /^#{2,6}\s+(.+?)\s*#*\s*$/.exec(line)?.[1];
489
+ if (heading && /\b(retention|disposal|deletion|destruction|archiving)\b/i.test(heading)
490
+ && reviewedMarkdownSection(source, heading) !== null) headings.add(heading);
491
+ }
492
+ }
493
+ return headings.size === 1 ? [...headings][0] : null;
494
+ }
495
+
463
496
  const reviewDecisionFields = {
464
497
  control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
465
498
  commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "sourceSectionHeadings", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
@@ -489,6 +522,7 @@ function substantiveReviewSource(record, currentModel, reviewer = null) {
489
522
  }
490
523
  if (record.type === "component" && reviewer?.type === "retention-schedule-item") {
491
524
  decision.systemUses = retentionSystemUses(decision.systemUses);
525
+ delete decision.evidenceSourceKinds;
492
526
  }
493
527
  if (["control", "commitment", "complementary-control"].includes(record.type)) {
494
528
  decision.nonApplicableDecision = ["not-applicable", "externally-managed", "zero-population"]
package/src/workflow.js CHANGED
@@ -1,6 +1,6 @@
1
- import { cp, mkdtemp, rm } from "node:fs/promises";
1
+ import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
3
+ import { join, relative } from "node:path";
4
4
  import { modelSupports } from "../model/index.js";
5
5
  import { buildActionContext } from "./action-context.js";
6
6
  import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "./applicability-scope.js";
@@ -14,7 +14,7 @@ import {
14
14
  deleteResource,
15
15
  updateResource
16
16
  } from "./files.js";
17
- import { getGitSummary, getWorkspaceHistories } from "./git.js";
17
+ import { getGitSummary, getWorkspaceHistories, runGitCommandSync } from "./git.js";
18
18
  import {
19
19
  completeObligationAction,
20
20
  completeObligationEvent,
@@ -260,13 +260,39 @@ function contextualAction(item, loaded) {
260
260
 
261
261
  export async function previewWorkflowMutation(input, mutation) {
262
262
  const loaded = await loadWorkspace(input);
263
- const previewRoot = await mkdtemp(join(tmpdir(), "filegrc-workflow-preview-"));
263
+ const previewCheckout = await mkdtemp(join(tmpdir(), "filegrc-workflow-preview-"));
264
264
  try {
265
+ const repository = getGitSummary(loaded.root);
266
+ if (repository.available) {
267
+ // Legacy review bindings need committed source versions. A local clone
268
+ // gives preview the same history as update without touching the source.
269
+ runGitCommandSync(loaded.root, ["clone", "--shared", "--no-checkout", "--quiet", repository.root, previewCheckout], {
270
+ timeoutMs: 30_000
271
+ });
272
+ }
273
+ const previewRoot = repository.available
274
+ ? join(previewCheckout, relative(repository.root, loaded.root))
275
+ : previewCheckout;
276
+ await mkdir(previewRoot, { recursive: true });
277
+ if (repository.commit) {
278
+ // Restore the index and tracked workspace files. A no-checkout clone
279
+ // otherwise reports every source file as deleted and copied data as new.
280
+ runGitCommandSync(loaded.root, ["-C", previewCheckout, "reset", "--mixed", "HEAD"]);
281
+ if (runGitCommandSync(loaded.root, ["-C", previewRoot, "ls-files", "--", "."])) {
282
+ runGitCommandSync(loaded.root, ["-C", previewRoot, "restore", "--worktree", "--", "."]);
283
+ }
284
+ }
285
+ await rm(join(previewRoot, "data"), { recursive: true, force: true });
265
286
  await cp(join(loaded.root, "data"), join(previewRoot, "data"), {
266
287
  recursive: true,
267
288
  errorOnExist: true,
268
289
  force: false
269
290
  });
291
+ if (repository.available && !repository.clean && getGitSummary(previewRoot).clean) {
292
+ const marker = join(previewRoot, "preview-source-dirty.marker");
293
+ await writeFile(marker, "The source workspace has uncommitted files.\n");
294
+ runGitCommandSync(loaded.root, ["-C", previewRoot, "add", "-N", "-f", "--", "preview-source-dirty.marker"]);
295
+ }
270
296
  const before = await assessWorkflow(loaded.root, mutation?.assessment || {});
271
297
  const record = mutation?.record;
272
298
  const existing = record?.id
@@ -307,7 +333,7 @@ export async function previewWorkflowMutation(input, mutation) {
307
333
  workflow: after
308
334
  };
309
335
  } finally {
310
- await rm(previewRoot, { recursive: true, force: true });
336
+ await rm(previewCheckout, { recursive: true, force: true });
311
337
  }
312
338
  }
313
339