opencode-workflow-guard 1.15.0 → 1.15.2
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/docs/policies.md +2 -2
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +58 -18
- package/src/lib/guard-dispatcher.ts +31 -3
- package/src/lib/state.ts +118 -49
- package/src/lib/v2-plugin.ts +10 -10
- package/src/policies/boundary.ts +9 -2
- package/src/policies/changelog.ts +112 -11
- package/src/policies/git.ts +134 -11
- package/src/workflow-guard-ui.ts +1 -2
- package/src/workflow-guard.ts +14 -8
- package/test/test.mts +254 -9
|
@@ -193,16 +193,114 @@ export function prBodyHasLiteralLineBreakEscapes(command: string): boolean {
|
|
|
193
193
|
return false;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Top-level package.json sections whose change requires a matching lockfile
|
|
198
|
+
* update: the dependency maps (including peerDependenciesMeta) plus every
|
|
199
|
+
* other section a supported package manager records in its lockfile
|
|
200
|
+
* (engines/packageManager, overrides/resolutions, the pnpm section with
|
|
201
|
+
* pnpm.overrides and pnpm.patchedDependencies, the workspace graph, and the
|
|
202
|
+
* project name/version stored in the lockfile root). A scripts-only or
|
|
203
|
+
* metadata-only manifest edit regenerates the lockfile byte-identical —
|
|
204
|
+
* demanding a lockfile change for it is unsatisfiable and must not block PR
|
|
205
|
+
* creation.
|
|
206
|
+
*/
|
|
207
|
+
const LOCKFILE_RELEVANT_MANIFEST_KEYS = new Set([
|
|
208
|
+
"name",
|
|
209
|
+
"version",
|
|
210
|
+
"dependencies",
|
|
211
|
+
"devDependencies",
|
|
212
|
+
"optionalDependencies",
|
|
213
|
+
"peerDependencies",
|
|
214
|
+
"peerDependenciesMeta",
|
|
215
|
+
"engines",
|
|
216
|
+
"packageManager",
|
|
217
|
+
"overrides",
|
|
218
|
+
"resolutions",
|
|
219
|
+
"workspaces",
|
|
220
|
+
// pnpm-specific top-level section: pnpm.overrides and
|
|
221
|
+
// pnpm.patchedDependencies are recorded in pnpm-lock.yaml.
|
|
222
|
+
"pnpm",
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
/** Order-insensitive JSON comparison: dependency maps are semantically
|
|
226
|
+
* unordered, so reordering keys alone is not a lockfile-relevant change. */
|
|
227
|
+
function canonicalJsonValue(value: unknown): string {
|
|
228
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
|
|
229
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJsonValue).join(",")}]`;
|
|
230
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
231
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJsonValue(item)}`).join(",")}}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function manifestTouchesLockfileRelevantKeys(oldSource: string | undefined, newSource: string | undefined): boolean {
|
|
235
|
+
try {
|
|
236
|
+
const parse = (source: string | undefined): Record<string, unknown> | undefined => {
|
|
237
|
+
if (source === undefined) return undefined;
|
|
238
|
+
const parsed = JSON.parse(source) as unknown;
|
|
239
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a manifest object");
|
|
240
|
+
return parsed as Record<string, unknown>;
|
|
241
|
+
};
|
|
242
|
+
const oldManifest = parse(oldSource);
|
|
243
|
+
const newManifest = parse(newSource);
|
|
244
|
+
const keys = new Set([...Object.keys(oldManifest ?? {}), ...Object.keys(newManifest ?? {})]);
|
|
245
|
+
for (const key of keys) {
|
|
246
|
+
if (!LOCKFILE_RELEVANT_MANIFEST_KEYS.has(key)) continue;
|
|
247
|
+
if (canonicalJsonValue(oldManifest?.[key]) !== canonicalJsonValue(newManifest?.[key])) return true;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
} catch {
|
|
251
|
+
return true; // unreadable manifest: fail closed and keep demanding the lockfile
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function manifestSourceAt(root: string, revision: string, manifestPath: string): string | undefined {
|
|
256
|
+
const res = spawnSync("git", ["show", `${revision}:${manifestPath}`], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
257
|
+
if (res.status !== 0) return undefined;
|
|
258
|
+
return res.stdout;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function workingTreeManifestSource(root: string, manifestPath: string): string | undefined {
|
|
262
|
+
try {
|
|
263
|
+
return readFileSync(resolve(root, manifestPath), "utf8");
|
|
264
|
+
} catch {
|
|
265
|
+
return undefined; // deleted from the working tree
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Whether a manifest change demands a lockfile update. package.json is
|
|
271
|
+
* compared section-by-section (scripts-only edits pass); Cargo.toml and
|
|
272
|
+
* go.mod have no dependency-section parser here, so any change to them
|
|
273
|
+
* conservatively demands the lockfile (unchanged behavior).
|
|
274
|
+
*/
|
|
275
|
+
function manifestChangeRequiresLockfile(root: string, mergeBaseCommit: string | undefined, manifestPath: string): boolean {
|
|
276
|
+
if (!/(?:^|\/)package\.json$/.test(manifestPath)) return true;
|
|
277
|
+
let oldSource: string | undefined;
|
|
278
|
+
if (mergeBaseCommit) {
|
|
279
|
+
// Absent at the merge base means the manifest was introduced on this
|
|
280
|
+
// branch (or is untracked): the pre-branch state had no manifest, so
|
|
281
|
+
// every lockfile-relevant key it has counts as new.
|
|
282
|
+
oldSource = manifestSourceAt(root, mergeBaseCommit, manifestPath);
|
|
283
|
+
} else {
|
|
284
|
+
oldSource = manifestSourceAt(root, "HEAD", manifestPath);
|
|
285
|
+
}
|
|
286
|
+
const newSource = workingTreeManifestSource(root, manifestPath);
|
|
287
|
+
if (oldSource === undefined && newSource === undefined) return true;
|
|
288
|
+
return manifestTouchesLockfileRelevantKeys(oldSource, newSource);
|
|
289
|
+
}
|
|
290
|
+
|
|
196
291
|
export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifest?: string; lockfile?: string; reason?: string } {
|
|
197
292
|
try {
|
|
198
293
|
let modifiedFiles: string[] = [];
|
|
294
|
+
let mergeBaseCommit: string | undefined;
|
|
199
295
|
const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
|
|
200
296
|
for (const base of baseCandidates) {
|
|
201
297
|
const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
202
298
|
if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
|
|
203
|
-
const
|
|
299
|
+
const baseCommit = mergeBase.stdout.trim();
|
|
300
|
+
const diff = spawnSync("git", ["diff", "--name-only", `${baseCommit}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
204
301
|
if (diff.status === 0 && diff.stdout.trim()) {
|
|
205
302
|
modifiedFiles = diff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
303
|
+
mergeBaseCommit = baseCommit;
|
|
206
304
|
break;
|
|
207
305
|
}
|
|
208
306
|
}
|
|
@@ -236,16 +334,19 @@ export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifes
|
|
|
236
334
|
];
|
|
237
335
|
|
|
238
336
|
for (const item of manifestToLockfiles) {
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
337
|
+
const manifestPaths = modifiedFiles.filter((f) => item.manifest.test(f));
|
|
338
|
+
if (manifestPaths.length === 0) continue;
|
|
339
|
+
const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
|
|
340
|
+
if (hasLockfile) continue;
|
|
341
|
+
// Only a lockfile-relevant manifest change demands the lockfile: a
|
|
342
|
+
// scripts-only edit regenerates it byte-identical, so demanding one
|
|
343
|
+
// is unsatisfiable.
|
|
344
|
+
if (manifestPaths.some((manifestPath) => manifestChangeRequiresLockfile(root, mergeBaseCommit, manifestPath))) {
|
|
345
|
+
return {
|
|
346
|
+
isOutOfSync: true,
|
|
347
|
+
manifest: item.name,
|
|
348
|
+
reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
|
|
349
|
+
};
|
|
249
350
|
}
|
|
250
351
|
}
|
|
251
352
|
} catch {}
|
package/src/policies/git.ts
CHANGED
|
@@ -335,29 +335,152 @@ export function checkMergeConflicts(root: string): {
|
|
|
335
335
|
return { hasConflicts: false };
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
|
|
338
|
+
const BRANCH_CREATION_VALUELESS_FLAGS = new Set([
|
|
339
|
+
"-f",
|
|
340
|
+
"--force",
|
|
341
|
+
"--discard-changes",
|
|
342
|
+
"-m",
|
|
343
|
+
"--merge",
|
|
344
|
+
"-q",
|
|
345
|
+
"--quiet",
|
|
346
|
+
"--no-track",
|
|
347
|
+
]);
|
|
348
|
+
|
|
349
|
+
// Classifies the optional start-point operand of an already-matched branch
|
|
350
|
+
// creation invocation (`git switch -c|--create` / `git checkout -b`). Returns
|
|
351
|
+
// the explicit start point when present, `{ start: undefined }` when the
|
|
352
|
+
// invocation creates a branch from the current HEAD, and `undefined` when the
|
|
353
|
+
// shape cannot be confidently classified — the caller must then fail closed to
|
|
354
|
+
// the HEAD-based staleness check. Indeterminate tokens (variables, quotes,
|
|
355
|
+
// unknown or value-taking flags) are never classified.
|
|
356
|
+
export function branchCreationStartPoint(rest: string): { start?: string } | undefined {
|
|
357
|
+
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
358
|
+
let sawCreate = false;
|
|
359
|
+
let named = false;
|
|
360
|
+
for (const token of tokens) {
|
|
361
|
+
const indeterminate = /[$`"']/.test(token);
|
|
362
|
+
if (!sawCreate) {
|
|
363
|
+
if (token === "switch" || token === "checkout") continue;
|
|
364
|
+
if (token === "-c" || token === "--create" || token === "-b") {
|
|
365
|
+
sawCreate = true;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (token.startsWith("--create=")) {
|
|
369
|
+
sawCreate = true;
|
|
370
|
+
named = true;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (!token.startsWith("--") && token.length > 2 && (token.startsWith("-c") || token.startsWith("-b"))) {
|
|
374
|
+
sawCreate = true;
|
|
375
|
+
named = true;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (token.startsWith("-")) {
|
|
379
|
+
if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
if (!named) {
|
|
385
|
+
if (token.startsWith("-")) {
|
|
386
|
+
if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
if (indeterminate) return undefined;
|
|
390
|
+
named = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (token.startsWith("-")) {
|
|
394
|
+
if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
if (indeterminate) return undefined;
|
|
398
|
+
return { start: token };
|
|
399
|
+
}
|
|
400
|
+
return sawCreate ? { start: undefined } : undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A branch created from an explicit start point that already contains the
|
|
404
|
+
// remote default is fresh by construction, so the current branch's staleness
|
|
405
|
+
// is irrelevant for it; every provided start point must resolve and contain
|
|
406
|
+
// the base, and any missing start point keeps the HEAD-based check. Start
|
|
407
|
+
// points that cannot be resolved fail closed. Block reasons name what was
|
|
408
|
+
// actually checked and remedies that work under the guard.
|
|
409
|
+
function evaluateBranchCreationStarts(
|
|
410
|
+
root: string,
|
|
411
|
+
base: string,
|
|
412
|
+
startPoints: ReadonlyArray<string>,
|
|
413
|
+
): { isBehind: true; baseRef: string; reason: string } | undefined {
|
|
414
|
+
for (const startPoint of startPoints) {
|
|
415
|
+
const resolved = spawnSync("git", ["rev-parse", "--verify", "--quiet", startPoint], {
|
|
416
|
+
cwd: root,
|
|
417
|
+
encoding: "utf8",
|
|
418
|
+
timeout: 5_000,
|
|
419
|
+
});
|
|
420
|
+
if (resolved.status !== 0 || !resolved.stdout.trim()) {
|
|
421
|
+
return {
|
|
422
|
+
isBehind: true,
|
|
423
|
+
baseRef: base,
|
|
424
|
+
reason: `the explicit start point '${startPoint}' could not be resolved as a commit, so freshness cannot be classified (fail closed). Use a literal branch name or ref as the start point.`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const startSha = resolved.stdout.trim();
|
|
428
|
+
const contains = spawnSync("git", ["merge-base", "--is-ancestor", base, startSha], {
|
|
429
|
+
cwd: root,
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
timeout: 5_000,
|
|
432
|
+
});
|
|
433
|
+
if (contains.status !== 0) {
|
|
434
|
+
const behind = spawnSync("git", ["rev-list", `${startSha}..${base}`, "--count"], {
|
|
435
|
+
cwd: root,
|
|
436
|
+
encoding: "utf8",
|
|
437
|
+
timeout: 5_000,
|
|
438
|
+
});
|
|
439
|
+
const behindCount = parseInt((behind.stdout ?? "").trim(), 10);
|
|
440
|
+
return {
|
|
441
|
+
isBehind: true,
|
|
442
|
+
baseRef: base,
|
|
443
|
+
reason: `the explicit start point '${startPoint}' is ${isNaN(behindCount) ? "" : `${behindCount} `}commit(s) behind ${base}. Create the branch from ${base}, or from any ref that contains it, instead.`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function checkBranchBaseIsUpToDate(
|
|
451
|
+
root: string,
|
|
452
|
+
startPoints?: ReadonlyArray<string | undefined>,
|
|
453
|
+
): {
|
|
339
454
|
isBehind: boolean;
|
|
340
455
|
count?: number;
|
|
341
456
|
baseRef?: string;
|
|
342
457
|
reason?: string;
|
|
343
458
|
} {
|
|
459
|
+
const classified = startPoints && startPoints.length > 0 ? startPoints : undefined;
|
|
344
460
|
for (const base of ["origin/HEAD", "origin/main", "origin/master"]) {
|
|
345
461
|
const res = spawnSync("git", ["rev-list", `HEAD..${base}`, "--count"], {
|
|
346
462
|
cwd: root,
|
|
347
463
|
encoding: "utf8",
|
|
348
464
|
timeout: 5_000,
|
|
349
465
|
});
|
|
350
|
-
if (res.status
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
466
|
+
if (res.status !== 0 || !res.stdout.trim()) continue;
|
|
467
|
+
const count = parseInt(res.stdout.trim(), 10);
|
|
468
|
+
if (isNaN(count)) continue;
|
|
469
|
+
const explicit = (classified ?? []).filter((startPoint): startPoint is string => startPoint !== undefined);
|
|
470
|
+
if (explicit.length > 0) {
|
|
471
|
+
const startBlock = evaluateBranchCreationStarts(root, base, explicit);
|
|
472
|
+
if (startBlock) return startBlock;
|
|
473
|
+
}
|
|
474
|
+
if (count > 0 && (!classified || classified.some((startPoint) => startPoint === undefined))) {
|
|
475
|
+
const branch = currentGitBranch(root) || "detached HEAD";
|
|
476
|
+
return {
|
|
477
|
+
isBehind: true,
|
|
478
|
+
count,
|
|
479
|
+
baseRef: base,
|
|
480
|
+
reason: `current branch '${branch}' is ${count} commit(s) behind remote default (${base}), so a branch created from it starts stale. Update this branch first (git rebase ${base} or git merge --ff-only ${base}), or create the new branch from ${base} directly.`,
|
|
481
|
+
};
|
|
360
482
|
}
|
|
483
|
+
return { isBehind: false };
|
|
361
484
|
}
|
|
362
485
|
return { isBehind: false };
|
|
363
486
|
}
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Workflow Guard TUI companion plugin for OpenCode V2 (CLI plugin API).
|
|
3
3
|
*
|
|
4
4
|
* - Registers the `/guard-options` palette + slash command to toggle project options.
|
|
5
|
-
* - Renders the Workflow Guard badge in the
|
|
5
|
+
* - Renders the Workflow Guard badge in the prompt footer status slot.
|
|
6
6
|
*
|
|
7
7
|
* The V1 entrypoint (`WorkflowGuardTui`) remains for OpenCode 1.x TUI clients.
|
|
8
8
|
*/
|
|
@@ -277,7 +277,6 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
|
277
277
|
},
|
|
278
278
|
});
|
|
279
279
|
};
|
|
280
|
-
registerBadgeSlot("home.footer.status");
|
|
281
280
|
registerBadgeSlot("prompt.footer.status");
|
|
282
281
|
diagRender("probe-after-setup");
|
|
283
282
|
|
package/src/workflow-guard.ts
CHANGED
|
@@ -84,6 +84,9 @@ import {
|
|
|
84
84
|
sessionReviews,
|
|
85
85
|
setSessionWorkspace,
|
|
86
86
|
getSessionWorkspace,
|
|
87
|
+
getSessionBoundWorkspace,
|
|
88
|
+
registerSessionWorkspaceFromToolCall,
|
|
89
|
+
resolveSessionBoundWorkspace,
|
|
87
90
|
resolveEffectiveWorkspace,
|
|
88
91
|
} from "./lib/state.ts";
|
|
89
92
|
import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
|
|
@@ -116,6 +119,9 @@ export {
|
|
|
116
119
|
resetReviewState,
|
|
117
120
|
setSessionWorkspace,
|
|
118
121
|
getSessionWorkspace,
|
|
122
|
+
getSessionBoundWorkspace,
|
|
123
|
+
registerSessionWorkspaceFromToolCall,
|
|
124
|
+
resolveSessionBoundWorkspace,
|
|
119
125
|
resolveEffectiveWorkspace,
|
|
120
126
|
findGitRoot,
|
|
121
127
|
isSameGitRepo,
|
|
@@ -448,12 +454,16 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
448
454
|
tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client: ctx.client }),
|
|
449
455
|
|
|
450
456
|
"tool.execute.before": async (input, output) => {
|
|
451
|
-
const
|
|
457
|
+
const hostWorktree =
|
|
452
458
|
(input as { worktree?: string })?.worktree ||
|
|
453
459
|
(input as { directory?: string })?.directory ||
|
|
454
460
|
(output as { worktree?: string })?.worktree ||
|
|
455
|
-
(output as { directory?: string })?.directory
|
|
456
|
-
|
|
461
|
+
(output as { directory?: string })?.directory;
|
|
462
|
+
// Session-bound workspace before the plugin-instance fallback:
|
|
463
|
+
// concurrent loops share this process, and a fallback derived from
|
|
464
|
+
// the last-active instance must not re-anchor another session's
|
|
465
|
+
// policy context.
|
|
466
|
+
const toolWorktree = hostWorktree || getSessionBoundWorkspace(input.sessionID) || effectiveRoot;
|
|
457
467
|
const toolAgent =
|
|
458
468
|
(input as { agent?: string })?.agent ||
|
|
459
469
|
(output as { agent?: string })?.agent;
|
|
@@ -480,11 +490,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
480
490
|
const record = asRecord(args);
|
|
481
491
|
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
482
492
|
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
483
|
-
|
|
484
|
-
if (candidatePath) {
|
|
485
|
-
const gitRoot = findGitRoot(candidatePath);
|
|
486
|
-
if (gitRoot) setSessionWorkspace(input.sessionID, gitRoot);
|
|
487
|
-
}
|
|
493
|
+
registerSessionWorkspaceFromToolCall(input.sessionID, { workdir, filePath, hostWorktree, toolWorktree });
|
|
488
494
|
}
|
|
489
495
|
if (input.tool === "read") {
|
|
490
496
|
const target = editTargets(args, toolWorktree)[0];
|