@webpresso/agent-kit 3.2.0 → 3.3.0
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/bin/_run.js +4 -1
- package/dist/esm/audit/blueprint-trust.js +14 -10
- package/dist/esm/audit/changeset-evidence.d.ts +23 -0
- package/dist/esm/audit/changeset-evidence.js +30 -0
- package/dist/esm/audit/changeset-required.d.ts +10 -0
- package/dist/esm/audit/changeset-required.js +73 -0
- package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
- package/dist/esm/audit/ci-guardrails-detection.js +68 -0
- package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
- package/dist/esm/audit/registry.d.ts +4 -4
- package/dist/esm/audit/registry.js +6 -1
- package/dist/esm/blueprint/core/parser.js +61 -4
- package/dist/esm/blueprint/core/schema.d.ts +1 -0
- package/dist/esm/blueprint/core/schema.js +26 -0
- package/dist/esm/blueprint/lifecycle/audit.js +22 -10
- package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
- package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
- package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
- package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
- package/dist/esm/blueprint/sync/client.d.ts +2 -1
- package/dist/esm/blueprint/sync/client.js +1 -1
- package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
- package/dist/esm/blueprint/trust/dossier.js +40 -6
- package/dist/esm/blueprint/trust/promotion.js +12 -0
- package/dist/esm/build/cli-mcp-parity.js +5 -0
- package/dist/esm/cli/cli.d.ts +1 -1
- package/dist/esm/cli/cli.js +9 -0
- package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
- package/dist/esm/cli/commands/agent-launch.js +31 -20
- package/dist/esm/cli/commands/audit.js +4 -0
- package/dist/esm/cli/commands/ci-preflight.js +24 -3
- package/dist/esm/cli/commands/init/convergence-apply.d.ts +64 -0
- package/dist/esm/cli/commands/init/convergence-apply.js +127 -23
- package/dist/esm/cli/commands/init/index.d.ts +2 -0
- package/dist/esm/cli/commands/init/index.js +2 -2
- package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
- package/dist/esm/cli/commands/init/package-root.js +10 -2
- package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
- package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
- package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
- package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
- package/dist/esm/cli/commands/opencode-probe.d.ts +107 -0
- package/dist/esm/cli/commands/opencode-probe.js +323 -0
- package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
- package/dist/esm/cli/commands/opencode-rotate.js +23 -11
- package/dist/esm/cli/commands/quality-runner.js +3 -3
- package/dist/esm/cli/commands/review.js +27 -5
- package/dist/esm/cli/commands/rust-check.d.ts +24 -0
- package/dist/esm/cli/commands/rust-check.js +110 -0
- package/dist/esm/cli/commands/typecheck.d.ts +1 -0
- package/dist/esm/cli/commands/typecheck.js +30 -1
- package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +6 -0
- package/dist/esm/cli/commands/worktree/router-dispatch.js +113 -1
- package/dist/esm/cli/commands/worktree/router.js +10 -0
- package/dist/esm/hooks/doctor.d.ts +2 -0
- package/dist/esm/hooks/doctor.js +37 -1
- package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
- package/dist/esm/hooks/shared/types.js +27 -1
- package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
- package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
- package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
- package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
- package/dist/esm/mcp/tools/audit.d.ts +2 -1
- package/dist/esm/mcp/tools/audit.js +12 -0
- package/dist/esm/mcp/tools/audits.d.ts +1 -0
- package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
- package/dist/esm/package.json +2 -0
- package/dist/esm/platform/client.d.ts +7 -1
- package/dist/esm/platform/github-contract-source.d.ts +2 -1
- package/dist/esm/pr-description/contract.js +24 -4
- package/dist/esm/review/authority.js +14 -9
- package/dist/esm/review/availability.d.ts +11 -0
- package/dist/esm/review/availability.js +18 -3
- package/dist/esm/review/execution/adapters.d.ts +11 -1
- package/dist/esm/review/execution/adapters.js +10 -1
- package/dist/esm/review/execution/artifacts.d.ts +1 -1
- package/dist/esm/review/execution/review-checkout.js +35 -16
- package/dist/esm/review/execution/supervisor.js +31 -4
- package/dist/esm/review/execution/types.d.ts +1 -0
- package/dist/esm/review/subject.d.ts +13 -0
- package/dist/esm/review/subject.js +42 -4
- package/dist/esm/rust/affected-manifests.d.ts +38 -0
- package/dist/esm/rust/affected-manifests.js +69 -0
- package/dist/esm/session-memory/fetch-index.d.ts +1 -1
- package/dist/esm/status/snapshot.js +2 -6
- package/dist/esm/test-helpers/global-setup.d.ts +0 -1
- package/dist/esm/test-helpers/global-setup.js +18 -15
- package/dist/esm/typecheck/planner.d.ts +9 -1
- package/dist/esm/typecheck/planner.js +31 -0
- package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
- package/dist/esm/worktrees/orphan-scan.js +211 -0
- package/package.json +14 -12
|
@@ -312,26 +312,32 @@ function neutralizePromotionGateResults(text) {
|
|
|
312
312
|
// continuation lines). Each mirrors exactly one lifecycle writer:
|
|
313
313
|
// `completed_at` (upsertCompletedAt) and the `wp blueprint start` /
|
|
314
314
|
// progress-stamp fields (setBlueprintFrontmatterFields lifecycle intents).
|
|
315
|
-
|
|
315
|
+
// Lifecycle writers only — the pre-tags-strip normalization (compat tier).
|
|
316
|
+
const PLAN_LIFECYCLE_STRIPPED_KEYS_CORE = [
|
|
316
317
|
"completed_at",
|
|
317
318
|
"last_updated",
|
|
318
319
|
"progress",
|
|
319
320
|
"worktree_owner_id",
|
|
320
321
|
"worktree_owner_branch",
|
|
321
322
|
];
|
|
323
|
+
// Current plan-subject strip set: core lifecycle keys plus advisory `tags`.
|
|
324
|
+
// Tags are not plan substance; MCP put and CLI serialize disagree on array-item
|
|
325
|
+
// quoting (`- "x"` vs `- x`). Approvals recorded before the tags strip must
|
|
326
|
+
// still verify via `preTagsStripNormalizePlanSubjectContent`.
|
|
327
|
+
const PLAN_LIFECYCLE_STRIPPED_KEYS = [...PLAN_LIFECYCLE_STRIPPED_KEYS_CORE, "tags"];
|
|
322
328
|
// Neutralize the lifecycle-owned frontmatter fields (`status:` value plus the
|
|
323
|
-
// stripped keys above) INSIDE the leading frontmatter block only. A body line
|
|
329
|
+
// stripped keys above, including advisory `tags`) INSIDE the leading frontmatter block only. A body line
|
|
324
330
|
// that merely looks like one of these (e.g. task text reading
|
|
325
331
|
// "completed_at: must remain unset until release") is plan substance and must
|
|
326
332
|
// keep affecting the digest. Stripping removes the key line and any indented
|
|
327
333
|
// continuation lines so folded (`>-`) values normalize away too.
|
|
328
|
-
function neutralizeLifecycleFrontmatter(text) {
|
|
334
|
+
function neutralizeLifecycleFrontmatter(text, strippedKeys = PLAN_LIFECYCLE_STRIPPED_KEYS) {
|
|
329
335
|
const match = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(text);
|
|
330
336
|
if (!match)
|
|
331
337
|
return text;
|
|
332
338
|
let frontmatter = match[0];
|
|
333
339
|
frontmatter = frontmatter.replace(/^(status:\s*)(['"]?)[^'"\r\n]+?(['"]?)(\s*)$/m, `$1${PLAN_STATUS_PLACEHOLDER}$4`);
|
|
334
|
-
for (const key of
|
|
340
|
+
for (const key of strippedKeys) {
|
|
335
341
|
frontmatter = frontmatter.replace(new RegExp(String.raw `^${key}:[^\n]*\n?(?:[ \t]+[^\n]*\n?)*`, "gmu"), "");
|
|
336
342
|
}
|
|
337
343
|
return `${frontmatter}${text.slice(match[0].length)}`;
|
|
@@ -463,6 +469,20 @@ export function legacyNormalizePlanSubjectContent(content) {
|
|
|
463
469
|
text = neutralizePromotionGateResults(text);
|
|
464
470
|
return Buffer.from(text, "utf8");
|
|
465
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* Digest-compatibility variant for review events recorded after the lifecycle
|
|
474
|
+
* stripped-key extension but before the advisory `tags` strip. Verification
|
|
475
|
+
* accepts a match against this tier so existing plan approvals on tagged
|
|
476
|
+
* blueprints are not stranded by the tags-strip rollout.
|
|
477
|
+
*/
|
|
478
|
+
export function preTagsStripNormalizePlanSubjectContent(content) {
|
|
479
|
+
let text = typeof content === "string" ? content : content.toString("utf8");
|
|
480
|
+
text = neutralizeLifecycleFrontmatter(text, PLAN_LIFECYCLE_STRIPPED_KEYS_CORE);
|
|
481
|
+
text = neutralizeReadinessStamps(text);
|
|
482
|
+
text = neutralizePromotionGateResults(text);
|
|
483
|
+
text = neutralizeTaskExecutionState(text);
|
|
484
|
+
return Buffer.from(text, "utf8");
|
|
485
|
+
}
|
|
466
486
|
function planSubjectAtRef(cwd, ref, blueprintSlug, normalize, blueprintPath) {
|
|
467
487
|
const commit = resolveReviewCommit(cwd, ref);
|
|
468
488
|
const entries = parseTree(cwd, commit);
|
|
@@ -491,6 +511,24 @@ export function createPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
|
|
|
491
511
|
export function createLegacyPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
|
|
492
512
|
return planSubjectAtRef(cwd, ref, blueprintSlug, legacyNormalizePlanSubjectContent, blueprintPath);
|
|
493
513
|
}
|
|
514
|
+
/** Plan subject digested without the advisory `tags` strip (pre-tags-strip tier). */
|
|
515
|
+
export function createPreTagsStripPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
|
|
516
|
+
return planSubjectAtRef(cwd, ref, blueprintSlug, preTagsStripNormalizePlanSubjectContent, blueprintPath);
|
|
517
|
+
}
|
|
518
|
+
/** Working-ledger digest for the pre-tags-strip compatibility tier. */
|
|
519
|
+
export function createPreTagsStripPlanSubjectDigestForContent(blueprintSlug, content) {
|
|
520
|
+
const hash = createHash("sha256").update(PLAN_DOMAIN);
|
|
521
|
+
updateField(hash, blueprintSlug);
|
|
522
|
+
updateField(hash, preTagsStripNormalizePlanSubjectContent(content));
|
|
523
|
+
return hash.digest("hex");
|
|
524
|
+
}
|
|
525
|
+
/** Working-ledger digest for the pre-extension legacy compatibility tier. */
|
|
526
|
+
export function createLegacyPlanSubjectDigestForContent(blueprintSlug, content) {
|
|
527
|
+
const hash = createHash("sha256").update(PLAN_DOMAIN);
|
|
528
|
+
updateField(hash, blueprintSlug);
|
|
529
|
+
updateField(hash, legacyNormalizePlanSubjectContent(content));
|
|
530
|
+
return hash.digest("hex");
|
|
531
|
+
}
|
|
494
532
|
export const reviewSubjectInternals = {
|
|
495
533
|
isExcludedReviewEnvelope,
|
|
496
534
|
isReviewEvidencePath,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps changed repo paths to the tracked Rust crate(s) they belong to.
|
|
3
|
+
*
|
|
4
|
+
* Pure and dependency-free so it is unit-testable without shelling out to
|
|
5
|
+
* `cargo` or `git` — see `#cli/commands/rust-check.js` for the CLI command
|
|
6
|
+
* that spawns `cargo check`/`cargo test` for whatever this resolves.
|
|
7
|
+
*/
|
|
8
|
+
export interface TrackedRustCrate {
|
|
9
|
+
/** Crate directory relative to the repo root, e.g. "native/dash-tui". */
|
|
10
|
+
readonly root: string;
|
|
11
|
+
/** Manifest path relative to the repo root. */
|
|
12
|
+
readonly manifest: string;
|
|
13
|
+
/** `cargo` args (after `cargo`) that prove this crate's tests still pass. */
|
|
14
|
+
readonly testArgs: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The Rust crates `wp ci-preflight` proves compile + pass on an affected
|
|
18
|
+
* change. Mirrors exactly what `.github/workflows/ci.agent-kit.yml` (native
|
|
19
|
+
* session-memory + dash-tui jobs) and `.github/workflows/desktop-perf.yml`
|
|
20
|
+
* (desktop `src-tauri`) already build and test in CI — no new cargo
|
|
21
|
+
* invocation shape is invented here.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately EXCLUDES the `spikes/**` crates (`spikes/pty-spawn-transport/
|
|
24
|
+
* rust`, `spikes/desktop-pty-loadtest/rust`,
|
|
25
|
+
* `spikes/desktop-pty-loadtest/src-tauri`): no CI workflow builds or tests
|
|
26
|
+
* them today — they are scratch/prototype crates with no compile guarantee.
|
|
27
|
+
* Gating pre-push on them would fail closed on code nobody has ever promised
|
|
28
|
+
* compiles. Revisit this list if a spike graduates to a tracked crate.
|
|
29
|
+
*/
|
|
30
|
+
export declare const TRACKED_RUST_CRATES: readonly TrackedRustCrate[];
|
|
31
|
+
/**
|
|
32
|
+
* Returns the sorted, deduped list of tracked-crate manifest paths touched by
|
|
33
|
+
* `files`. Rust files under an untracked crate (e.g. `spikes/**`) or outside
|
|
34
|
+
* any crate are ignored, not escalated to "affects everything" — each
|
|
35
|
+
* manifest maps back to `TRACKED_RUST_CRATES` for its `testArgs`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveAffectedRustManifests(files: readonly string[]): readonly string[];
|
|
38
|
+
export declare function trackedCrateForManifest(manifest: string): TrackedRustCrate | undefined;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps changed repo paths to the tracked Rust crate(s) they belong to.
|
|
3
|
+
*
|
|
4
|
+
* Pure and dependency-free so it is unit-testable without shelling out to
|
|
5
|
+
* `cargo` or `git` — see `#cli/commands/rust-check.js` for the CLI command
|
|
6
|
+
* that spawns `cargo check`/`cargo test` for whatever this resolves.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The Rust crates `wp ci-preflight` proves compile + pass on an affected
|
|
10
|
+
* change. Mirrors exactly what `.github/workflows/ci.agent-kit.yml` (native
|
|
11
|
+
* session-memory + dash-tui jobs) and `.github/workflows/desktop-perf.yml`
|
|
12
|
+
* (desktop `src-tauri`) already build and test in CI — no new cargo
|
|
13
|
+
* invocation shape is invented here.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately EXCLUDES the `spikes/**` crates (`spikes/pty-spawn-transport/
|
|
16
|
+
* rust`, `spikes/desktop-pty-loadtest/rust`,
|
|
17
|
+
* `spikes/desktop-pty-loadtest/src-tauri`): no CI workflow builds or tests
|
|
18
|
+
* them today — they are scratch/prototype crates with no compile guarantee.
|
|
19
|
+
* Gating pre-push on them would fail closed on code nobody has ever promised
|
|
20
|
+
* compiles. Revisit this list if a spike graduates to a tracked crate.
|
|
21
|
+
*/
|
|
22
|
+
export const TRACKED_RUST_CRATES = [
|
|
23
|
+
{
|
|
24
|
+
root: "native/session-memory-engine",
|
|
25
|
+
manifest: "native/session-memory-engine/Cargo.toml",
|
|
26
|
+
testArgs: ["test", "--manifest-path", "native/session-memory-engine/Cargo.toml", "--all"],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
root: "native/dash-tui",
|
|
30
|
+
manifest: "native/dash-tui/Cargo.toml",
|
|
31
|
+
testArgs: ["test", "--manifest-path", "native/dash-tui/Cargo.toml"],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
root: "apps/desktop/src-tauri",
|
|
35
|
+
manifest: "apps/desktop/src-tauri/Cargo.toml",
|
|
36
|
+
testArgs: ["test", "--manifest-path", "apps/desktop/src-tauri/Cargo.toml", "--lib"],
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
function isRustSignalFile(file) {
|
|
40
|
+
return (file.endsWith(".rs") ||
|
|
41
|
+
file === "Cargo.toml" ||
|
|
42
|
+
file.endsWith("/Cargo.toml") ||
|
|
43
|
+
file === "Cargo.lock" ||
|
|
44
|
+
file.endsWith("/Cargo.lock"));
|
|
45
|
+
}
|
|
46
|
+
function owningTrackedCrate(file) {
|
|
47
|
+
return TRACKED_RUST_CRATES.find((candidate) => file === candidate.root || file.startsWith(`${candidate.root}/`));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Returns the sorted, deduped list of tracked-crate manifest paths touched by
|
|
51
|
+
* `files`. Rust files under an untracked crate (e.g. `spikes/**`) or outside
|
|
52
|
+
* any crate are ignored, not escalated to "affects everything" — each
|
|
53
|
+
* manifest maps back to `TRACKED_RUST_CRATES` for its `testArgs`.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveAffectedRustManifests(files) {
|
|
56
|
+
const manifests = new Set();
|
|
57
|
+
for (const rawFile of files) {
|
|
58
|
+
const file = rawFile.replaceAll("\\", "/");
|
|
59
|
+
if (!isRustSignalFile(file))
|
|
60
|
+
continue;
|
|
61
|
+
const crate = owningTrackedCrate(file);
|
|
62
|
+
if (crate)
|
|
63
|
+
manifests.add(crate.manifest);
|
|
64
|
+
}
|
|
65
|
+
return [...manifests].sort();
|
|
66
|
+
}
|
|
67
|
+
export function trackedCrateForManifest(manifest) {
|
|
68
|
+
return TRACKED_RUST_CRATES.find((candidate) => candidate.manifest === manifest);
|
|
69
|
+
}
|
|
@@ -20,7 +20,7 @@ export interface FetchAndIndexOptions {
|
|
|
20
20
|
signal?: AbortSignal;
|
|
21
21
|
}
|
|
22
22
|
export declare function fetchAndIndex(options: FetchAndIndexOptions, deps?: {
|
|
23
|
-
readonly fetchImpl?:
|
|
23
|
+
readonly fetchImpl?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
24
24
|
/** Internal/test-only escape hatch for loopback fixtures; never exposed in CLI/MCP input. */
|
|
25
25
|
readonly allowedHosts?: readonly string[];
|
|
26
26
|
}): Promise<SessionMemoryChunk[]>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { hasChangesetEvidence } from "#audit/changeset-evidence.js";
|
|
3
4
|
import { evaluateApprovalReviewers } from "#lifecycle/audit.js";
|
|
4
5
|
import { collectV2ReviewProvenance } from "#lifecycle/review-provenance.js";
|
|
5
6
|
import { evaluateRequiredChecks } from "#mcp/tools/_shared/required-checks.js";
|
|
@@ -210,12 +211,7 @@ function hasDisclosure(body, label) {
|
|
|
210
211
|
return new RegExp(`(?:^|\\n)\\s*(?:-\\s*)?${escaped}:\\s*\\S`, "iu").test(body);
|
|
211
212
|
}
|
|
212
213
|
function hasReleaseEvidence(pr) {
|
|
213
|
-
|
|
214
|
-
if (files.length > 0 && files.every((file) => file.endsWith(".md")))
|
|
215
|
-
return true;
|
|
216
|
-
if (files.some((file) => /^\.changeset\/[^/]+\.md$/u.test(file)))
|
|
217
|
-
return true;
|
|
218
|
-
return /Changeset-exempt:\s*\S/iu.test(stringField(pr.body) ?? "");
|
|
214
|
+
return hasChangesetEvidence(prFiles(pr), [stringField(pr.body) ?? ""]);
|
|
219
215
|
}
|
|
220
216
|
export function evaluatePrReadiness(evidence) {
|
|
221
217
|
const blockers = [];
|
|
@@ -11,10 +11,6 @@ const MIGRATION_SENTINEL = join(REPO_ROOT, "dist", "esm", "blueprint", "db", "mi
|
|
|
11
11
|
// Lock target lives outside dist/ (tshy rewrites dist/) and under the always-present, gitignored
|
|
12
12
|
// node_modules/.cache so a concurrent test run cannot clobber it mid-build.
|
|
13
13
|
const BUILD_LOCK_TARGET = join(REPO_ROOT, "node_modules", ".cache", "wp-test-setup");
|
|
14
|
-
const DIST_PREP_PROJECTS = new Set(["subprocess", "serial-subprocess"]);
|
|
15
|
-
export function shouldPrepareDistForProject(projectName) {
|
|
16
|
-
return projectName !== undefined && DIST_PREP_PROJECTS.has(projectName);
|
|
17
|
-
}
|
|
18
14
|
// Serialize the dist build across concurrent test runs sharing this checkout: tshy's non-atomic
|
|
19
15
|
// dist/package.json writes corrupt each other if two runs build at once. proper-lockfile gives a
|
|
20
16
|
// bounded wait plus native stale-owner recovery (a dead builder's lock is reclaimed after `stale`).
|
|
@@ -33,19 +29,21 @@ async function withDistBuildLock(fn) {
|
|
|
33
29
|
await release();
|
|
34
30
|
}
|
|
35
31
|
}
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
// h-019: scripts/run-vitest-projects.ts spawns the "unit" and "subprocess" vitest projects as two
|
|
33
|
+
// independent, unsynchronized child processes (Promise.all) — the same exposure exists for any two
|
|
34
|
+
// `vitest run --project <name>` invocations that happen to share this checkout. tshy's package.json
|
|
35
|
+
// write below is non-atomic (truncate+write, not write-temp+rename): a project whose setup() returned
|
|
36
|
+
// without taking this lock could start reading package.json (module resolution, etc.) while a sibling
|
|
37
|
+
// project's process was mid-tshy-write, observing a truncated/empty file (JSONError "File is empty").
|
|
38
|
+
// Every project therefore passes through this SAME gate before its tests can start, whether or not
|
|
39
|
+
// that project needs dist output for itself: whichever process reaches the lock first performs the
|
|
40
|
+
// idempotent, re-checked-under-lock build; every other process blocks until that release, then finds
|
|
41
|
+
// the sentinels already satisfied and returns immediately. In the steady state (dist/ already built)
|
|
42
|
+
// this is a single existsSync check with no lock contention at all.
|
|
43
|
+
async function ensureDistPrepared() {
|
|
42
44
|
// Fast path: nothing to build. Only pay the lock when a build artifact is actually missing.
|
|
43
|
-
if (existsSync(DIST_SENTINEL) && existsSync(MIGRATION_SENTINEL))
|
|
44
|
-
if (project?.name === "serial-subprocess") {
|
|
45
|
-
ensureHostRuntimeArtifactsForSerialTests({ rootDir: REPO_ROOT });
|
|
46
|
-
}
|
|
45
|
+
if (existsSync(DIST_SENTINEL) && existsSync(MIGRATION_SENTINEL))
|
|
47
46
|
return;
|
|
48
|
-
}
|
|
49
47
|
await withDistBuildLock(async () => {
|
|
50
48
|
let builtDist = false;
|
|
51
49
|
// Re-check under the lock: another run may have completed the build while we waited.
|
|
@@ -83,6 +81,11 @@ export async function setup(project) {
|
|
|
83
81
|
});
|
|
84
82
|
}
|
|
85
83
|
});
|
|
84
|
+
}
|
|
85
|
+
// Vitest passes the selected TestProject to globalSetup, once per project process.
|
|
86
|
+
export async function setup(project) {
|
|
87
|
+
syncSastRuleAssets(REPO_ROOT);
|
|
88
|
+
await ensureDistPrepared();
|
|
86
89
|
if (project?.name === "serial-subprocess") {
|
|
87
90
|
ensureHostRuntimeArtifactsForSerialTests({ rootDir: REPO_ROOT });
|
|
88
91
|
}
|
|
@@ -13,7 +13,7 @@ export interface PlannedTypecheckCommand {
|
|
|
13
13
|
}
|
|
14
14
|
export interface TypecheckExecutionPlan {
|
|
15
15
|
readonly repoRoot: string;
|
|
16
|
-
readonly mode: "default" | "files" | "packages";
|
|
16
|
+
readonly mode: "default" | "files" | "packages" | "tests";
|
|
17
17
|
readonly commands: readonly PlannedTypecheckCommand[];
|
|
18
18
|
readonly resolvedScopes: readonly TypecheckScope[];
|
|
19
19
|
readonly preambleLine?: string;
|
|
@@ -24,6 +24,14 @@ export interface PlanTypecheckExecutionOptions {
|
|
|
24
24
|
readonly files?: readonly string[];
|
|
25
25
|
readonly packages?: readonly string[];
|
|
26
26
|
readonly pretty?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Run the separate test-surface typecheck lane (`tsconfig.test.json`)
|
|
29
|
+
* instead of the default production-source lane. Mutually exclusive with
|
|
30
|
+
* `files`/`packages`. This lane is invoked via `wp typecheck --tests` and is
|
|
31
|
+
* it is not part of the default `wp typecheck` gate or `ci-preflight`
|
|
32
|
+
* until the test-surface error corpus is burned down to zero.
|
|
33
|
+
*/
|
|
34
|
+
readonly tests?: boolean;
|
|
27
35
|
}
|
|
28
36
|
export declare function planTypecheckExecution(options: PlanTypecheckExecutionOptions): TypecheckExecutionPlan;
|
|
29
37
|
export declare function formatResolvedScopesLine(scopes: readonly TypecheckScope[]): string;
|
|
@@ -3,6 +3,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
|
|
|
3
3
|
import { getPackageScript, readPackageJson, isRecursiveWpScript } from "#cli/package-scripts.js";
|
|
4
4
|
import { getWorkspacePackages } from "#quality-engine/target-resolver.js";
|
|
5
5
|
import { getManagedRunner, resolveEffectiveRunnerRoot } from "#tool-runtime";
|
|
6
|
+
const TEST_TSCONFIG_FILENAME = "tsconfig.test.json";
|
|
6
7
|
const GENERATED_ROOT_PREFIXES = [".webpresso/generated"];
|
|
7
8
|
const OUTPUT_SEGMENTS = new Set(["coverage", "dist", "node_modules"]);
|
|
8
9
|
export function planTypecheckExecution(options) {
|
|
@@ -14,6 +15,17 @@ export function planTypecheckExecution(options) {
|
|
|
14
15
|
if (files.length > 0 && packages.length > 0) {
|
|
15
16
|
throw new Error("Cannot use both --file and --package for typecheck targeting.");
|
|
16
17
|
}
|
|
18
|
+
if (options.tests) {
|
|
19
|
+
if (files.length > 0 || packages.length > 0) {
|
|
20
|
+
throw new Error("Cannot use --tests together with --file or --package.");
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
repoRoot,
|
|
24
|
+
mode: "tests",
|
|
25
|
+
commands: [buildTestsCommand(defaultScopeRoot, options.pretty)],
|
|
26
|
+
resolvedScopes: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
17
29
|
if (packages.length > 0) {
|
|
18
30
|
const scopes = resolvePackageScopes(repoRoot, realRepoRoot, packages);
|
|
19
31
|
return {
|
|
@@ -63,6 +75,25 @@ function buildUntargetedCommand(cwd, pretty = false) {
|
|
|
63
75
|
cwd,
|
|
64
76
|
};
|
|
65
77
|
}
|
|
78
|
+
function buildTestsCommand(cwd, pretty = false) {
|
|
79
|
+
const testTsconfigPath = join(cwd, TEST_TSCONFIG_FILENAME);
|
|
80
|
+
if (!existsSync(testTsconfigPath)) {
|
|
81
|
+
throw new Error(`--tests requires ${TEST_TSCONFIG_FILENAME} at the repo root (not found at ${testTsconfigPath}).`);
|
|
82
|
+
}
|
|
83
|
+
const resolution = getManagedRunner("tsc");
|
|
84
|
+
return {
|
|
85
|
+
scope: null,
|
|
86
|
+
command: resolution.command,
|
|
87
|
+
args: [
|
|
88
|
+
...resolution.args,
|
|
89
|
+
"--noEmit",
|
|
90
|
+
"-p",
|
|
91
|
+
TEST_TSCONFIG_FILENAME,
|
|
92
|
+
...(pretty ? [] : ["--pretty", "false"]),
|
|
93
|
+
],
|
|
94
|
+
cwd,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
66
97
|
function buildTargetedCommand(scope, pretty = false) {
|
|
67
98
|
const checkTypesScript = getPackageScript(scope.root, "check-types");
|
|
68
99
|
if (checkTypesScript && !isRecursiveWpScript(checkTypesScript, "typecheck")) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type WorktreeInventory } from "./identity.js";
|
|
2
|
+
export type OrphanClassification = "alive" | "orphan" | "broken" | "unrecognized";
|
|
3
|
+
export interface OrphanScanCandidate {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly repoNamespace: string;
|
|
6
|
+
}
|
|
7
|
+
export interface OrphanScanRow extends OrphanScanCandidate {
|
|
8
|
+
readonly classification: OrphanClassification;
|
|
9
|
+
readonly dirty: boolean | "unknown";
|
|
10
|
+
/**
|
|
11
|
+
* Recursive size, computed ONLY for the final `orphan` classification.
|
|
12
|
+
* `alive` worktrees are actively worked on (often large `node_modules`
|
|
13
|
+
* trees) and are never removable, and `broken`/`unrecognized` rows need
|
|
14
|
+
* manual review regardless of size, so walking their full tree would burn
|
|
15
|
+
* significant time for no decision-relevant signal. `"unknown"` also
|
|
16
|
+
* covers a measurement failure/timeout on an orphan candidate.
|
|
17
|
+
*/
|
|
18
|
+
readonly sizeBytes: number | "unknown";
|
|
19
|
+
readonly removable: boolean;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
}
|
|
22
|
+
export interface OrphanClassifyDeps {
|
|
23
|
+
readonly resolveRepoRoot: (candidate: OrphanScanCandidate) => string | undefined;
|
|
24
|
+
readonly repoRootExists: (repoRoot: string) => boolean;
|
|
25
|
+
readonly buildInventory: (repoRoot: string) => WorktreeInventory;
|
|
26
|
+
readonly probeDirty: (path: string) => boolean | "unknown";
|
|
27
|
+
readonly measureSize: (path: string) => number;
|
|
28
|
+
}
|
|
29
|
+
/** Classify a single on-disk candidate. Pure aside from the injected deps. */
|
|
30
|
+
export declare function classifyOrphanCandidate(candidate: OrphanScanCandidate, deps: OrphanClassifyDeps): OrphanScanRow;
|
|
31
|
+
/** Namespace-keyed lookup of a known-live `repoRoot`, first-registered-wins. */
|
|
32
|
+
export declare function buildRegistryRepoRootMap(entries: readonly {
|
|
33
|
+
readonly repoNamespace: string;
|
|
34
|
+
readonly repoRoot: string;
|
|
35
|
+
}[]): ReadonlyMap<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* Fallback primary-repo resolver for candidates the registry never recorded:
|
|
38
|
+
* a linked worktree's `.git` is a file containing `gitdir: <repoRoot>/.git/
|
|
39
|
+
* worktrees/<name>`. Returns `undefined` when unreadable or unparseable
|
|
40
|
+
* (e.g. `.git` is a directory, or the pointer target was already reaped).
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveRepoRootFromGitPointer(path: string, readGitPointer: (gitPath: string) => string | undefined): string | undefined;
|
|
43
|
+
export interface DiscoverOrphanCandidatesOptions {
|
|
44
|
+
readonly repoNamespaceFilter?: string;
|
|
45
|
+
readonly dirExists?: (path: string) => boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Walk every known managed-worktree root and collect worktree-shaped leaf dirs. */
|
|
48
|
+
export declare function discoverOrphanScanCandidates(roots: readonly string[], options?: DiscoverOrphanCandidatesOptions): OrphanScanCandidate[];
|
|
49
|
+
/** Bounded recursive directory size, symlinks excluded, unreadable entries skipped. */
|
|
50
|
+
export declare function measureDirectorySizeBytes(path: string): number;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem-level orphan scan for `wp worktree prune --orphans`.
|
|
3
|
+
*
|
|
4
|
+
* Complements the existing registry-only `wp worktree prune --all`: that
|
|
5
|
+
* command only drops stale bookkeeping rows from `registry.json`. This module
|
|
6
|
+
* instead walks the physical managed-worktree roots
|
|
7
|
+
* (`~/.webpresso/worktrees`, legacy `~/.agent/worktrees`) directly, because a
|
|
8
|
+
* large share of stale directories on disk were never registered at all
|
|
9
|
+
* (crashed sessions, `gh pr merge --delete-branch` deregistering the git
|
|
10
|
+
* worktree out from under the directory, manual `rm` of only part of a
|
|
11
|
+
* worktree). Git metadata and the registry can both be perfectly clean while
|
|
12
|
+
* the `~/.webpresso` tree still accumulates dozens of dead directories.
|
|
13
|
+
*
|
|
14
|
+
* Classification (see `catalog blueprint worktree-prune-stale-dirs`):
|
|
15
|
+
*
|
|
16
|
+
* - `alive` — path is listed by `git worktree list` for its resolved
|
|
17
|
+
* primary repo. Never removable.
|
|
18
|
+
* - `orphan` — a resolvable, existing primary repo's `git worktree
|
|
19
|
+
* list` output does NOT include this path. Removable only
|
|
20
|
+
* when a `git status --porcelain` probe inside the
|
|
21
|
+
* directory succeeds AND reports no changes.
|
|
22
|
+
* - `broken` — a primary repo pointer was resolved, but the target
|
|
23
|
+
* repo itself is missing on disk, or its `git worktree
|
|
24
|
+
* list` probe failed outright (bounded, non-retrying).
|
|
25
|
+
* Never removable — the directory's true state cannot be
|
|
26
|
+
* proven.
|
|
27
|
+
* - `unrecognized` — no primary repo pointer could be resolved at all
|
|
28
|
+
* (neither `registry.json` nor the directory's own
|
|
29
|
+
* `.git` file yields one), or the directory's `.git`
|
|
30
|
+
* pointer resolves but `git status --porcelain` cannot
|
|
31
|
+
* even run inside it (not a functioning checkout).
|
|
32
|
+
* Reported for manual review; never removable.
|
|
33
|
+
*/
|
|
34
|
+
import { readdirSync, statSync } from "node:fs";
|
|
35
|
+
import { join, relative, sep } from "node:path";
|
|
36
|
+
import { canonicalizeWorktreePath } from "./identity.js";
|
|
37
|
+
function measureSizeSafely(path, deps) {
|
|
38
|
+
try {
|
|
39
|
+
return deps.measureSize(path);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "unknown";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function finalizeRow(candidate, classification, reason, deps) {
|
|
46
|
+
if (classification !== "orphan") {
|
|
47
|
+
return {
|
|
48
|
+
...candidate,
|
|
49
|
+
classification,
|
|
50
|
+
dirty: "unknown",
|
|
51
|
+
sizeBytes: "unknown",
|
|
52
|
+
removable: false,
|
|
53
|
+
reason,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const dirty = deps.probeDirty(candidate.path);
|
|
57
|
+
if (dirty === "unknown") {
|
|
58
|
+
return {
|
|
59
|
+
...candidate,
|
|
60
|
+
classification: "unrecognized",
|
|
61
|
+
dirty,
|
|
62
|
+
sizeBytes: "unknown",
|
|
63
|
+
removable: false,
|
|
64
|
+
reason: "git status probe failed — not a functioning git checkout",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Size is decision-relevant only once a row is a confirmed orphan, so it is
|
|
68
|
+
// measured here — never for alive/broken/unrecognized rows above.
|
|
69
|
+
const sizeBytes = measureSizeSafely(candidate.path, deps);
|
|
70
|
+
return {
|
|
71
|
+
...candidate,
|
|
72
|
+
classification,
|
|
73
|
+
dirty,
|
|
74
|
+
sizeBytes,
|
|
75
|
+
removable: dirty === false,
|
|
76
|
+
reason: dirty ? "uncommitted/untracked changes present" : reason,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Classify a single on-disk candidate. Pure aside from the injected deps. */
|
|
80
|
+
export function classifyOrphanCandidate(candidate, deps) {
|
|
81
|
+
const repoRoot = deps.resolveRepoRoot(candidate);
|
|
82
|
+
if (!repoRoot) {
|
|
83
|
+
return finalizeRow(candidate, "unrecognized", "no resolvable primary repo pointer", deps);
|
|
84
|
+
}
|
|
85
|
+
if (!deps.repoRootExists(repoRoot)) {
|
|
86
|
+
return finalizeRow(candidate, "broken", `target repo missing: ${repoRoot}`, deps);
|
|
87
|
+
}
|
|
88
|
+
const inventory = deps.buildInventory(repoRoot);
|
|
89
|
+
if (!inventory.complete) {
|
|
90
|
+
return finalizeRow(candidate, "broken", "git worktree list unavailable for target repo", deps);
|
|
91
|
+
}
|
|
92
|
+
const canonical = canonicalizeWorktreePath(candidate.path);
|
|
93
|
+
if (inventory.canonicalPaths.has(canonical)) {
|
|
94
|
+
return finalizeRow(candidate, "alive", "present in git worktree list", deps);
|
|
95
|
+
}
|
|
96
|
+
return finalizeRow(candidate, "orphan", "absent from git worktree list", deps);
|
|
97
|
+
}
|
|
98
|
+
/** Namespace-keyed lookup of a known-live `repoRoot`, first-registered-wins. */
|
|
99
|
+
export function buildRegistryRepoRootMap(entries) {
|
|
100
|
+
const map = new Map();
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (!map.has(entry.repoNamespace))
|
|
103
|
+
map.set(entry.repoNamespace, entry.repoRoot);
|
|
104
|
+
}
|
|
105
|
+
return map;
|
|
106
|
+
}
|
|
107
|
+
const WORKTREES_GITDIR_MARKER = "/.git/worktrees/";
|
|
108
|
+
/**
|
|
109
|
+
* Fallback primary-repo resolver for candidates the registry never recorded:
|
|
110
|
+
* a linked worktree's `.git` is a file containing `gitdir: <repoRoot>/.git/
|
|
111
|
+
* worktrees/<name>`. Returns `undefined` when unreadable or unparseable
|
|
112
|
+
* (e.g. `.git` is a directory, or the pointer target was already reaped).
|
|
113
|
+
*/
|
|
114
|
+
export function resolveRepoRootFromGitPointer(path, readGitPointer) {
|
|
115
|
+
const content = readGitPointer(join(path, ".git"));
|
|
116
|
+
if (!content)
|
|
117
|
+
return undefined;
|
|
118
|
+
const match = /^gitdir:\s*(.+)$/mu.exec(content.trim());
|
|
119
|
+
const gitdir = match?.[1]?.trim();
|
|
120
|
+
if (!gitdir)
|
|
121
|
+
return undefined;
|
|
122
|
+
const markerIndex = gitdir.lastIndexOf(WORKTREES_GITDIR_MARKER);
|
|
123
|
+
if (markerIndex === -1)
|
|
124
|
+
return undefined;
|
|
125
|
+
return gitdir.slice(0, markerIndex);
|
|
126
|
+
}
|
|
127
|
+
const MAX_SCAN_DEPTH = 8;
|
|
128
|
+
const SKIP_DIR_NAMES = new Set([".git", "node_modules"]);
|
|
129
|
+
function repoNamespaceFromPath(root, path) {
|
|
130
|
+
const segments = relative(root, path).split(sep).filter(Boolean);
|
|
131
|
+
const reposIndex = segments.indexOf("repos");
|
|
132
|
+
if (reposIndex === -1)
|
|
133
|
+
return undefined;
|
|
134
|
+
return segments[reposIndex + 1];
|
|
135
|
+
}
|
|
136
|
+
function walkForCandidates(root, dir, depth, out) {
|
|
137
|
+
if (depth > MAX_SCAN_DEPTH)
|
|
138
|
+
return;
|
|
139
|
+
let entries;
|
|
140
|
+
try {
|
|
141
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (entries.some((entry) => entry.name === ".git")) {
|
|
147
|
+
const repoNamespace = repoNamespaceFromPath(root, dir);
|
|
148
|
+
if (repoNamespace)
|
|
149
|
+
out.push({ path: dir, repoNamespace });
|
|
150
|
+
return; // never descend into a worktree checkout's own contents
|
|
151
|
+
}
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (!entry.isDirectory() || SKIP_DIR_NAMES.has(entry.name))
|
|
154
|
+
continue;
|
|
155
|
+
walkForCandidates(root, join(dir, entry.name), depth + 1, out);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Walk every known managed-worktree root and collect worktree-shaped leaf dirs. */
|
|
159
|
+
export function discoverOrphanScanCandidates(roots, options = {}) {
|
|
160
|
+
const dirExists = options.dirExists ??
|
|
161
|
+
((path) => {
|
|
162
|
+
try {
|
|
163
|
+
return statSync(path).isDirectory();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const root of roots) {
|
|
171
|
+
if (!dirExists(root))
|
|
172
|
+
continue;
|
|
173
|
+
walkForCandidates(root, root, 0, out);
|
|
174
|
+
}
|
|
175
|
+
return options.repoNamespaceFilter
|
|
176
|
+
? out.filter((candidate) => candidate.repoNamespace === options.repoNamespaceFilter)
|
|
177
|
+
: out;
|
|
178
|
+
}
|
|
179
|
+
/** Bounded recursive directory size, symlinks excluded, unreadable entries skipped. */
|
|
180
|
+
export function measureDirectorySizeBytes(path) {
|
|
181
|
+
let total = 0;
|
|
182
|
+
const stack = [path];
|
|
183
|
+
while (stack.length > 0) {
|
|
184
|
+
const current = stack.pop();
|
|
185
|
+
if (current === undefined)
|
|
186
|
+
break;
|
|
187
|
+
let entries;
|
|
188
|
+
try {
|
|
189
|
+
entries = readdirSync(current, { withFileTypes: true, encoding: "utf8" });
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (entry.isSymbolicLink())
|
|
196
|
+
continue;
|
|
197
|
+
const full = join(current, entry.name);
|
|
198
|
+
if (entry.isDirectory()) {
|
|
199
|
+
stack.push(full);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
total += statSync(full).size;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// unreadable file: skip rather than fail the whole scan
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return total;
|
|
211
|
+
}
|