ce-code-review (4 personas) on Phase B. No P0; the review conjunction was
confirmed safe (no false-done — empty map and per-repo throws both route to
UNAVAILABLE, which blocks). Applied:
P1: the fn_task_done scope-leak guard now fails CLOSED in workspace mode — a
per-repo capture throw blocks completion ("refusing as a precaution") instead of
the outer .catch returning {blocked:false} and letting an incomplete check pass.
A scoped task that acquired ZERO sub-repo worktrees is now blocked rather than
silently passing scope enforcement.
P2: reviewWorkspacePerRepo breaks on the first non-APPROVE repo so a later repo's
throw can't discard an already-determined REVISE (callers were seeing UNAVAILABLE
instead). captureWorkspaceModifiedFiles isolates each per-repo capture in
try/catch so one repo's throw can't skip the modifiedFiles write. The .changeset
always-allowed carve-out is honored in workspace mode: the scope-leak branch now
filters repo-LOCAL paths via the (previously dead) workspace-paths.ts
deriveRepoScopeSubset helper through the same filter as the singular path, so a
sub-repo .changeset/* no longer falsely blocks fn_task_done. All four per-repo
loops iterate sorted keys for deterministic offending-repo reporting; the dead
repoRel callback param and the duplicate path-normalizer are removed.
Verified safe (no change): the reviewer semaphore releases on throw (try/finally),
and per-repo reviewers inherit the task abort via session disposal.
Deferred to Phase C: extracting a workspace-executor.ts module (before the merge
loop lands). Gate green: typecheck, lint, build, test:gate (649+58).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
116 lines
6.0 KiB
TypeScript
116 lines
6.0 KiB
TypeScript
/*
|
|
FNXC:Workspace 2026-06-22-00:30:
|
|
Minimal shared repo-prefix-derivation helper for workspace mode (Phase B U2; master U5 reuses it). A workspace task's File Scope, modified-file list, and review/scope-leak findings are all repo-prefixed (`<repoRel>/<file>`). Per-repo review and per-repo scope-leak need to map a path → its owning sub-repo, and to derive each repo's File-Scope subset (so a reviewer at `cwd = repo.worktreePath` and a per-repo scope-leak check evaluate only that repo's declared paths).
|
|
|
|
NO lease logic lives here (file-scope leases are Phase C / master U7). This module is intentionally dependency-light (pure string/path math) so it can be reused across the executor, reviewer callers, and the later merge loop without pulling in executor state.
|
|
|
|
Matching rule: canonicalize the path to forward-slash relative segments, then pick the LONGEST configured repo key that is a path-segment prefix of the file path. Longest-prefix (not naive first-segment) correctly handles nested repo keys like `apps/web` while still satisfying the simple `wolf-server/src/** → wolf-server` case. A path that matches no configured repo (absolute paths outside the workspace, root-level files like `.changeset/x.md`, or a first segment that is not a repo) derives to the `UNSCOPED` sentinel.
|
|
*/
|
|
|
|
/** Sentinel returned when a path does not belong to any configured sub-repo. */
|
|
export const UNSCOPED_REPO = "unscoped" as const;
|
|
|
|
/*
|
|
FNXC:Workspace 2026-06-21-15:00:
|
|
F8 — single normalize helper. The executor previously kept its own `normalizeWorkflowScopePath` that was a near-duplicate of this function, differing only in leading-slash stripping (`/^\/+/` here vs none there) and trailing-slash greediness (`/\/+$/` here vs `/\/$/` there). Two slightly-different normalizers meant an absolute or trailing-slash-laden path could derive a different scope key in the two code paths. We promote THIS (more aggressive: strips leading slash + collapses repeated trailing slashes) to the single exported normalizer and have the executor import it for scope-path normalization, so workspace and non-workspace scope matching canonicalize identically. workspace-paths.ts stays dependency-light (imports nothing), so executor→workspace-paths is a one-way, acyclic edge.
|
|
*/
|
|
export function normalizeRepoRelPath(value: string): string {
|
|
return value
|
|
.trim()
|
|
.replace(/\\/g, "/")
|
|
.replace(/^\.\//, "")
|
|
.replace(/\/+/g, "/")
|
|
.replace(/^\/+/, "")
|
|
.replace(/\/+$/, "");
|
|
}
|
|
|
|
/** Split a normalized path into non-empty segments. */
|
|
function segmentsOf(value: string): string[] {
|
|
const normalized = normalizeRepoRelPath(value);
|
|
return normalized ? normalized.split("/") : [];
|
|
}
|
|
|
|
/**
|
|
* Return true when `repoSegs` is a leading segment-prefix of `pathSegs`.
|
|
* Segment-wise (not substring) so `repo-a` does NOT match `repo-ab/...`.
|
|
*/
|
|
function isSegmentPrefix(repoSegs: string[], pathSegs: string[]): boolean {
|
|
if (repoSegs.length === 0 || repoSegs.length > pathSegs.length) return false;
|
|
for (let i = 0; i < repoSegs.length; i++) {
|
|
if (repoSegs[i] !== pathSegs[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Derive the configured sub-repo that owns `filePath`, or {@link UNSCOPED_REPO}.
|
|
*
|
|
* `repos` are the configured workspace sub-repo relative keys (from
|
|
* `workspaceConfig.repos` or `Object.keys(task.workspaceWorktrees)`). The LONGEST
|
|
* matching repo key wins so nested repos (`apps/web` vs `apps`) resolve to the
|
|
* most specific owner.
|
|
*/
|
|
export function deriveRepoForPath(filePath: string, repos: readonly string[]): string {
|
|
const pathSegs = segmentsOf(filePath);
|
|
if (pathSegs.length === 0) return UNSCOPED_REPO;
|
|
let best: string | null = null;
|
|
let bestLen = 0;
|
|
for (const repo of repos) {
|
|
const repoSegs = segmentsOf(repo);
|
|
if (repoSegs.length === 0) continue;
|
|
if (isSegmentPrefix(repoSegs, pathSegs) && repoSegs.length > bestLen) {
|
|
best = normalizeRepoRelPath(repo);
|
|
bestLen = repoSegs.length;
|
|
}
|
|
}
|
|
return best ?? UNSCOPED_REPO;
|
|
}
|
|
|
|
/**
|
|
* Result of splitting a repo-prefixed File-Scope entry into its owning repo and
|
|
* the repo-relative remainder (the path AS the reviewer at `cwd = repo` sees it).
|
|
*/
|
|
export interface RepoScopedPath {
|
|
/** Owning sub-repo key, or {@link UNSCOPED_REPO}. */
|
|
repo: string;
|
|
/** The path with the repo prefix stripped (repo-local). Equals `path` when unscoped. */
|
|
relativePath: string;
|
|
}
|
|
|
|
/**
|
|
* Split a repo-prefixed path into `{ repo, relativePath }`. For `repo-a/src/x.ts`
|
|
* with `repos=["repo-a"]` → `{ repo:"repo-a", relativePath:"src/x.ts" }`. An
|
|
* unscoped path returns the whole normalized path as `relativePath`.
|
|
*/
|
|
export function splitRepoScopedPath(filePath: string, repos: readonly string[]): RepoScopedPath {
|
|
const repo = deriveRepoForPath(filePath, repos);
|
|
const normalized = normalizeRepoRelPath(filePath);
|
|
if (repo === UNSCOPED_REPO) {
|
|
return { repo, relativePath: normalized };
|
|
}
|
|
const repoNormalized = normalizeRepoRelPath(repo);
|
|
const remainder = normalized.slice(repoNormalized.length).replace(/^\/+/, "");
|
|
return { repo, relativePath: remainder };
|
|
}
|
|
|
|
/**
|
|
* Derive a single sub-repo's File-Scope subset from the task's full (repo-prefixed)
|
|
* declared scope. Returns the repo-LOCAL scope patterns (prefix stripped) so a
|
|
* per-repo reviewer or per-repo scope-leak check — operating with `cwd = repo` —
|
|
* can compare repo-local paths directly. Entries owned by other repos (or unscoped)
|
|
* are excluded. A scope entry whose prefix-stripped remainder is empty (the repo
|
|
* root itself, e.g. `repo-a` or `repo-a/`) maps to `**` (whole-repo scope).
|
|
*/
|
|
export function deriveRepoScopeSubset(declaredScope: readonly string[], repoRel: string): string[] {
|
|
const repoSegs = segmentsOf(repoRel);
|
|
if (repoSegs.length === 0) return [];
|
|
const subset: string[] = [];
|
|
for (const entry of declaredScope) {
|
|
const entrySegs = segmentsOf(entry);
|
|
if (!isSegmentPrefix(repoSegs, entrySegs)) continue;
|
|
const remainder = entrySegs.slice(repoSegs.length).join("/");
|
|
subset.push(remainder === "" ? "**" : remainder);
|
|
}
|
|
return subset;
|
|
}
|