mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Policies: enforce input on uploads only; badge follows edited files (#6627)
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
fileContextReducer,
|
||||||
|
initialFileContextState,
|
||||||
|
} from "@app/contexts/file/FileReducer";
|
||||||
|
import type {
|
||||||
|
FileContextState,
|
||||||
|
StirlingFileStub,
|
||||||
|
} from "@app/types/fileContext";
|
||||||
|
import type { FileId } from "@app/types/file";
|
||||||
|
|
||||||
|
function stub(
|
||||||
|
id: string,
|
||||||
|
overrides: Partial<StirlingFileStub> = {},
|
||||||
|
): StirlingFileStub {
|
||||||
|
return {
|
||||||
|
id: id as FileId,
|
||||||
|
name: `${id}.pdf`,
|
||||||
|
type: "application/pdf",
|
||||||
|
size: 1,
|
||||||
|
lastModified: 0,
|
||||||
|
isLeaf: true,
|
||||||
|
originalFileId: id,
|
||||||
|
versionNumber: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWith(stubs: StirlingFileStub[]): FileContextState {
|
||||||
|
return {
|
||||||
|
...initialFileContextState,
|
||||||
|
files: {
|
||||||
|
ids: stubs.map((s) => s.id),
|
||||||
|
byId: Object.fromEntries(stubs.map((s) => [s.id, s])),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fileContextReducer — derivedFromTool provenance", () => {
|
||||||
|
it("ADD_FILES leaves uploads unmarked (a genuine upload is not tool-derived)", () => {
|
||||||
|
const next = fileContextReducer(initialFileContextState, {
|
||||||
|
type: "ADD_FILES",
|
||||||
|
payload: { stirlingFileStubs: [stub("a")] },
|
||||||
|
});
|
||||||
|
expect(next.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CONSUME_FILES marks every output as tool-derived", () => {
|
||||||
|
// An upload "a" is consumed by a tool producing an independent artifact "b"
|
||||||
|
// (version metadata identical to an upload — only the flag distinguishes it).
|
||||||
|
const next = fileContextReducer(stateWith([stub("a")]), {
|
||||||
|
type: "CONSUME_FILES",
|
||||||
|
payload: {
|
||||||
|
inputFileIds: ["a" as FileId],
|
||||||
|
outputStirlingFileStubs: [stub("b")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(next.files.byId["b" as FileId].derivedFromTool).toBe(true);
|
||||||
|
// Provenance: "b" records the input it derived from.
|
||||||
|
expect(next.files.byId["b" as FileId].sourceFileIds).toEqual(["a"]);
|
||||||
|
expect(next.files.byId["a" as FileId]).toBeUndefined(); // input consumed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CONSUME_FILES accumulates sourceFileIds transitively", () => {
|
||||||
|
// "b" already derived from "a"; consuming "b" → "c" carries both, so the
|
||||||
|
// badge still resolves after the intermediate "b" is gone (e.g. split).
|
||||||
|
const start = stateWith([stub("b", { sourceFileIds: ["a" as FileId] })]);
|
||||||
|
const next = fileContextReducer(start, {
|
||||||
|
type: "CONSUME_FILES",
|
||||||
|
payload: {
|
||||||
|
inputFileIds: ["b" as FileId],
|
||||||
|
outputStirlingFileStubs: [stub("c")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(next.files.byId["c" as FileId].sourceFileIds).toEqual(["b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CONSUME_FILES with multiple inputs (merge) records all sources", () => {
|
||||||
|
const start = stateWith([stub("a"), stub("b")]);
|
||||||
|
const next = fileContextReducer(start, {
|
||||||
|
type: "CONSUME_FILES",
|
||||||
|
payload: {
|
||||||
|
inputFileIds: ["a" as FileId, "b" as FileId],
|
||||||
|
outputStirlingFileStubs: [stub("merged")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(next.files.byId["merged" as FileId].sourceFileIds).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UNDO_CONSUME_FILES restores the original upload without mislabelling it", () => {
|
||||||
|
// Reverses the swap above: the original upload "a" comes back through the
|
||||||
|
// same helper, but must NOT be flagged tool-derived.
|
||||||
|
const consumed = fileContextReducer(stateWith([stub("a")]), {
|
||||||
|
type: "CONSUME_FILES",
|
||||||
|
payload: {
|
||||||
|
inputFileIds: ["a" as FileId],
|
||||||
|
outputStirlingFileStubs: [stub("b")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const undone = fileContextReducer(consumed, {
|
||||||
|
type: "UNDO_CONSUME_FILES",
|
||||||
|
payload: {
|
||||||
|
inputStirlingFileStubs: [stub("a")],
|
||||||
|
outputFileIds: ["b" as FileId],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(undone.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
|
||||||
|
expect(undone.files.byId["b" as FileId]).toBeUndefined(); // output removed
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -288,7 +288,33 @@ export function fileContextReducer(
|
|||||||
case "CONSUME_FILES": {
|
case "CONSUME_FILES": {
|
||||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||||
|
|
||||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
// Transitive provenance: the outputs derive from these inputs AND from
|
||||||
|
// whatever those inputs themselves derived from. Accumulating the closure
|
||||||
|
// (rather than just the immediate inputs) means a policy badge still
|
||||||
|
// resolves after an intermediate edit has been consumed and removed —
|
||||||
|
// e.g. redact → edit → split still tags each split part. Captured before
|
||||||
|
// the inputs are swapped out below.
|
||||||
|
const sourceFileIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
inputFileIds.flatMap((id) => [
|
||||||
|
id,
|
||||||
|
...(state.files.byId[id]?.sourceFileIds ?? []),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark every consume output as tool-produced (the single chokepoint for
|
||||||
|
// both versioned edits and independent artifacts like convert/split/merge)
|
||||||
|
// and stamp its provenance. Tag here, not in processFileSwap, so
|
||||||
|
// UNDO_CONSUME (which restores the original inputs through the same helper)
|
||||||
|
// doesn't mislabel real uploads.
|
||||||
|
const taggedOutputs = outputStirlingFileStubs.map((stub) => ({
|
||||||
|
...stub,
|
||||||
|
derivedFromTool: true,
|
||||||
|
sourceFileIds,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return processFileSwap(state, inputFileIds, taggedOutputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "UNDO_CONSUME_FILES": {
|
case "UNDO_CONSUME_FILES": {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
legacyDerivedFromTool,
|
||||||
|
type StoredStirlingFileRecord,
|
||||||
|
} from "@app/services/fileStorage";
|
||||||
|
import type { FileId } from "@app/types/file";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill of `derivedFromTool` for files persisted before the field existed
|
||||||
|
* (old users' IndexedDB). New records always carry an explicit flag, so this
|
||||||
|
* helper only governs pre-upgrade records.
|
||||||
|
*/
|
||||||
|
function record(
|
||||||
|
overrides: Partial<StoredStirlingFileRecord>,
|
||||||
|
): StoredStirlingFileRecord {
|
||||||
|
return {
|
||||||
|
id: "f" as FileId,
|
||||||
|
fileId: "f" as FileId,
|
||||||
|
quickKey: "k",
|
||||||
|
name: "f.pdf",
|
||||||
|
type: "application/pdf",
|
||||||
|
size: 1,
|
||||||
|
lastModified: 0,
|
||||||
|
isLeaf: true,
|
||||||
|
originalFileId: "f",
|
||||||
|
versionNumber: 1,
|
||||||
|
data: new ArrayBuffer(0),
|
||||||
|
...overrides,
|
||||||
|
} as StoredStirlingFileRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", () => {
|
||||||
|
it("flags a legacy versioned edit (has tool history)", () => {
|
||||||
|
expect(
|
||||||
|
legacyDerivedFromTool(
|
||||||
|
record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a legacy file past its first version", () => {
|
||||||
|
expect(legacyDerivedFromTool(record({ versionNumber: 2 }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a legacy file with a parent", () => {
|
||||||
|
expect(legacyDerivedFromTool(record({ parentFileId: "p" as FileId }))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a clean legacy root unflagged — treated as an upload (safe default for enforcement)", () => {
|
||||||
|
// A genuine upload AND a legacy independent artifact (convert/split/merge)
|
||||||
|
// both look like this; old data can't tell them apart, so we enforce.
|
||||||
|
expect(legacyDerivedFromTool(record({}))).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,6 +38,25 @@ export interface StorageStats {
|
|||||||
quota?: number;
|
quota?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort provenance for records persisted before `derivedFromTool`
|
||||||
|
* existed. A version chain (a tool history, a version past the first, or a
|
||||||
|
* parent) is unambiguously a tool output, so flag it. Legacy independent
|
||||||
|
* artifacts (convert/split/merge) recorded none of that and are
|
||||||
|
* indistinguishable from uploads in old data — they stay unflagged, which for
|
||||||
|
* an enforcement feature is the safe default (enforce rather than silently
|
||||||
|
* skip). New records always carry an explicit flag, so this only fires for
|
||||||
|
* pre-existing files on first read after upgrade.
|
||||||
|
*/
|
||||||
|
export function legacyDerivedFromTool(
|
||||||
|
record: StoredStirlingFileRecord,
|
||||||
|
): boolean | undefined {
|
||||||
|
if ((record.toolHistory?.length ?? 0) > 0) return true;
|
||||||
|
if ((record.versionNumber ?? 1) > 1) return true;
|
||||||
|
if (record.parentFileId != null) return true;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
class FileStorageService {
|
class FileStorageService {
|
||||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||||
private readonly storeName = "files";
|
private readonly storeName = "files";
|
||||||
@@ -124,6 +143,8 @@ class FileStorageService {
|
|||||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||||
parentFileId: stub.parentFileId ?? undefined,
|
parentFileId: stub.parentFileId ?? undefined,
|
||||||
toolHistory: stub.toolHistory ?? [],
|
toolHistory: stub.toolHistory ?? [],
|
||||||
|
derivedFromTool: stub.derivedFromTool ?? false,
|
||||||
|
sourceFileIds: stub.sourceFileIds,
|
||||||
|
|
||||||
// Folder organisation (root when null)
|
// Folder organisation (root when null)
|
||||||
folderId: stub.folderId ?? null,
|
folderId: stub.folderId ?? null,
|
||||||
@@ -247,6 +268,9 @@ class FileStorageService {
|
|||||||
originalFileId: record.originalFileId,
|
originalFileId: record.originalFileId,
|
||||||
parentFileId: record.parentFileId,
|
parentFileId: record.parentFileId,
|
||||||
toolHistory: record.toolHistory,
|
toolHistory: record.toolHistory,
|
||||||
|
derivedFromTool:
|
||||||
|
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||||
|
sourceFileIds: record.sourceFileIds,
|
||||||
folderId: record.folderId ?? null,
|
folderId: record.folderId ?? null,
|
||||||
createdAt: record.createdAt || Date.now(),
|
createdAt: record.createdAt || Date.now(),
|
||||||
};
|
};
|
||||||
@@ -303,6 +327,9 @@ class FileStorageService {
|
|||||||
originalFileId: record.originalFileId || record.id,
|
originalFileId: record.originalFileId || record.id,
|
||||||
parentFileId: record.parentFileId,
|
parentFileId: record.parentFileId,
|
||||||
toolHistory: record.toolHistory || [],
|
toolHistory: record.toolHistory || [],
|
||||||
|
derivedFromTool:
|
||||||
|
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||||
|
sourceFileIds: record.sourceFileIds,
|
||||||
folderId: record.folderId ?? null,
|
folderId: record.folderId ?? null,
|
||||||
createdAt: record.createdAt || Date.now(),
|
createdAt: record.createdAt || Date.now(),
|
||||||
});
|
});
|
||||||
@@ -391,6 +418,9 @@ class FileStorageService {
|
|||||||
originalFileId: record.originalFileId || record.id,
|
originalFileId: record.originalFileId || record.id,
|
||||||
parentFileId: record.parentFileId,
|
parentFileId: record.parentFileId,
|
||||||
toolHistory: record.toolHistory || [],
|
toolHistory: record.toolHistory || [],
|
||||||
|
derivedFromTool:
|
||||||
|
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||||
|
sourceFileIds: record.sourceFileIds,
|
||||||
folderId: record.folderId ?? null,
|
folderId: record.folderId ?? null,
|
||||||
createdAt: record.createdAt || Date.now(),
|
createdAt: record.createdAt || Date.now(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,30 @@ export interface BaseFileMetadata {
|
|||||||
parentFileId?: FileId; // Immediate parent file ID
|
parentFileId?: FileId; // Immediate parent file ID
|
||||||
toolHistory?: ToolOperation[]; // Tool chain for history tracking
|
toolHistory?: ToolOperation[]; // Tool chain for history tracking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if this file was produced by a tool/automation in-app (any
|
||||||
|
* `consumeFiles` output — a versioned edit OR an independent artifact like a
|
||||||
|
* convert/split/merge result) rather than entering the system as a genuine
|
||||||
|
* upload. Set at the consume chokepoint so it covers both kinds, including
|
||||||
|
* independent artifacts whose version metadata is otherwise indistinguishable
|
||||||
|
* from a fresh upload. Persisted so the distinction survives a reload.
|
||||||
|
* Used by input-mode (upload) policy auto-run to enforce only on real uploads.
|
||||||
|
*/
|
||||||
|
derivedFromTool?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transitive set of fileIds this file was derived from — the inputs of the
|
||||||
|
* tool operation that produced it, plus those inputs' own `sourceFileIds`.
|
||||||
|
* Recorded at the `consumeFiles` boundary, the only place that knows the
|
||||||
|
* input→output mapping. Unlike `parentFileId` (the version chain) this is a
|
||||||
|
* pure provenance link, so it covers independent artifacts — split (1→N),
|
||||||
|
* merge (N→1), convert — that intentionally have no parent. Being transitive,
|
||||||
|
* it survives an intermediate edit being consumed/removed. Persisted; used so
|
||||||
|
* a policy badge follows a document onto everything derived from it. Legacy
|
||||||
|
* files predate it (the link was never recorded) and stay empty.
|
||||||
|
*/
|
||||||
|
sourceFileIds?: FileId[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cloud folder this file lives in. Semantics:
|
* The cloud folder this file lives in. Semantics:
|
||||||
* - `remoteStorageId == null` → file is local-only; folderId MUST be null.
|
* - `remoteStorageId == null` → file is local-only; folderId MUST be null.
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ export function usePolicyAutoRun(): void {
|
|||||||
);
|
);
|
||||||
for (const [categoryId, s] of active) {
|
for (const [categoryId, s] of active) {
|
||||||
for (const stub of fileStubs) {
|
for (const stub of fileStubs) {
|
||||||
|
// Input-mode policies enforce only on files that actually entered the
|
||||||
|
// system as an upload — not on files a tool/automation produced in-app
|
||||||
|
// (versioned edits or independent artifacts like convert/split/merge).
|
||||||
|
// Those are enforced only by export-mode policies, at export time.
|
||||||
|
if (stub.derivedFromTool) continue;
|
||||||
const key = dispatchKey(categoryId, stub.id);
|
const key = dispatchKey(categoryId, stub.id);
|
||||||
// Skip if already run (persisted) or a dispatch is in flight — the
|
// Skip if already run (persisted) or a dispatch is in flight — the
|
||||||
// in-memory guard prevents double-firing during the async wait.
|
// in-memory guard prevents double-firing during the async wait.
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildPolicyBadgeMap } from "@app/hooks/usePolicyFileBadges";
|
||||||
|
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
|
||||||
|
|
||||||
|
const NOW = 1_000_000;
|
||||||
|
const labels = new Map([
|
||||||
|
["security", "Security"],
|
||||||
|
["watermark", "Watermark"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function run(overrides: Partial<PolicyRunRecord>): PolicyRunRecord {
|
||||||
|
return {
|
||||||
|
runId: "r",
|
||||||
|
categoryId: "security",
|
||||||
|
fileId: "in",
|
||||||
|
fileName: "in.pdf",
|
||||||
|
fileSize: 1,
|
||||||
|
status: "COMPLETED",
|
||||||
|
outputs: [],
|
||||||
|
outputFileIds: ["out"],
|
||||||
|
error: null,
|
||||||
|
startedAt: NOW - 1_000, // recent by default
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildPolicyBadgeMap — badge follows the document onto derived files", () => {
|
||||||
|
it("badges a policy's direct output, and marks it recent within the window", () => {
|
||||||
|
const map = buildPolicyBadgeMap([run({})], [{ id: "out" }], labels, NOW);
|
||||||
|
const badges = map.get("out") ?? [];
|
||||||
|
expect(badges.map((b) => b.id)).toEqual(["security"]);
|
||||||
|
expect(badges[0].recent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a versioned edit inherits the badge via parentFileId (never glows)", () => {
|
||||||
|
const map = buildPolicyBadgeMap(
|
||||||
|
[run({})],
|
||||||
|
[{ id: "out" }, { id: "edit", parentFileId: "out" }],
|
||||||
|
labels,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
const edit = map.get("edit") ?? [];
|
||||||
|
expect(edit.map((b) => b.id)).toEqual(["security"]);
|
||||||
|
expect(edit[0].recent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SPLIT parts inherit the badge via sourceFileIds, though they have no parent", () => {
|
||||||
|
// Split consumes the policy output "out" → two fresh roots, no parentFileId,
|
||||||
|
// each recording sourceFileIds=["out"]. "out" itself is gone from the
|
||||||
|
// workbench (consumed) but still lives in the run store.
|
||||||
|
const map = buildPolicyBadgeMap(
|
||||||
|
[run({})],
|
||||||
|
[
|
||||||
|
{ id: "part1", sourceFileIds: ["out"] },
|
||||||
|
{ id: "part2", sourceFileIds: ["out"] },
|
||||||
|
],
|
||||||
|
labels,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect((map.get("part1") ?? []).map((b) => b.id)).toEqual(["security"]);
|
||||||
|
expect((map.get("part2") ?? []).map((b) => b.id)).toEqual(["security"]);
|
||||||
|
expect((map.get("part1") ?? [])[0].recent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves transitively when an intermediate edit was consumed/removed", () => {
|
||||||
|
// redact → edit (consumed) → split. The split part's sourceFileIds carries
|
||||||
|
// the original output id directly, so the badge still resolves.
|
||||||
|
const map = buildPolicyBadgeMap(
|
||||||
|
[run({})],
|
||||||
|
[{ id: "part", sourceFileIds: ["editGone", "out"] }],
|
||||||
|
labels,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect((map.get("part") ?? []).map((b) => b.id)).toEqual(["security"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MERGE output inherits every input's badge", () => {
|
||||||
|
const map = buildPolicyBadgeMap(
|
||||||
|
[
|
||||||
|
run({ runId: "r1", categoryId: "security", outputFileIds: ["a"] }),
|
||||||
|
run({ runId: "r2", categoryId: "watermark", outputFileIds: ["b"] }),
|
||||||
|
],
|
||||||
|
[{ id: "merged", sourceFileIds: ["a", "b"] }],
|
||||||
|
labels,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect((map.get("merged") ?? []).map((b) => b.id).sort()).toEqual([
|
||||||
|
"security",
|
||||||
|
"watermark",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a file with no policy provenance gets no badge", () => {
|
||||||
|
const map = buildPolicyBadgeMap(
|
||||||
|
[run({})],
|
||||||
|
[{ id: "out" }, { id: "unrelated", sourceFileIds: ["someUpload"] }],
|
||||||
|
labels,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(map.has("unrelated")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inherited badges never glow even when the source run is recent", () => {
|
||||||
|
const map = buildPolicyBadgeMap(
|
||||||
|
[run({ startedAt: NOW })], // maximally recent
|
||||||
|
[{ id: "out" }, { id: "part", sourceFileIds: ["out"] }],
|
||||||
|
labels,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect((map.get("out") ?? [])[0].recent).toBe(true);
|
||||||
|
expect((map.get("part") ?? [])[0].recent).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
|
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
|
||||||
|
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
|
||||||
|
import { useAllFiles } from "@app/contexts/FileContext";
|
||||||
import { loadPolicyCatalog } from "@app/services/policyCatalog";
|
import { loadPolicyCatalog } from "@app/services/policyCatalog";
|
||||||
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
||||||
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
|
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
|
||||||
@@ -18,27 +20,53 @@ const ACCENT_VAR: Record<string, string> = {
|
|||||||
red: "var(--color-red)",
|
red: "var(--color-red)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Minimal provenance shape needed to resolve a file's inherited badges. */
|
||||||
|
type LineageStub = {
|
||||||
|
id: string;
|
||||||
|
parentFileId?: string;
|
||||||
|
sourceFileIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Merge a ref into a list, deduping by policy id. A direct (recent) hit wins
|
||||||
|
* the glow over an inherited one for the same policy. */
|
||||||
|
function mergeRef(list: FileItemPolicyRef[], ref: FileItemPolicyRef): void {
|
||||||
|
const existing = list.find((p) => p.id === ref.id);
|
||||||
|
if (!existing) {
|
||||||
|
list.push(ref);
|
||||||
|
} else if (ref.recent) {
|
||||||
|
existing.recent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distinct policies that have produced each file, keyed by fileId, derived from
|
* Pure core of {@link usePolicyFileBadges} (no React/storage deps, so it's
|
||||||
* the reactive policy run store. Drives the file sidebar's shield badges. The
|
* unit-testable). Returns the policies that have run on each file, keyed by
|
||||||
* badge marks a policy's OUTPUT (the versioned/added result), not the input it
|
* fileId.
|
||||||
* ran on — so it keys off each run's imported output fileIds. Shadows the core
|
*
|
||||||
* stub via the {@code @app/*} alias cascade.
|
* A policy run is pinned to a specific output fileId, so a later tool edit
|
||||||
|
* produces a NEW file that wouldn't carry the badge — "been through a policy"
|
||||||
|
* would vanish the moment you edit the file. To keep the badge with the
|
||||||
|
* document, every file also INHERITS the badges of the files it was derived
|
||||||
|
* from: its transitive `sourceFileIds` (recorded at the consume boundary, so it
|
||||||
|
* covers split/merge/convert too) plus, defensively, its `parentFileId`.
|
||||||
|
* Because `sourceFileIds` is transitive, a flat lookup suffices — no chain walk,
|
||||||
|
* and it survives a consumed intermediate. Inherited badges never glow
|
||||||
|
* (recent=false): only the original application does.
|
||||||
*/
|
*/
|
||||||
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
export function buildPolicyBadgeMap(
|
||||||
const runs = usePolicyRuns();
|
runs: ReadonlyArray<PolicyRunRecord>,
|
||||||
return useMemo(() => {
|
stubs: ReadonlyArray<LineageStub>,
|
||||||
const labelById = new Map(
|
labelById: ReadonlyMap<string, string>,
|
||||||
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
|
now: number,
|
||||||
);
|
): Map<string, FileItemPolicyRef[]> {
|
||||||
const now = Date.now();
|
// Direct badges: a file that IS a policy run's output.
|
||||||
const byFile = new Map<string, FileItemPolicyRef[]>();
|
const directByFile = new Map<string, FileItemPolicyRef[]>();
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
const name = labelById.get(run.categoryId);
|
const name = labelById.get(run.categoryId);
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const recent = now - run.startedAt < RECENT_MS;
|
const recent = now - run.startedAt < RECENT_MS;
|
||||||
for (const fileId of run.outputFileIds ?? []) {
|
for (const fileId of run.outputFileIds ?? []) {
|
||||||
const list = byFile.get(fileId) ?? [];
|
const list = directByFile.get(fileId) ?? [];
|
||||||
if (!list.some((p) => p.id === run.categoryId)) {
|
if (!list.some((p) => p.id === run.categoryId)) {
|
||||||
list.push({
|
list.push({
|
||||||
id: run.categoryId,
|
id: run.categoryId,
|
||||||
@@ -46,10 +74,56 @@ export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
|||||||
accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
|
accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
|
||||||
recent,
|
recent,
|
||||||
});
|
});
|
||||||
byFile.set(fileId, list);
|
directByFile.set(fileId, list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return byFile;
|
|
||||||
}, [runs]);
|
// Seed the result with the direct badges (cloning the arrays so the lineage
|
||||||
|
// pass can mutate freely without touching the direct map).
|
||||||
|
const result = new Map<string, FileItemPolicyRef[]>();
|
||||||
|
for (const [id, refs] of directByFile) {
|
||||||
|
if (refs.length)
|
||||||
|
result.set(
|
||||||
|
id,
|
||||||
|
refs.map((r) => ({ ...r })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inheritance pass: a derived file carries the badges of every file it came
|
||||||
|
// from. `sourceFileIds` is the transitive provenance set (so a flat lookup
|
||||||
|
// catches even ancestors whose intermediate edits were consumed), and
|
||||||
|
// `parentFileId` is included defensively for any child not created via a
|
||||||
|
// consume. Inherited badges are marked recent=false (carried, not applied).
|
||||||
|
for (const stub of stubs) {
|
||||||
|
const sources = new Set<string>(stub.sourceFileIds ?? []);
|
||||||
|
if (stub.parentFileId) sources.add(stub.parentFileId);
|
||||||
|
for (const src of sources) {
|
||||||
|
const srcBadges = directByFile.get(src);
|
||||||
|
if (!srcBadges?.length) continue;
|
||||||
|
const list = result.get(stub.id) ?? [];
|
||||||
|
for (const ref of srcBadges) mergeRef(list, { ...ref, recent: false });
|
||||||
|
result.set(stub.id, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct policies that have produced each file, keyed by fileId, derived from
|
||||||
|
* the reactive policy run store. Drives the file sidebar's shield badges. The
|
||||||
|
* badge follows a document down its tool-edit chain — see
|
||||||
|
* {@link buildPolicyBadgeMap}. Shadows the core stub via the {@code @app/*}
|
||||||
|
* alias cascade.
|
||||||
|
*/
|
||||||
|
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
||||||
|
const runs = usePolicyRuns();
|
||||||
|
const { fileStubs } = useAllFiles();
|
||||||
|
return useMemo(() => {
|
||||||
|
const labelById = new Map(
|
||||||
|
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
|
||||||
|
);
|
||||||
|
return buildPolicyBadgeMap(runs, fileStubs, labelById, Date.now());
|
||||||
|
}, [runs, fileStubs]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user