filegrc 0.12.1 → 0.12.3
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/model/v10.json +67 -1
- package/package.json +1 -1
- package/src/cli.js +16 -4
- package/src/files.js +14 -2
- package/src/git.js +398 -65
- package/src/index.js +1 -1
- package/src/obligations.js +1 -1
- package/src/reconciliation.js +799 -149
- package/src/server.js +10 -1
- package/src/state.js +9 -0
- package/src/validate.js +45 -7
- package/src/web.js +59 -2
- package/src/workflow-history-integrity.js +31 -0
package/src/server.js
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
} from "./external-reviewer.js";
|
|
58
58
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
59
59
|
import { activatePolicies } from "./policy-activation.js";
|
|
60
|
-
import { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
60
|
+
import { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
|
|
61
61
|
import { resourceReviewRevisions } from "./retention.js";
|
|
62
62
|
import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail } from "./state.js";
|
|
63
63
|
import { setupWorkspace } from "./setup.js";
|
|
@@ -190,6 +190,15 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
190
190
|
confirmed: payload.confirmed === true
|
|
191
191
|
})));
|
|
192
192
|
}
|
|
193
|
+
if (request.method === "POST" && url.pathname === "/api/reconciliation/dismissal") {
|
|
194
|
+
const payload = await readJson(request);
|
|
195
|
+
return json(response, 201, await browserMutation(input, requestOptions, {
|
|
196
|
+
message: (result) => `Dismiss Git transition candidate: ${result.candidate?.eventType || payload.candidateId}`
|
|
197
|
+
}, () => dismissReconciliation(input, {
|
|
198
|
+
...payload,
|
|
199
|
+
confirmed: payload.confirmed === true
|
|
200
|
+
})));
|
|
201
|
+
}
|
|
193
202
|
if (request.method === "POST" && url.pathname === "/api/external-reviewer-governance/preview") {
|
|
194
203
|
return json(response, 200, await planExternalReviewerGovernance(input, await readJson(request)));
|
|
195
204
|
}
|
package/src/state.js
CHANGED
|
@@ -5,6 +5,7 @@ import { assessCollectionReviews } from "./collection-review.js";
|
|
|
5
5
|
import { getBrowserRepositoryState, getRepositorySnapshot, getWorkspaceHistories } from "./git.js";
|
|
6
6
|
import { renderMarkdown } from "./markdown.js";
|
|
7
7
|
import { planObligations } from "./obligations.js";
|
|
8
|
+
import { planReconciliation } from "./reconciliation.js";
|
|
8
9
|
import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
9
10
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
10
11
|
import { markdownEntries } from "./resource-markdown.js";
|
|
@@ -82,6 +83,7 @@ export async function createAppBootstrap(input = process.cwd(), options = {}) {
|
|
|
82
83
|
counts: { errors: 0, warnings: 0 },
|
|
83
84
|
diagnostics: []
|
|
84
85
|
},
|
|
86
|
+
reconciliation: { contractVersion: 1, gitRevision: null, changedPaths: [], candidates: [] },
|
|
85
87
|
obligations: { loading: true, items: [], triggers: [], counts: {} },
|
|
86
88
|
collectionReviews: {},
|
|
87
89
|
applicabilityConstraints: {},
|
|
@@ -116,6 +118,8 @@ export async function createAppStateSection(input, section, options = {}) {
|
|
|
116
118
|
validateWorkspace(loaded),
|
|
117
119
|
measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root))
|
|
118
120
|
]);
|
|
121
|
+
const reconciliation = validation.reconciliation
|
|
122
|
+
?? await measureTiming("state-reconciliation", () => planReconciliation(loaded));
|
|
119
123
|
const git = { ...snapshot };
|
|
120
124
|
delete git.root;
|
|
121
125
|
const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded, {
|
|
@@ -128,6 +132,7 @@ export async function createAppStateSection(input, section, options = {}) {
|
|
|
128
132
|
readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
|
|
129
133
|
repository,
|
|
130
134
|
git,
|
|
135
|
+
reconciliation,
|
|
131
136
|
validation: {
|
|
132
137
|
ok: validation.ok,
|
|
133
138
|
counts: validation.counts,
|
|
@@ -294,6 +299,8 @@ async function createAppStateUnlocked(input, options) {
|
|
|
294
299
|
now: options.now ?? generatedAt,
|
|
295
300
|
model: loaded.model
|
|
296
301
|
});
|
|
302
|
+
const reconciliation = validation.reconciliation
|
|
303
|
+
?? await measureTiming("state-reconciliation", () => planReconciliation(loaded));
|
|
297
304
|
const workflow = await measureTiming("state-workflow", () => assessWorkflow(loaded, {
|
|
298
305
|
programId: activeProgram.id,
|
|
299
306
|
asOf,
|
|
@@ -303,6 +310,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
303
310
|
Object.entries(auditPreparations).filter(([id]) => id !== "none")
|
|
304
311
|
),
|
|
305
312
|
obligations,
|
|
313
|
+
reconciliation,
|
|
306
314
|
git,
|
|
307
315
|
validation
|
|
308
316
|
}));
|
|
@@ -337,6 +345,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
337
345
|
diagnostics: validation.diagnostics
|
|
338
346
|
},
|
|
339
347
|
obligations,
|
|
348
|
+
reconciliation,
|
|
340
349
|
collectionReviews,
|
|
341
350
|
applicabilityConstraints,
|
|
342
351
|
programReadiness,
|
package/src/validate.js
CHANGED
|
@@ -62,6 +62,7 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
62
62
|
async function validateWorkspaceUnmeasured(input) {
|
|
63
63
|
const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
|
|
64
64
|
const diagnostics = [...loaded.diagnostics];
|
|
65
|
+
let reconciliation = null;
|
|
65
66
|
const { byId } = indexResources(loaded.resources);
|
|
66
67
|
const seen = new Map();
|
|
67
68
|
const pathById = new Map(loaded.entries.map((entry) => [
|
|
@@ -216,14 +217,30 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
216
217
|
validateCollectionReviewSet(loaded.resources, pathById, diagnostics);
|
|
217
218
|
}
|
|
218
219
|
if (modelSupports(loaded.model, "guided-workflow")) {
|
|
219
|
-
const { planReconciliation } = await import("./reconciliation.js");
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
220
|
+
const { planReconciliation, validateReconciliationDismissals } = await import("./reconciliation.js");
|
|
221
|
+
try {
|
|
222
|
+
const rawReconciliation = await validateReconciliationDismissals(loaded, diagnostics);
|
|
223
|
+
reconciliation = rawReconciliation?.filteredPlan ?? await planReconciliation(loaded);
|
|
224
|
+
for (const candidate of reconciliation.candidates.filter(({ committedRevision }) => committedRevision)) {
|
|
225
|
+
diagnostics.push(warning(
|
|
226
|
+
"unreconciled-committed-transition",
|
|
227
|
+
candidate.sourcePath,
|
|
228
|
+
`${candidate.eventType} transition in Git commit ${candidate.committedRevision.slice(0, 8)} still needs confirmation or dismissal.`
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
} catch (cause) {
|
|
232
|
+
diagnostics.push(error(
|
|
233
|
+
"reconciliation-history-unavailable",
|
|
234
|
+
"data/workspace.json",
|
|
235
|
+
cause instanceof Error ? cause.message : "Git history is unavailable for reconciliation."
|
|
226
236
|
));
|
|
237
|
+
reconciliation = {
|
|
238
|
+
contractVersion: 1,
|
|
239
|
+
gitRevision: null,
|
|
240
|
+
changedPaths: [],
|
|
241
|
+
candidates: [],
|
|
242
|
+
unavailable: true
|
|
243
|
+
};
|
|
227
244
|
}
|
|
228
245
|
}
|
|
229
246
|
|
|
@@ -241,6 +258,10 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
241
258
|
value: loaded,
|
|
242
259
|
enumerable: false
|
|
243
260
|
});
|
|
261
|
+
Object.defineProperty(result, "reconciliation", {
|
|
262
|
+
value: reconciliation,
|
|
263
|
+
enumerable: false
|
|
264
|
+
});
|
|
244
265
|
return result;
|
|
245
266
|
}
|
|
246
267
|
|
|
@@ -2128,6 +2149,23 @@ function validateLocation(record, definition, relativePath, diagnostics) {
|
|
|
2128
2149
|
}
|
|
2129
2150
|
}
|
|
2130
2151
|
|
|
2152
|
+
export function assertHistoricalRecordValid(record, model, relativePath) {
|
|
2153
|
+
const displayPath = `data/${relativePath}`;
|
|
2154
|
+
const diagnostics = [];
|
|
2155
|
+
let definition;
|
|
2156
|
+
try {
|
|
2157
|
+
definition = getResourceDefinition(model, record?.type);
|
|
2158
|
+
} catch {
|
|
2159
|
+
throw new Error(`Unknown historical resource type "${record?.type ?? ""}" at ${displayPath}.`);
|
|
2160
|
+
}
|
|
2161
|
+
validateLocation(record, definition, relativePath, diagnostics);
|
|
2162
|
+
validateRecord(record, definition, model, displayPath, diagnostics);
|
|
2163
|
+
validateDateRanges(record, displayPath, diagnostics);
|
|
2164
|
+
if (diagnostics.some(({ severity }) => severity === "error")) {
|
|
2165
|
+
throw new Error(`Invalid historical resource at ${displayPath}: ${diagnostics.map(({ message }) => message).join(" ")}`);
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2131
2169
|
function validateRecord(record, definition, model, path, diagnostics) {
|
|
2132
2170
|
const fields = { ...model.commonFields, ...definition.fields };
|
|
2133
2171
|
const required = new Set([
|
package/src/web.js
CHANGED
|
@@ -3121,10 +3121,11 @@ function renderRepository(main) {
|
|
|
3121
3121
|
const validationBody = state.validation.diagnostics.length
|
|
3122
3122
|
? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
|
|
3123
3123
|
: empty("No validation problems.");
|
|
3124
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>' + repositoryInstructions + ' Git supplies authors, timestamps, messages, revisions, and file history.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + pullButton + commitButton + pushButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Current Revision</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(state.git.upstream || "Not configured") + '</dd></div><div><dt>Remotes</dt><dd>' + esc(state.git.remotes?.join(", ") || "None configured") + '</dd></div><div><dt>Commit</dt><dd><code>' + esc(state.git.commit || "Unavailable") + '</code></dd></div><div><dt>Working tree</dt><dd>' + (state.git.clean === null ? "Unavailable" : state.git.clean ? "Clean" : "Has changes") + '</dd></div><div><dt>Generated</dt><dd>' + esc(formatLocalDateTime(state.generatedAt)) + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Uncommitted Changes</h3></div>' + (state.git.changes?.length ? '<ul class="changes">' + state.git.changes.map((change) => '<li><code>' + esc(change) + '</code></li>').join("") + '</ul>' : empty(state.git.available ? "No uncommitted changes." : state.git.message)) + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section
|
|
3124
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>' + repositoryInstructions + ' Git supplies authors, timestamps, messages, revisions, and file history.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + pullButton + commitButton + pushButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Current Revision</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(state.git.upstream || "Not configured") + '</dd></div><div><dt>Remotes</dt><dd>' + esc(state.git.remotes?.join(", ") || "None configured") + '</dd></div><div><dt>Commit</dt><dd><code>' + esc(state.git.commit || "Unavailable") + '</code></dd></div><div><dt>Working tree</dt><dd>' + (state.git.clean === null ? "Unavailable" : state.git.clean ? "Clean" : "Has changes") + '</dd></div><div><dt>Generated</dt><dd>' + esc(formatLocalDateTime(state.generatedAt)) + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Uncommitted Changes</h3></div>' + (state.git.changes?.length ? '<ul class="changes">' + state.git.changes.map((change) => '<li><code>' + esc(change) + '</code></li>').join("") + '</ul>' : empty(state.git.available ? "No uncommitted changes." : state.git.message)) + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section>' + reconciliationPanel() + '</div></div>';
|
|
3125
3125
|
main.querySelector("#commit-workspace")?.addEventListener("click", openCommitDialog);
|
|
3126
3126
|
main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
|
|
3127
3127
|
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
3128
|
+
bindReconciliationDismissals(main);
|
|
3128
3129
|
}
|
|
3129
3130
|
|
|
3130
3131
|
function renderTrunkRepository(main) {
|
|
@@ -3148,9 +3149,63 @@ function renderTrunkRepository(main) {
|
|
|
3148
3149
|
const validationBody = state.validation.diagnostics.length
|
|
3149
3150
|
? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
|
|
3150
3151
|
: empty("No validation problems.");
|
|
3151
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section
|
|
3152
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section>' + reconciliationPanel() + '</div></div>';
|
|
3152
3153
|
main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
|
|
3153
3154
|
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
3155
|
+
bindReconciliationDismissals(main);
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
function reconciliationPanel() {
|
|
3159
|
+
const candidates = state.reconciliation?.candidates || [];
|
|
3160
|
+
if (!candidates.length) return "";
|
|
3161
|
+
const items = candidates.map((candidate) => '<article class="reconciliation-candidate"><div><span class="badge warn">Review</span><strong>' + esc(titleCase(candidate.eventType)) + '</strong><p>' + esc(candidate.message) + '</p><small><code>' + esc(candidate.sourcePath) + '</code> · fingerprint <code>' + esc(candidate.transitionFingerprint) + '</code></small></div><div class="reconciliation-actions"><button class="button" type="button" data-dismiss-reconciliation="' + esc(candidate.transitionFingerprint) + '" ' + (state.readOnly ? 'disabled title="Writes are not available in this repository state"' : "") + '>Dismiss false positive</button><code>' + esc(candidate.action.command) + '</code></div></article>').join("");
|
|
3162
|
+
return '<section class="panel span-2 reconciliation-panel"><div class="panel-head"><div><p class="kicker">Git transition review</p><h3>Transitions Need Confirmation</h3></div><span class="badge warn">' + candidates.length + '</span></div><p>Confirm real events with the shown command. Dismiss only when the exact Git change does not represent a real event.</p><div class="reconciliation-candidates">' + items + '</div></section>';
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
function bindReconciliationDismissals(main) {
|
|
3166
|
+
main.querySelectorAll("[data-dismiss-reconciliation]").forEach((button) => button.addEventListener("click", () => {
|
|
3167
|
+
const candidate = state.reconciliation?.candidates?.find((item) => item.transitionFingerprint === button.dataset.dismissReconciliation);
|
|
3168
|
+
if (candidate) openReconciliationDismissal(candidate);
|
|
3169
|
+
}));
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
function openReconciliationDismissal(candidate) {
|
|
3173
|
+
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
3174
|
+
const dialog = document.createElement("dialog");
|
|
3175
|
+
dialog.className = "commit-dialog event-dialog";
|
|
3176
|
+
dialog.setAttribute("aria-labelledby", "reconciliation-dismissal-title");
|
|
3177
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">False-positive review</p><h2 id="reconciliation-dismissal-title">Dismiss ' + esc(titleCase(candidate.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>This decision applies only to fingerprint <code>' + esc(candidate.transitionFingerprint) + '</code>. A later change creates a new candidate.</p><label><span>Reviewer</span><select name="reviewedById" required><option value="">Select a Person</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label><span>Rationale</span><textarea name="rationale" rows="4" required placeholder="Explain why this Git change does not represent a real event."></textarea></label><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-dismiss-dialog>Cancel</button><button type="submit" class="button primary" id="save-reconciliation-dismissal">Record dismissal</button></div></form>';
|
|
3178
|
+
document.body.append(dialog);
|
|
3179
|
+
dialog.showModal();
|
|
3180
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
3181
|
+
dialog.querySelector("[data-dismiss-dialog]").addEventListener("click", () => dialog.close());
|
|
3182
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
3183
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
3184
|
+
event.preventDefault();
|
|
3185
|
+
const form = event.currentTarget;
|
|
3186
|
+
if (!form.reportValidity()) return;
|
|
3187
|
+
try {
|
|
3188
|
+
setMutationBusy(dialog, true, "Recording…", "Record dismissal");
|
|
3189
|
+
const response = await localFetch("/api/reconciliation/dismissal", {
|
|
3190
|
+
method: "POST",
|
|
3191
|
+
headers: { "content-type": "application/json" },
|
|
3192
|
+
body: JSON.stringify({
|
|
3193
|
+
candidateId: candidate.transitionFingerprint,
|
|
3194
|
+
reviewedById: form.elements.reviewedById.value,
|
|
3195
|
+
reviewedOn: form.elements.reviewedOn.value,
|
|
3196
|
+
rationale: form.elements.rationale.value,
|
|
3197
|
+
confirmed: true
|
|
3198
|
+
})
|
|
3199
|
+
});
|
|
3200
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
3201
|
+
applyMutationState(await response.json());
|
|
3202
|
+
dialog.close();
|
|
3203
|
+
render();
|
|
3204
|
+
} catch (error) {
|
|
3205
|
+
setMutationBusy(dialog, false, "", "Record dismissal");
|
|
3206
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
3207
|
+
}
|
|
3208
|
+
});
|
|
3154
3209
|
}
|
|
3155
3210
|
|
|
3156
3211
|
async function runRepositoryGitAction(action) {
|
|
@@ -5576,6 +5631,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
5576
5631
|
.page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.page-guide>.guide-review{grid-column:1/-1;border-top:1px solid var(--line);border-left:0}.guide-review ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:0;padding-left:18px;color:var(--muted);font-size:11px;line-height:1.45}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
|
|
5577
5632
|
.operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
|
|
5578
5633
|
.page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-sync-alert{display:flex;align-items:flex-start;gap:9px;padding:10px 22px;border-bottom:1px solid #e9c888;background:#fff8e8;color:var(--ink);font-size:12px;line-height:1.45}.repository-sync-alert.syncing{border-color:var(--line);background:var(--surface-soft)}.repository-sync-alert .status-dot{flex:0 0 auto;margin-top:4px}.repository-sync-alert a{margin-left:auto;white-space:nowrap}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;flex-wrap:wrap;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
|
|
5634
|
+
.reconciliation-panel>p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidates{display:grid;gap:9px}.reconciliation-candidate{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);gap:16px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.reconciliation-candidate strong{margin-left:8px;font-size:13.2px}.reconciliation-candidate p{margin:7px 0;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidate small{display:block;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px}.reconciliation-actions{display:grid;gap:8px;justify-items:end;align-content:center}.reconciliation-actions>code{max-width:100%;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px;text-align:right}
|
|
5579
5635
|
.onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
|
|
5580
5636
|
.save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
|
|
5581
5637
|
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
|
|
@@ -5588,6 +5644,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
5588
5644
|
@media(max-width:760px){.setup-banner,.page-guide,.stage-overview-hero,.relationship-note,.group-destination-card,.stage-page-grid,.evidence-map-expectation,.evidence-map-links,.external-evidence-list,.policy-lifecycle-note{grid-template-columns:1fr}.context-workflow,.evidence-map-head{align-items:stretch;flex-direction:column}.evidence-map-actions{flex-wrap:wrap}.page-guide>div{border-left:0;border-top:1px solid var(--line)}.page-guide>div:first-child{border-top:0}.group-overview-head{display:block}.stage-progress-card,.stage-status-link{margin-top:15px}.destination-rollup{text-align:left}.form-grid{grid-template-columns:1fr}.record-table{min-width:0}.record-table thead{display:none}.record-table,.record-table tbody,.record-table tr{display:block}.record-table tr{padding:8px 12px;border-bottom:1px solid var(--line)}.record-table tr:last-child{border-bottom:0}.record-table td:not([data-label]){display:block}.record-table td[data-label]{display:grid;grid-template-columns:105px minmax(0,1fr);gap:10px;border:0;padding:7px 0;align-items:start}.record-table td[data-label]::before{content:attr(data-label);color:#75817b;text-transform:uppercase;letter-spacing:.07em;font-size:9.6px;font-weight:700}.record-table td[data-primary-field]{display:block;padding:8px 0 10px}.record-table td[data-primary-field]::before{display:none}.content-label{align-items:flex-start}.editor form{padding:18px}.diagnostics>div{grid-template-columns:58px minmax(0,1fr)}.diagnostics p{grid-column:1/-1}.changes code{overflow-wrap:anywhere}.onboarding-dialog{max-height:56vh}}
|
|
5589
5645
|
.detail-grid aside .panel{width:100%;max-height:min(520px,65vh);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.detail-grid aside .workflow-guidance,.detail-grid aside .resource-review-criteria{margin:0}.detail-grid aside .resource-review-criteria{padding:21px}.detail-grid aside .detail-workflow-panel{max-height:min(440px,60vh)}.detail-grid aside .detail-review-panel{max-height:min(300px,50vh)}.detail-grid aside .workflow-findings,.detail-grid aside .resource-review-criteria ul{grid-template-columns:1fr}.detail-support-stack{display:grid;min-width:0;gap:14px;align-content:start}
|
|
5590
5646
|
@media(max-width:760px){.detail-grid aside{order:-1}}
|
|
5647
|
+
@media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
|
|
5591
5648
|
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}.detail-grid-structured .detail-history-panel{order:1}}
|
|
5592
5649
|
@media(max-width:760px){.guide-review ul,.resource-review-criteria ul{grid-template-columns:1fr}.collection-review-head,.collection-review-foot{align-items:stretch;flex-direction:column}.record-workflow-action{min-width:0}}
|
|
5593
5650
|
@media(max-width:520px){.onboarding-form,.onboarding-sections,.setup-steps{grid-template-columns:1fr}.onboarding-form label.wide{grid-column:auto}.onboarding-actions{flex-wrap:wrap}.onboarding-skip{width:100%;order:3;margin:3px 0 0}.readiness-flow{grid-template-columns:1fr}.obligation-card-foot{align-items:flex-start;flex-direction:column}.obligation-action{align-self:flex-start}}
|
|
@@ -15,6 +15,7 @@ import { currentCalendarDate, isRfc3339Timestamp, timestampFromLocalDateTime } f
|
|
|
15
15
|
import { recordsAtRevision, reportingRouteFixedEvidence } from "./reporting-route-integrity.js";
|
|
16
16
|
|
|
17
17
|
const FINAL_STATUSES = new Map([
|
|
18
|
+
["reconciliation-dismissal", new Set([undefined])],
|
|
18
19
|
["collection-review", new Set(["active", "retired"])],
|
|
19
20
|
["obligation-rule", new Set(["active", "retired"])],
|
|
20
21
|
["obligation-occurrence", new Set(["reconciled", "superseded"])],
|
|
@@ -96,6 +97,21 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
|
|
|
96
97
|
));
|
|
97
98
|
continue;
|
|
98
99
|
}
|
|
100
|
+
if (
|
|
101
|
+
entry.record.type === "reconciliation-dismissal"
|
|
102
|
+
&& !validInitialReconciliationDismissal(
|
|
103
|
+
historical,
|
|
104
|
+
loaded,
|
|
105
|
+
historiesById,
|
|
106
|
+
workspaceTimezoneAtRevision(loaded, historical.commit)
|
|
107
|
+
)
|
|
108
|
+
) {
|
|
109
|
+
diagnostics.push(integrityError(
|
|
110
|
+
entry,
|
|
111
|
+
`Reconciliation dismissal "${entry.record.id}" must first be committed with exactly one active Person reviewer.`
|
|
112
|
+
));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
99
115
|
if (
|
|
100
116
|
entry.record.type === "exception"
|
|
101
117
|
&& historical.record.reportingRouteSetId
|
|
@@ -354,6 +370,21 @@ function validInitialCollectionReview(historical, timezone) {
|
|
|
354
370
|
);
|
|
355
371
|
}
|
|
356
372
|
|
|
373
|
+
function validInitialReconciliationDismissal(historical, loaded, historiesById, timezone) {
|
|
374
|
+
const reviewerIds = historical.record.reviewedByIds || [];
|
|
375
|
+
if (reviewerIds.length !== 1) return false;
|
|
376
|
+
const reviewer = recordAtRevision(
|
|
377
|
+
loaded,
|
|
378
|
+
historical.commit,
|
|
379
|
+
reviewerIds[0],
|
|
380
|
+
historiesById.get(reviewerIds[0]) || []
|
|
381
|
+
)?.record;
|
|
382
|
+
return reviewer?.type === "person"
|
|
383
|
+
&& reviewer.status === "active"
|
|
384
|
+
&& isRfc3339Timestamp(historical.timestamp)
|
|
385
|
+
&& historical.record.reviewedOn === currentCalendarDate(timezone, new Date(historical.timestamp));
|
|
386
|
+
}
|
|
387
|
+
|
|
357
388
|
function legacyCollectionReview(loaded, historical) {
|
|
358
389
|
if (historical.legacyBaseline && historical.record.status === "active" && !historical.record.knowledgeCutoffAt) {
|
|
359
390
|
return true;
|