Skip to content

T-9JRW-workspace-membership-drift-assertion

Status: open/ready · Impact: medium · Complexity: small

AUTO-DEFINED: this spec was best-effort machine-authored by /sdlc:task-auto-define on 2026-07-20 because the task is autonomy: autonomous/pr. Review the Goal, Approach, Today, Files-to-touch, and Acceptance-criteria carefully before trusting it.

A directory move that rewrites a workspaces glob can silently change workspace membership, and nothing catches it mechanically. This task adds a membership-drift assertion — resolved workspace/project membership compared before and after a move, failing unless the task explicitly declares a delta — so a mis-scoped glob is caught by the gate instead of by a human noticing the lockfile grew. Originating from T-C9RD-consolidate-augmented-into-solutions on sksizer/dev.

T-C9RD’s prescribed solutions/*/apps/* workspaces glob was wrong as written: it silently absorbed augmented-books and augmented_dot_com, which are their own Bun roots per D-0016/D-0018 and were never root workspace members, pulling in 60 extra packages. Two ! negations had to be added by hand after a human noticed the lockfile had grown. A membership-drift assertion — workspace member COUNT (and id set) unchanged across a move unless the task explicitly declares a delta — would have caught this mechanically. Add such a check, comparing the resolved workspace/project membership before and after (moon query projects gave a crisp before/after count in T-J5DI, 101 to 100). Implement alongside the gate ops in apps/sdlc/lib/services/gate/ops/ (see worktree-scope.ts for the op shape). Distinct from T-VY9S-workspaces-glob-covers-apps-consumers, which is about widening the glob for apps/* consumers, not about detecting drift.

T-C9RD-consolidate-augmented-into-solutions

LocationRole today
package.jsonRoot Bun workspace manifest. Its workspaces array of globs IS the membership definition a directory move rewrites; nothing asserts the resolved member set is stable across such a rewrite.
.moon/workspace.ymlmoon’s separate project discovery (projects.globs + projects.sources). The before/after counts cited in T-J5DI-remove-clear-duplicate-augmented-packages came from moon query projects against this file, run by hand.
apps/sdlc/lib/services/gate/ops/worktree-scope.tsThe only pre-commit gate op under gate/ops/. Supplies the op shape this task copies: defineOp with hidden: true, zod input/output, a cli.render hook returning non-zero, and a WORKTREE_SCOPE_GUARD=skip env bypass.
apps/sdlc/lib/services/gate/ops/worktree-scope.ts#findCollisionsThe pure comparison core the handler calls — the handler/core split this task mirrors.
apps/sdlc/lib/services/quality/baseline.tsPrecedent for a before/after comparison core living at the service-package level with thin ops (quality baseline capture / quality baseline diff) layered on top.
apps/sdlc/lib/_generated/op-manifest.tsGenerated static barrel of op modules. A new op module is invisible to bun build --compile until this is regenerated.
apps/sdlc/scripts/gen-op-manifest.tsThe generator that rewrites that barrel.
lefthook.ymlpre-commit driver. Wires sdlc gate worktree-scope plus the project-check gates; no workspace-membership gate exists. Its project-check-docs-drift comment records why a gate reads the git INDEX rather than the working tree under parallel sessions.
apps/sdlc/lib/services/gate/tests/worktree_scope.test.tsTest precedent: drives a gate op through the real CLI against an ephemeral git repo built in a temp dir.
apps/sdlc/conventions/worktree-scope-guard.mdContract doc for the sibling guard — carve-outs, env override — that the new guard’s doc mirrors.

A sdlc gate workspace-membership op that resolves the ROOT Bun workspace member set twice — once from the package.json and file tree at a baseline git rev (--base, default HEAD), once from the same pair read out of the git index — and fails when the two sets differ. A deliberate move declares its delta through WORKSPACE_MEMBERSHIP_DELTA="+<dir>,-<dir>" and passes only when the observed delta equals the declaration exactly, so the T-C9RD shape (declare one move, 60 packages tag along) still fails. WORKSPACE_MEMBERSHIP_GUARD=skip is the blunt bypass, mirroring WORKTREE_SCOPE_GUARD=skip. Wired into lefthook.yml’s pre-commit block beside the worktree-scope guard, so a mis-scoped glob is rejected at the commit boundary instead of surfacing later as unexplained bun.lock growth.

  1. Add the pure core at apps/sdlc/lib/services/gate/workspace-membership.ts. It performs no ambient I/O — callers inject the manifest text and the path listing — and exports:
    • parseWorkspaceGlobs(packageJsonText): string[] — reads the workspaces key, accepting both the array form and the { packages: [...] } object form, and returns the globs verbatim including leading ! negations.
    • expandMembers(globs, paths): Member[] — a path qualifies when it is <dir>/package.json and <dir> matches at least one positive glob and no ! negation, matched with tinyglobby (already a dependency of @sksizer/sdlc). Member = { dir: string; name: string | null }, sorted by dir.
    • diffMembership(before, after): { added: Member[]; removed: Member[] } — set difference keyed on dir.
    • parseDeclaredDelta(spec): { added: string[]; removed: string[] } — parses the comma-separated +dir / -dir declaration form.
  2. Resolve the BEFORE side from the baseline rev: git show <base>:package.json for the manifest and git ls-tree -r --name-only <base> for the path listing.
  3. Resolve the AFTER side from the git INDEX: git show :package.json and git ls-files --cached. The index — not the working tree — is what the commit will contain, and matches the reasoning already recorded on project-check-docs-drift in lefthook.yml (a working-tree read is contaminated by concurrent sessions’ uncommitted edits). Both sides issue their git calls through ctx.git, the CommandRunner seam the worktree-scope handler already uses; no raw child_process spawn.
  4. Add the op at apps/sdlc/lib/services/gate/ops/workspace-membership.ts as a thin defineOp({ path: ["gate", "workspace-membership"], hidden: true, ... }) wrapper over the core, mirroring the worktree-scope descriptor.
    • Input: { projectRoot: string; base: string } with base defaulting to HEAD.
    • Output: { base, before: { count, members }, after: { count, members }, added, removed, declared, drift, skipped, reason? }.
    • Handler order: env bypass first, then membership resolution, then declared-delta reconciliation.
  5. Implement the two escape hatches in the handler:
    • WORKSPACE_MEMBERSHIP_GUARD=skip returns { skipped: true, reason } without measuring.
    • WORKSPACE_MEMBERSHIP_DELTA sets declared; drift is false only when the observed added/removed dir sets equal the declared sets exactly.
    • Absent a declaration, any non-empty delta sets drift: true.
  6. Add the renderWorkspaceMembership(out, io) hook: return 0 when skipped is true or drift is false; otherwise write the before/after counts and one + <dir> / - <dir> line per changed member to stderr, name both env vars, and return 1 so lefthook shapes the exit red.
  7. Register the op by running apps/sdlc/scripts/gen-op-manifest.ts under bun run, which adds the import "@lib/services/gate/ops/workspace-membership.ts"; line to apps/sdlc/lib/_generated/op-manifest.ts.
  8. Wire the gate into lefthook.yml as a workspace-membership-guard entry in pre-commit.commands, placed next to worktree-scope-guard, running bun apps/sdlc/cli/sdlc.ts gate workspace-membership, with a comment naming both env vars and pointing at the conventions doc.
  9. Write the conventions doc at apps/sdlc/conventions/workspace-membership-guard.md, mirroring worktree-scope-guard.md: what the gate measures, the --base flag, the declared-delta format, and the two env vars.
  10. Add apps/sdlc/lib/services/gate/tests/workspace_membership.test.ts, modelled on worktree_scope.test.ts. Build an ephemeral git repo whose committed package.json has workspaces: ["packages/*"] and two member dirs; then in the index rewrite the glob to ["packages/*", "apps/*"] and add apps/extra/package.json. Drive bun run <cli> gate workspace-membership --advanced and assert the five cases enumerated in AC-2 through AC-5.
LocationKindChange
apps/sdlc/lib/services/gate/workspace-membership.tsnewPure core — parseWorkspaceGlobs, expandMembers, diffMembership, parseDeclaredDelta; no ambient I/O.
apps/sdlc/lib/services/gate/ops/workspace-membership.tsnewThin hidden defineOp wrapper: reads base-rev and index state through ctx.git, calls the core, renders the drift report on stderr, returns non-zero on drift.
apps/sdlc/lib/services/gate/tests/workspace_membership.test.tsnewEphemeral-git-repo suite driving the op through the real CLI; covers clean, drifted, declared-matching, declared-mismatched, and skipped.
apps/sdlc/conventions/workspace-membership-guard.mdnewGuard contract doc mirroring worktree-scope-guard.md.
apps/sdlc/lib/_generated/op-manifest.tsmodifyRegenerated to add the new op’s import line so it lands in the compiled binary.
lefthook.ymlmodifyNew workspace-membership-guard command in pre-commit.commands beside worktree-scope-guard.
  • AC-1: bun apps/sdlc/cli/sdlc.ts gate workspace-membership --advanced --help exits 0 and prints the op’s summary, confirming it is registered as a hidden gate op alongside gate worktree-scope.
  • AC-2: On a repo whose index membership matches the base rev, the op exits 0 and --output json reports drift: false with empty added and removed arrays.
  • AC-3: When the index widens the root workspaces glob so it absorbs a directory that was not a member at the base rev, the op exits 1 and its stderr names that directory.
  • AC-4: With WORKSPACE_MEMBERSHIP_DELTA set to exactly the observed delta the op exits 0; with a declaration that omits one observed added member it still exits 1.
  • AC-5: With WORKSPACE_MEMBERSHIP_GUARD=skip in the environment the op exits 0 and --output json reports skipped: true, regardless of the observed delta.
  • AC-6: bun test apps/sdlc/lib/services/gate/tests/workspace_membership.test.ts passes, exercising the five cases in AC-2 through AC-5.
  • AC-7: apps/sdlc/lib/_generated/op-manifest.ts contains the line import "@lib/services/gate/ops/workspace-membership.ts";, and re-running the generator apps/sdlc/scripts/gen-op-manifest.ts leaves that file byte-identical.
  • AC-8: lefthook.yml contains a workspace-membership-guard entry under pre-commit.commands invoking bun apps/sdlc/cli/sdlc.ts gate workspace-membership.
  • AC-9: The command-seam linter apps/sdlc/scripts/check_command_seam.sh exits 0 — the two new modules reach git only through ctx.git / the command seam.
  • moon project membership. The moon project set is defined separately in .moon/workspace.yml and resolving it requires the moon binary on PATH, which a pre-commit gate should not depend on. The T-C9RD regression was a root workspaces glob fault, so the Bun member set is what this gate measures; moon query projects stays the human cross-check.
  • Nested Bun workspace roots (apps/sdlc/desktop, augmented-books, augmented_dot_com per D-0016 / D-0018). Determined is no longer one: its subtrees are enrolled per-solution in the root workspaces (solutions/determined/{apps,experiences,products,packages}/*), so they ARE root members this gate measures. This gate measures the ROOT workspace’s membership only.
  • Widening the root workspaces glob to cover apps/* consumers — that is T-VY9S-workspaces-glob-covers-apps-consumers’s scope. This task detects drift; it does not change the glob.
  • Lockfile-size or dependency-count assertions on bun.lock. Lockfile growth was the symptom a human noticed; membership is the cause this gate measures.
  • Working-tree (unstaged) measurement. The gate reads the git index, per the parallel-session contamination reasoning recorded on project-check-docs-drift in lefthook.yml.
  • Auto-repairing a drifted glob — e.g. synthesizing the ! negations T-C9RD needed by hand. The gate reports; the human fixes.
  • none

Spawned by /sdlc:spawn-task-pr on 2026-07-20 UTC from T-C9RD-consolidate-augmented-into-solutions in https://github.com/sksizer/dev.


← Back to Tasks