@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
|
@@ -146,6 +146,10 @@ export const REPO_AUDIT_REGISTRY = {
|
|
|
146
146
|
"legacy-workflow-identity": async (root) => (await import("#audit/legacy-workflow-identity")).auditLegacyWorkflowIdentity(root),
|
|
147
147
|
rules: async (root) => runContentAudit(root, "rule"),
|
|
148
148
|
skills: async (root) => runContentAudit(root, "skill"),
|
|
149
|
+
"changeset-required": async (root, options) => (await import("#audit/changeset-required")).auditChangesetRequired(root, {
|
|
150
|
+
baseRef: options.baseRef ?? options.base,
|
|
151
|
+
changedFiles: options.affectedFiles,
|
|
152
|
+
}),
|
|
149
153
|
};
|
|
150
154
|
async function runContentAudit(root, kind) {
|
|
151
155
|
const { auditContent } = await import("../../content/audit.js");
|
|
@@ -24,9 +24,12 @@ export const CI_PREFLIGHT_COMMAND_HELP = [
|
|
|
24
24
|
"Runs, in order, stopping at the first failure:",
|
|
25
25
|
" 1. wp format --affected --branch --check",
|
|
26
26
|
" 2. wp audit guardrails --affected --branch",
|
|
27
|
-
" 3. wp
|
|
28
|
-
" 4. wp
|
|
29
|
-
" 5. wp
|
|
27
|
+
" 3. wp audit changeset-required --affected --branch",
|
|
28
|
+
" 4. wp typecheck --affected --branch",
|
|
29
|
+
" 5. wp typecheck --tests",
|
|
30
|
+
" 6. wp lint --affected --branch",
|
|
31
|
+
" 7. wp rust-check --affected --branch",
|
|
32
|
+
" 8. wp test --affected --branch",
|
|
30
33
|
"",
|
|
31
34
|
"This is what `.husky/pre-push` runs. A normal `git push` (no --no-verify)",
|
|
32
35
|
"must pass it. Network-bound / whole-repo gates (dependency freshness) and",
|
|
@@ -42,9 +45,27 @@ export const CI_PREFLIGHT_COMMAND_HELP = [
|
|
|
42
45
|
export const CI_PREFLIGHT_CHECKS = [
|
|
43
46
|
{ label: "format", args: ["format", "--affected", "--branch", "--check"] },
|
|
44
47
|
{ label: "guardrails", args: ["audit", "guardrails", "--affected", "--branch"] },
|
|
48
|
+
// Mirrors the `changeset-missing` blocker `wp_pr_status` computes against a
|
|
49
|
+
// live PR (`#status/snapshot.js`), scoped to the local branch diff so a
|
|
50
|
+
// missing changeset surfaces before push instead of after — see
|
|
51
|
+
// `catalog/agent/rules/ci-cost-local-first.md`. Deliberately excluded from
|
|
52
|
+
// the `wp audit guardrails` composite (see `changeset-required`'s
|
|
53
|
+
// `includedInGuardrails: false` in `#audit/registry.js`), so it gets its own
|
|
54
|
+
// preflight step.
|
|
55
|
+
{ label: "changeset", args: ["audit", "changeset-required", "--affected", "--branch"] },
|
|
45
56
|
{ label: "typecheck", args: ["typecheck", "--affected", "--branch"] },
|
|
57
|
+
// Required test-surface typecheck (tsconfig.test.json). Not branch-scoped:
|
|
58
|
+
// the tests lane is a single whole-repo tsc project and is the burn-down
|
|
59
|
+
// gate that keeps test/script types from rotting again.
|
|
60
|
+
{ label: "typecheck-tests", args: ["typecheck", "--tests"] },
|
|
46
61
|
// CI runs lint after typecheck in the Lint & typecheck job; mirror that order.
|
|
47
62
|
{ label: "lint", args: ["lint", "--affected", "--branch"] },
|
|
63
|
+
// TS/JS affectedness (above) says nothing about the Rust crates
|
|
64
|
+
// (native/session-memory-engine, native/dash-tui, apps/desktop/src-tauri) —
|
|
65
|
+
// without this stage a Rust-only branch reported every stage above as "no
|
|
66
|
+
// affected files" and exited green having proven nothing about the Rust
|
|
67
|
+
// (see `#rust/affected-manifests.js`).
|
|
68
|
+
{ label: "rust", args: ["rust-check", "--affected", "--branch"] },
|
|
48
69
|
{ label: "test", args: ["test", "--affected", "--branch"] },
|
|
49
70
|
];
|
|
50
71
|
function reproduceLine(check) {
|
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
import { type ConvergeUnit } from "./convergence.js";
|
|
2
2
|
export { resolveUntrackedCollision } from "./untracked-collision.js";
|
|
3
|
+
/** Bounded timeout for non-hook Git probes (status, add, rev-parse, diff-tree). */
|
|
4
|
+
export declare const GIT_PROBE_TIMEOUT_MS = 15000;
|
|
5
|
+
/**
|
|
6
|
+
* Default wall for the single hookful `git commit`. Consumer pre-commit chains
|
|
7
|
+
* (format + audits) routinely exceed the probe budget; Node's spawnSync kills
|
|
8
|
+
* the child with SIGTERM when `timeout` elapses, which left staged residue and
|
|
9
|
+
* multi-line partial hook output that looked like success.
|
|
10
|
+
*
|
|
11
|
+
* 10 minutes is a durable consumer budget, not infinite hang. Operators can
|
|
12
|
+
* raise/lower via `WP_SETUP_COMMIT_TIMEOUT_MS` or `AuthorCommitOptions.commitTimeoutMs`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_GIT_COMMIT_TIMEOUT_MS = 600000;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the hookful-commit timeout. Preference order:
|
|
17
|
+
* 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
|
|
18
|
+
* 2. `WP_SETUP_COMMIT_TIMEOUT_MS` env (operator override)
|
|
19
|
+
* 3. {@link DEFAULT_GIT_COMMIT_TIMEOUT_MS}
|
|
20
|
+
*
|
|
21
|
+
* Values must be finite numbers ≥ 0. `0` disables the artificial wall (Node default).
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveCommitTimeoutMs(options?: {
|
|
24
|
+
readonly commitTimeoutMs?: number;
|
|
25
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
26
|
+
}): number;
|
|
27
|
+
/** First-class timeout diagnosis; partial hook stdout/stderr is diagnostic only. */
|
|
28
|
+
export declare function formatCommitTimeoutError(options: {
|
|
29
|
+
readonly timeoutMs: number;
|
|
30
|
+
readonly hookOutput?: string;
|
|
31
|
+
}): string;
|
|
32
|
+
/**
|
|
33
|
+
* First-class interrupt diagnosis for non-timeout signal kills (operator kill,
|
|
34
|
+
* external SIGTERM/SIGKILL). Must not surface raw hook "passed" lines as the
|
|
35
|
+
* leading operator message.
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatCommitInterruptError(options: {
|
|
38
|
+
readonly signal: NodeJS.Signals;
|
|
39
|
+
readonly hookOutput?: string;
|
|
40
|
+
}): string;
|
|
41
|
+
/**
|
|
42
|
+
* Classify a failed hookful `git commit` result.
|
|
43
|
+
*
|
|
44
|
+
* - `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
|
|
45
|
+
* - non-null `signal` without timedOut → interrupt diagnosis (names the signal)
|
|
46
|
+
* - otherwise → hook/git stderr when present, else a generic preserved-index message
|
|
47
|
+
*
|
|
48
|
+
* Never lead with bare hook "passed" lines for timeout/interrupt paths.
|
|
49
|
+
*/
|
|
50
|
+
export declare function classifyCommitFailure(result: {
|
|
51
|
+
readonly timedOut: boolean;
|
|
52
|
+
readonly signal: NodeJS.Signals | null;
|
|
53
|
+
readonly stderr: string;
|
|
54
|
+
readonly stdout: string;
|
|
55
|
+
}, timeoutMs: number): string;
|
|
56
|
+
/**
|
|
57
|
+
* Prefix every line of a setup failure so multi-line timeout diagnoses stay
|
|
58
|
+
* labeled when printed via console.error (avoids a single `wp setup: ` on the
|
|
59
|
+
* first line and bare hook "passed" lines after it).
|
|
60
|
+
*/
|
|
61
|
+
export declare function formatWpSetupError(message: string): string;
|
|
3
62
|
export type AuthorCommitStatus = "committed" | "no-change" | "skipped-ci" | "failed" | "rolled-back";
|
|
4
63
|
export interface AuthorCommitResult {
|
|
5
64
|
readonly status: AuthorCommitStatus;
|
|
@@ -15,6 +74,11 @@ export interface AuthorCommitOptions {
|
|
|
15
74
|
readonly appliedUnits: readonly ConvergeUnit[];
|
|
16
75
|
readonly message: string;
|
|
17
76
|
readonly isCi?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Override wall for the single hookful `git commit` (ms). Prefer this in tests.
|
|
79
|
+
* `0` disables the artificial timeout. Probe Git calls always stay on the short budget.
|
|
80
|
+
*/
|
|
81
|
+
readonly commitTimeoutMs?: number;
|
|
18
82
|
}
|
|
19
83
|
export interface AppliedConvergenceCandidate {
|
|
20
84
|
readonly targetPath: string;
|
|
@@ -2,21 +2,124 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { convergeUnitFromTarget, deriveCommitAllowlist } from "./convergence.js";
|
|
4
4
|
export { resolveUntrackedCollision } from "./untracked-collision.js";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/** Bounded timeout for non-hook Git probes (status, add, rev-parse, diff-tree). */
|
|
6
|
+
export const GIT_PROBE_TIMEOUT_MS = 15_000;
|
|
7
|
+
/**
|
|
8
|
+
* Default wall for the single hookful `git commit`. Consumer pre-commit chains
|
|
9
|
+
* (format + audits) routinely exceed the probe budget; Node's spawnSync kills
|
|
10
|
+
* the child with SIGTERM when `timeout` elapses, which left staged residue and
|
|
11
|
+
* multi-line partial hook output that looked like success.
|
|
12
|
+
*
|
|
13
|
+
* 10 minutes is a durable consumer budget, not infinite hang. Operators can
|
|
14
|
+
* raise/lower via `WP_SETUP_COMMIT_TIMEOUT_MS` or `AuthorCommitOptions.commitTimeoutMs`.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_GIT_COMMIT_TIMEOUT_MS = 600_000;
|
|
17
|
+
const GIT_MAX_BUFFER = 1024 * 1024;
|
|
18
|
+
function git(repoRoot, args, timeoutMs) {
|
|
7
19
|
const result = spawnSync("git", [...args], {
|
|
8
20
|
cwd: repoRoot,
|
|
9
21
|
encoding: "utf8",
|
|
10
|
-
|
|
11
|
-
|
|
22
|
+
// Node: timeout > 0 arms SIGTERM; 0 / omitted means no artificial wall.
|
|
23
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
|
|
24
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
12
25
|
stdio: ["ignore", "pipe", "pipe"],
|
|
13
26
|
});
|
|
27
|
+
const timedOut = result.error !== undefined && "code" in result.error && result.error.code === "ETIMEDOUT";
|
|
14
28
|
return {
|
|
15
29
|
ok: result.status === 0 && !result.error,
|
|
16
|
-
stdout: result.stdout.trim(),
|
|
17
|
-
stderr: result.stderr.trim(),
|
|
30
|
+
stdout: (result.stdout ?? "").trim(),
|
|
31
|
+
stderr: (result.stderr ?? "").trim(),
|
|
32
|
+
timedOut,
|
|
33
|
+
signal: result.signal,
|
|
18
34
|
};
|
|
19
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the hookful-commit timeout. Preference order:
|
|
38
|
+
* 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
|
|
39
|
+
* 2. `WP_SETUP_COMMIT_TIMEOUT_MS` env (operator override)
|
|
40
|
+
* 3. {@link DEFAULT_GIT_COMMIT_TIMEOUT_MS}
|
|
41
|
+
*
|
|
42
|
+
* Values must be finite numbers ≥ 0. `0` disables the artificial wall (Node default).
|
|
43
|
+
*/
|
|
44
|
+
export function resolveCommitTimeoutMs(options) {
|
|
45
|
+
const env = options?.env ?? process.env;
|
|
46
|
+
if (typeof options?.commitTimeoutMs === "number" &&
|
|
47
|
+
Number.isFinite(options.commitTimeoutMs) &&
|
|
48
|
+
options.commitTimeoutMs >= 0) {
|
|
49
|
+
return options.commitTimeoutMs;
|
|
50
|
+
}
|
|
51
|
+
const raw = env.WP_SETUP_COMMIT_TIMEOUT_MS;
|
|
52
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
53
|
+
const parsed = Number.parseInt(raw, 10);
|
|
54
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
return DEFAULT_GIT_COMMIT_TIMEOUT_MS;
|
|
58
|
+
}
|
|
59
|
+
function appendHookOutput(diagnosis, hookOutput) {
|
|
60
|
+
const trimmed = hookOutput?.trim();
|
|
61
|
+
if (!trimmed)
|
|
62
|
+
return diagnosis;
|
|
63
|
+
const snippet = trimmed.length > 400 ? `${trimmed.slice(0, 400)}…` : trimmed;
|
|
64
|
+
return `${diagnosis}\n--- hook output (truncated) ---\n${snippet}`;
|
|
65
|
+
}
|
|
66
|
+
/** First-class timeout diagnosis; partial hook stdout/stderr is diagnostic only. */
|
|
67
|
+
export function formatCommitTimeoutError(options) {
|
|
68
|
+
const budget = options.timeoutMs > 0
|
|
69
|
+
? `${options.timeoutMs}ms`
|
|
70
|
+
: "the configured budget (no artificial wall was set, but the process was still interrupted)";
|
|
71
|
+
const diagnosis = `Convergence commit timed out after ${budget} while repository hooks were still running. ` +
|
|
72
|
+
`Node spawnSync kills the git process with SIGTERM on timeout, so no setup commit was authored. ` +
|
|
73
|
+
`Staged and worktree changes were preserved for remediation. ` +
|
|
74
|
+
`Re-run after hooks complete, raise WP_SETUP_COMMIT_TIMEOUT_MS / commitTimeoutMs, or fix slow hooks. ` +
|
|
75
|
+
`Partial hook lines such as "format applied" / "audit … passed" are not success.`;
|
|
76
|
+
return appendHookOutput(diagnosis, options.hookOutput);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* First-class interrupt diagnosis for non-timeout signal kills (operator kill,
|
|
80
|
+
* external SIGTERM/SIGKILL). Must not surface raw hook "passed" lines as the
|
|
81
|
+
* leading operator message.
|
|
82
|
+
*/
|
|
83
|
+
export function formatCommitInterruptError(options) {
|
|
84
|
+
const diagnosis = `Convergence commit was interrupted by ${options.signal} before completion, so no setup commit was authored. ` +
|
|
85
|
+
`Staged and worktree changes were preserved for remediation. ` +
|
|
86
|
+
`This is not a successful setup run. ` +
|
|
87
|
+
`Partial hook lines such as "format applied" / "audit … passed" are not success.`;
|
|
88
|
+
return appendHookOutput(diagnosis, options.hookOutput);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Classify a failed hookful `git commit` result.
|
|
92
|
+
*
|
|
93
|
+
* - `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
|
|
94
|
+
* - non-null `signal` without timedOut → interrupt diagnosis (names the signal)
|
|
95
|
+
* - otherwise → hook/git stderr when present, else a generic preserved-index message
|
|
96
|
+
*
|
|
97
|
+
* Never lead with bare hook "passed" lines for timeout/interrupt paths.
|
|
98
|
+
*/
|
|
99
|
+
export function classifyCommitFailure(result, timeoutMs) {
|
|
100
|
+
const hookOutput = [result.stderr, result.stdout].filter(Boolean).join("\n");
|
|
101
|
+
if (result.timedOut) {
|
|
102
|
+
return formatCommitTimeoutError({ timeoutMs, hookOutput });
|
|
103
|
+
}
|
|
104
|
+
if (result.signal) {
|
|
105
|
+
return formatCommitInterruptError({ signal: result.signal, hookOutput });
|
|
106
|
+
}
|
|
107
|
+
return hookOutput || "git commit failed; hook changes were preserved.";
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Prefix every line of a setup failure so multi-line timeout diagnoses stay
|
|
111
|
+
* labeled when printed via console.error (avoids a single `wp setup: ` on the
|
|
112
|
+
* first line and bare hook "passed" lines after it).
|
|
113
|
+
*/
|
|
114
|
+
export function formatWpSetupError(message) {
|
|
115
|
+
const body = message.trimEnd();
|
|
116
|
+
if (body.length === 0)
|
|
117
|
+
return "wp setup: Unable to author convergence commit.";
|
|
118
|
+
return body
|
|
119
|
+
.split("\n")
|
|
120
|
+
.map((line) => `wp setup: ${line}`)
|
|
121
|
+
.join("\n");
|
|
122
|
+
}
|
|
20
123
|
function lines(value) {
|
|
21
124
|
return value.length === 0 ? [] : value.split("\n").filter(Boolean).sort();
|
|
22
125
|
}
|
|
@@ -32,8 +135,8 @@ function readPorcelainStatus(repoRoot, gitPath) {
|
|
|
32
135
|
], {
|
|
33
136
|
cwd: repoRoot,
|
|
34
137
|
encoding: "utf8",
|
|
35
|
-
timeout:
|
|
36
|
-
maxBuffer:
|
|
138
|
+
timeout: GIT_PROBE_TIMEOUT_MS,
|
|
139
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
37
140
|
stdio: ["ignore", "pipe", "pipe"],
|
|
38
141
|
});
|
|
39
142
|
if (result.status !== 0 || result.error)
|
|
@@ -52,7 +155,8 @@ function readPorcelainStatus(repoRoot, gitPath) {
|
|
|
52
155
|
return entries;
|
|
53
156
|
}
|
|
54
157
|
function isIgnored(repoRoot, gitPath) {
|
|
55
|
-
return git(repoRoot, ["check-ignore", "--no-index", "-q", "--", gitPath])
|
|
158
|
+
return git(repoRoot, ["check-ignore", "--no-index", "-q", "--", gitPath], GIT_PROBE_TIMEOUT_MS)
|
|
159
|
+
.ok;
|
|
56
160
|
}
|
|
57
161
|
export function collectAppliedConvergeUnits(repoRoot, candidates) {
|
|
58
162
|
const units = [];
|
|
@@ -107,31 +211,35 @@ export function authorConvergenceCommit(options) {
|
|
|
107
211
|
return { status: "skipped-ci", ok: true, allowlist };
|
|
108
212
|
if (allowlist.length === 0)
|
|
109
213
|
return { status: "no-change", ok: true, allowlist };
|
|
110
|
-
const
|
|
214
|
+
const probe = (args) => git(options.repoRoot, args, GIT_PROBE_TIMEOUT_MS);
|
|
215
|
+
const before = probe(["rev-parse", "--verify", "HEAD"]);
|
|
111
216
|
if (!before.ok) {
|
|
112
217
|
return { status: "failed", ok: false, allowlist, error: before.stderr || "Missing HEAD." };
|
|
113
218
|
}
|
|
114
|
-
const add =
|
|
219
|
+
const add = probe(["add", "--", ...allowlist]);
|
|
115
220
|
if (!add.ok) {
|
|
116
221
|
return { status: "failed", ok: false, allowlist, error: add.stderr || "git add failed." };
|
|
117
222
|
}
|
|
118
|
-
const staged =
|
|
223
|
+
const staged = probe(["diff", "--cached", "--name-only"]);
|
|
119
224
|
if (!staged.ok) {
|
|
120
225
|
return { status: "failed", ok: false, allowlist, error: staged.stderr || "git diff failed." };
|
|
121
226
|
}
|
|
122
227
|
if (lines(staged.stdout).length === 0) {
|
|
123
228
|
return { status: "no-change", ok: true, allowlist };
|
|
124
229
|
}
|
|
125
|
-
const
|
|
230
|
+
const commitTimeoutMs = resolveCommitTimeoutMs({
|
|
231
|
+
commitTimeoutMs: options.commitTimeoutMs,
|
|
232
|
+
});
|
|
233
|
+
const commit = git(options.repoRoot, ["commit", "-m", options.message], commitTimeoutMs);
|
|
126
234
|
if (!commit.ok) {
|
|
127
235
|
return {
|
|
128
236
|
status: "failed",
|
|
129
237
|
ok: false,
|
|
130
238
|
allowlist,
|
|
131
|
-
error: commit
|
|
239
|
+
error: classifyCommitFailure(commit, commitTimeoutMs),
|
|
132
240
|
};
|
|
133
241
|
}
|
|
134
|
-
const committedHead =
|
|
242
|
+
const committedHead = probe(["rev-parse", "--verify", "HEAD"]);
|
|
135
243
|
if (!committedHead.ok) {
|
|
136
244
|
return {
|
|
137
245
|
status: "failed",
|
|
@@ -140,11 +248,7 @@ export function authorConvergenceCommit(options) {
|
|
|
140
248
|
error: committedHead.stderr || "Unable to validate the authored commit.",
|
|
141
249
|
};
|
|
142
250
|
}
|
|
143
|
-
const committedParent =
|
|
144
|
-
"rev-parse",
|
|
145
|
-
"--verify",
|
|
146
|
-
`${committedHead.stdout}^`,
|
|
147
|
-
]);
|
|
251
|
+
const committedParent = probe(["rev-parse", "--verify", `${committedHead.stdout}^`]);
|
|
148
252
|
if (!committedParent.ok || committedParent.stdout !== before.stdout) {
|
|
149
253
|
return {
|
|
150
254
|
status: "failed",
|
|
@@ -154,7 +258,7 @@ export function authorConvergenceCommit(options) {
|
|
|
154
258
|
error: "The observed commit does not directly follow the recorded HEAD; no rollback was attempted. Inspect Git history and recover manually.",
|
|
155
259
|
};
|
|
156
260
|
}
|
|
157
|
-
const committedPathsResult =
|
|
261
|
+
const committedPathsResult = probe([
|
|
158
262
|
"diff-tree",
|
|
159
263
|
"--root",
|
|
160
264
|
"--no-commit-id",
|
|
@@ -184,7 +288,7 @@ export function authorConvergenceCommit(options) {
|
|
|
184
288
|
commit: committedHead.stdout,
|
|
185
289
|
};
|
|
186
290
|
}
|
|
187
|
-
const currentHead =
|
|
291
|
+
const currentHead = probe(["rev-parse", "--verify", "HEAD"]);
|
|
188
292
|
if (!currentHead.ok || currentHead.stdout !== committedHead.stdout) {
|
|
189
293
|
return {
|
|
190
294
|
status: "failed",
|
|
@@ -196,7 +300,7 @@ export function authorConvergenceCommit(options) {
|
|
|
196
300
|
error: "The setup-authored commit changed before validation; no rollback was attempted. Inspect Git history and recover manually.",
|
|
197
301
|
};
|
|
198
302
|
}
|
|
199
|
-
const rollback =
|
|
303
|
+
const rollback = probe(["reset", "--mixed", before.stdout]);
|
|
200
304
|
if (!rollback.ok) {
|
|
201
305
|
return {
|
|
202
306
|
status: "failed",
|
|
@@ -84,6 +84,8 @@ export interface ResolveCatalogDirOptions {
|
|
|
84
84
|
readonly argv0?: string;
|
|
85
85
|
readonly argv1?: string;
|
|
86
86
|
readonly pathEnv?: string;
|
|
87
|
+
readonly pathExtEnv?: string;
|
|
88
|
+
readonly platform?: NodeJS.Platform;
|
|
87
89
|
}
|
|
88
90
|
export declare function resolveCatalogDir(options?: ResolveCatalogDirOptions): string;
|
|
89
91
|
export declare function formatHostSetupSurfaceVisibility(input: {
|
|
@@ -24,7 +24,7 @@ import { isAgentKitSourceRepo } from "./source-repo-hook-policy.js";
|
|
|
24
24
|
import { summarizeResults } from "./merge.js";
|
|
25
25
|
import { WritePlan, collectRepoWriteEntries, renderWritePlan } from "./write-plan.js";
|
|
26
26
|
import { writeConvergenceState } from "./convergence-state.js";
|
|
27
|
-
import { authorConvergenceCommit, collectAppliedConvergeUnits, } from "./convergence-apply.js";
|
|
27
|
+
import { authorConvergenceCommit, collectAppliedConvergeUnits, formatWpSetupError, } from "./convergence-apply.js";
|
|
28
28
|
import { prepareRepositoryForConvergence } from "./git-convergence.js";
|
|
29
29
|
import { collectUserScopeSteps, userScopeStepsToEntries, } from "./user-scope-plan.js";
|
|
30
30
|
import { clearUserScopeConsentMarker, resolveUserScopeConsent, writeUserScopeConsentMarker, } from "./user-scope-consent.js";
|
|
@@ -1979,7 +1979,7 @@ export async function runInit(flags, deps = {}) {
|
|
|
1979
1979
|
isCi: isCiEnvironment,
|
|
1980
1980
|
});
|
|
1981
1981
|
if (!commitResult.ok) {
|
|
1982
|
-
console.error(
|
|
1982
|
+
console.error(formatWpSetupError(commitResult.error ?? "Unable to author convergence commit."));
|
|
1983
1983
|
return EXIT_WRITE_FAIL;
|
|
1984
1984
|
}
|
|
1985
1985
|
if (commitResult.status === "committed") {
|
|
@@ -8,6 +8,13 @@ export interface ResolveAgentKitPackageRootOptions {
|
|
|
8
8
|
readonly pathExtEnv?: string;
|
|
9
9
|
readonly platform?: NodeJS.Platform;
|
|
10
10
|
readonly requireCatalog?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Optional cwd anchor used as a last-resort walk start when module/argv/PATH
|
|
13
|
+
* anchors cannot locate a root (virtual bunfs/vite moduleUrl, vitest entry).
|
|
14
|
+
* Not defaulted to process.cwd() so hermetic tests stay isolated from the
|
|
15
|
+
* monorepo checkout the test process runs in.
|
|
16
|
+
*/
|
|
17
|
+
readonly cwd?: string;
|
|
11
18
|
}
|
|
12
19
|
export declare function isAgentKitPackageRoot(dir: string, options?: {
|
|
13
20
|
readonly requireCatalog?: boolean;
|
|
@@ -84,13 +84,15 @@ export function isAgentKitPackageRoot(dir, options = {}) {
|
|
|
84
84
|
return false;
|
|
85
85
|
return hasStableAgentKitRootMarker(dir);
|
|
86
86
|
}
|
|
87
|
+
/** Max parent-walk hops from a start path. Deep pnpm/vitest/store layouts exceed 10. */
|
|
88
|
+
const PACKAGE_ROOT_WALK_MAX_DEPTH = 32;
|
|
87
89
|
export function findAgentKitPackageRoot(startPath, options = {}) {
|
|
88
90
|
if (!isRunnablePath(startPath))
|
|
89
91
|
return null;
|
|
90
92
|
const platform = options.platform ?? process.platform;
|
|
91
93
|
const pathModule = pathModuleForPlatform(platform);
|
|
92
94
|
let dir = pathModule.dirname(startPath);
|
|
93
|
-
for (let depth = 0; depth <
|
|
95
|
+
for (let depth = 0; depth < PACKAGE_ROOT_WALK_MAX_DEPTH; depth++) {
|
|
94
96
|
if (isAgentKitPackageRoot(dir, options))
|
|
95
97
|
return dir;
|
|
96
98
|
const parent = pathModule.dirname(dir);
|
|
@@ -113,7 +115,13 @@ export function resolveAgentKitPackageRoot(options = {}) {
|
|
|
113
115
|
platform,
|
|
114
116
|
});
|
|
115
117
|
const requireCatalog = options.requireCatalog;
|
|
116
|
-
|
|
118
|
+
// Opt-in cwd last: monorepo/test hosts often run from the package root when
|
|
119
|
+
// moduleUrl is virtual (bunfs/vite) or argv points outside the tree (vitest).
|
|
120
|
+
// Callers that want the fallback pass `cwd` explicitly (see hook launchers).
|
|
121
|
+
const cwdStart = typeof options.cwd === "string" && options.cwd.length > 0
|
|
122
|
+
? join(options.cwd, "package.json")
|
|
123
|
+
: undefined;
|
|
124
|
+
for (const startPath of [modulePath, argv1, execPath, pathResolvedBin, argv0, cwdStart]) {
|
|
117
125
|
const root = findAgentKitPackageRoot(startPath ?? undefined, { requireCatalog, platform });
|
|
118
126
|
if (root)
|
|
119
127
|
return root;
|
|
@@ -21,10 +21,19 @@ export declare function readSetupWpActionRef(metaUrl: string): string;
|
|
|
21
21
|
* consumer who deleted the step (with or without its comments) gets it back —
|
|
22
22
|
* and therefore keeps inheriting new guardrails (e.g. typescript-version) on
|
|
23
23
|
* their next agent-kit bump. Returns the content unchanged when the step is
|
|
24
|
-
* already present, the workflow is not a wp quality pipeline,
|
|
25
|
-
* anchor exists
|
|
24
|
+
* already present, the workflow is not a wp quality pipeline, no test step
|
|
25
|
+
* anchor exists, or `repoAlreadyWired` is true (guardrails already run in a
|
|
26
|
+
* sibling workflow of the same repo — see `migrateConsumerWorkflows`, and
|
|
27
|
+
* tech-debt h-017: without this check a consumer who deliberately hosts
|
|
28
|
+
* guardrails in a dedicated `guardrails.yml` got a duplicate step injected
|
|
29
|
+
* into `ci.yml` on every `wp setup`).
|
|
30
|
+
*
|
|
31
|
+
* `hasGuardrailsInvocation`/`findLiveTestRunAnchor` come from the shared
|
|
32
|
+
* `ci-guardrails-detection` module also used by `auditCiGuardrailsWiring`, so
|
|
33
|
+
* the audit and this injector can never disagree about what counts as a wired
|
|
34
|
+
* guardrails step or a live test-run anchor (tech-debt h-016).
|
|
26
35
|
*/
|
|
27
|
-
export declare function ensureGuardrailsCiStep(content: string): string;
|
|
36
|
+
export declare function ensureGuardrailsCiStep(content: string, repoAlreadyWired?: boolean): string;
|
|
28
37
|
export declare function readAgentConfigVersion(metaUrl: string): string;
|
|
29
38
|
export declare function collectRuntimeContractGuidance(packageJson: PackageJsonLike | null | undefined): RuntimeContractGuidance;
|
|
30
39
|
export declare const BASE_KIT_QUALITY_TARGETS: string[];
|
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
4
4
|
import { writeFileMerged } from "./merge.js";
|
|
5
5
|
import { resolveAgentKitPackageRoot } from "./package-root.js";
|
|
6
|
+
import { findLiveTestRunAnchor, hasGuardrailsInvocation } from "#audit/ci-guardrails-detection.js";
|
|
6
7
|
import { readPackageVersion } from "#cli/utils";
|
|
7
8
|
import { SETUP_WP_ACTION_REF } from "#build/release-action-ref.js";
|
|
8
9
|
const AUTHORING_TIME_DEPENDENCIES = [
|
|
@@ -162,12 +163,6 @@ function migrateWorkflowContent(content, actionRef, version) {
|
|
|
162
163
|
})
|
|
163
164
|
.join("\n");
|
|
164
165
|
}
|
|
165
|
-
// Detects whether a workflow already invokes `wp audit guardrails`, keyed on the
|
|
166
|
-
// command itself (not a `wp` prefix nor a marker comment) — mirrors how
|
|
167
|
-
// `hasAuditBlock()` (audit-hooks scaffolder) self-heals the pre-commit hook by
|
|
168
|
-
// scanning for the invocation string, so re-injection works even if a consumer
|
|
169
|
-
// stripped every surrounding comment.
|
|
170
|
-
const GUARDRAILS_CI_INVOCATION = /\baudit guardrails\b/u;
|
|
171
166
|
// A workflow is a wp-enabled quality pipeline when it both installs the `wp`
|
|
172
167
|
// runtime and runs the default test suite. Gating on a wp-runtime anchor keeps
|
|
173
168
|
// injection off non-wp workflows (a reusable-workflow release.yml, or
|
|
@@ -178,7 +173,6 @@ const GUARDRAILS_CI_INVOCATION = /\baudit guardrails\b/u;
|
|
|
178
173
|
// `package-conventions.md`; e.g. ozby/edge-matte's ci.yml).
|
|
179
174
|
const SETUP_WP_MARKER = "setup-wp@";
|
|
180
175
|
const GLOBAL_AGENT_KIT_INSTALL = /^(?=.*\b(?:npm|pnpm|vp)\s+(?:install|i|add)\b)(?=.*(?:-g\b|--global\b))(?=.*@webpresso\/agent-kit\b).*$/imu;
|
|
181
|
-
const GUARDRAILS_CI_TEST_RUN = /(?:\bvp\s+run\s+test\b|\bpnpm\s+(?:run\s+)?test\b|\bnpm\s+(?:run\s+)?test\b|\bbun\s+(?:run\s+)?test\b|\bwp\s+test\b|\bvitest\s+run\b)/iu;
|
|
182
176
|
function hasWpRuntimeAnchor(content) {
|
|
183
177
|
return content.includes(SETUP_WP_MARKER) || GLOBAL_AGENT_KIT_INSTALL.test(content);
|
|
184
178
|
}
|
|
@@ -189,30 +183,31 @@ function hasWpRuntimeAnchor(content) {
|
|
|
189
183
|
* consumer who deleted the step (with or without its comments) gets it back —
|
|
190
184
|
* and therefore keeps inheriting new guardrails (e.g. typescript-version) on
|
|
191
185
|
* their next agent-kit bump. Returns the content unchanged when the step is
|
|
192
|
-
* already present, the workflow is not a wp quality pipeline,
|
|
193
|
-
* anchor exists
|
|
186
|
+
* already present, the workflow is not a wp quality pipeline, no test step
|
|
187
|
+
* anchor exists, or `repoAlreadyWired` is true (guardrails already run in a
|
|
188
|
+
* sibling workflow of the same repo — see `migrateConsumerWorkflows`, and
|
|
189
|
+
* tech-debt h-017: without this check a consumer who deliberately hosts
|
|
190
|
+
* guardrails in a dedicated `guardrails.yml` got a duplicate step injected
|
|
191
|
+
* into `ci.yml` on every `wp setup`).
|
|
192
|
+
*
|
|
193
|
+
* `hasGuardrailsInvocation`/`findLiveTestRunAnchor` come from the shared
|
|
194
|
+
* `ci-guardrails-detection` module also used by `auditCiGuardrailsWiring`, so
|
|
195
|
+
* the audit and this injector can never disagree about what counts as a wired
|
|
196
|
+
* guardrails step or a live test-run anchor (tech-debt h-016).
|
|
194
197
|
*/
|
|
195
|
-
export function ensureGuardrailsCiStep(content) {
|
|
196
|
-
if (
|
|
198
|
+
export function ensureGuardrailsCiStep(content, repoAlreadyWired = false) {
|
|
199
|
+
if (hasGuardrailsInvocation(content))
|
|
200
|
+
return content;
|
|
201
|
+
if (repoAlreadyWired)
|
|
197
202
|
return content;
|
|
198
203
|
if (!hasWpRuntimeAnchor(content))
|
|
199
204
|
return content;
|
|
200
|
-
const
|
|
201
|
-
let anchorStepStart = -1;
|
|
202
|
-
let lastStepStart = -1;
|
|
203
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
204
|
-
const line = lines[index];
|
|
205
|
-
if (line.trim().startsWith("#"))
|
|
206
|
-
continue;
|
|
207
|
-
if (/^\s*-\s/u.test(line))
|
|
208
|
-
lastStepStart = index;
|
|
209
|
-
if (GUARDRAILS_CI_TEST_RUN.test(line)) {
|
|
210
|
-
anchorStepStart = lastStepStart;
|
|
211
|
-
break;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
205
|
+
const anchorStepStart = findLiveTestRunAnchor(content);
|
|
214
206
|
if (anchorStepStart === -1)
|
|
215
207
|
return content;
|
|
208
|
+
const lines = content.split("\n");
|
|
209
|
+
// Safe: anchorStepStart is an index returned by findLiveTestRunAnchor over
|
|
210
|
+
// this same content.split("\n"), so it is always within lines' bounds here.
|
|
216
211
|
const indent = lines[anchorStepStart].match(/^(\s*)/u)?.[1] ?? "";
|
|
217
212
|
lines.splice(anchorStepStart, 0, `${indent}- name: Run wp audit guardrails`, `${indent} run: wp audit guardrails`);
|
|
218
213
|
return lines.join("\n");
|
|
@@ -221,12 +216,18 @@ function migrateConsumerWorkflows(repoRoot, actionRef, version, options) {
|
|
|
221
216
|
const workflowDir = join(repoRoot, ".github", "workflows");
|
|
222
217
|
if (!existsSync(workflowDir))
|
|
223
218
|
return [];
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
219
|
+
const names = readdirSync(workflowDir).filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"));
|
|
220
|
+
// Read every workflow's original content once, up front, so per-file
|
|
221
|
+
// injection below can see the REPO-LEVEL wiring state (mirrors how
|
|
222
|
+
// `auditCiGuardrailsWiring` reasons across all workflows) instead of only
|
|
223
|
+
// the file it happens to be looking at. Without this, a consumer who
|
|
224
|
+
// deliberately hosts guardrails in a dedicated `guardrails.yml` gets a
|
|
225
|
+
// duplicate step injected into `ci.yml` on every `wp setup` (h-017).
|
|
226
|
+
const entries = names.map((name) => [name, readFileSync(join(workflowDir, name), "utf8")]);
|
|
227
|
+
const repoAlreadyWired = entries.some(([, content]) => hasGuardrailsInvocation(content));
|
|
228
|
+
return entries.map(([name, existing]) => {
|
|
227
229
|
const targetPath = join(workflowDir, name);
|
|
228
|
-
const
|
|
229
|
-
const migrated = ensureGuardrailsCiStep(migrateWorkflowContent(existing, actionRef, version));
|
|
230
|
+
const migrated = ensureGuardrailsCiStep(migrateWorkflowContent(existing, actionRef, version), repoAlreadyWired);
|
|
230
231
|
if (migrated === existing)
|
|
231
232
|
return { targetPath, action: "identical" };
|
|
232
233
|
if (options.dryRun)
|
|
@@ -581,7 +581,9 @@ export function disableManagedHooksFromManifest(input, manifest, vendors) {
|
|
|
581
581
|
return result;
|
|
582
582
|
}
|
|
583
583
|
export function resolvePackageRootForHookLaunchers(options = {}) {
|
|
584
|
-
|
|
584
|
+
// Default cwd so vitest/bunfs hosts that lose moduleUrl still resolve from
|
|
585
|
+
// the monorepo/package checkout the process is running in (CI flake fix).
|
|
586
|
+
return resolveAgentKitPackageRootOrThrow("wp setup: could not locate @webpresso/agent-kit package root for hook launchers.", { cwd: process.cwd(), ...options });
|
|
585
587
|
}
|
|
586
588
|
/**
|
|
587
589
|
* The `wp hook <sub>` subcommand maps each catalogued `wp-*` hook bin to the
|
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Test-only seams following the `claimInstallLock` options precedent. `isAlive`
|
|
3
|
+
* and `now` make liveness/age deterministic; `onBeforeReclaimDelete` fires while
|
|
4
|
+
* the reaper is held, just before a stale lock is removed, so a test can prove
|
|
5
|
+
* reclaim is serialized. Never supplied in production.
|
|
6
|
+
*/
|
|
7
|
+
export interface ScaffolderLockSeams {
|
|
8
|
+
readonly isAlive?: (pid: number) => boolean;
|
|
9
|
+
readonly now?: () => number;
|
|
10
|
+
readonly onBeforeReclaimDelete?: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Serialize a complete read/modify/write transaction for a host config file.
|
|
14
|
+
* These are local single-machine home-config locks (`~/.codex`, `~/.grok`), so
|
|
15
|
+
* owner-pid liveness is meaningful; this is not a shared-filesystem lock.
|
|
16
|
+
*/
|
|
17
|
+
export declare function withScaffolderFileLock<T>(path: string, action: () => T, seams?: ScaffolderLockSeams): T;
|
|
3
18
|
/**
|
|
4
19
|
* Serialize a text-file read/modify/write and install the replacement strictly
|
|
5
20
|
* atomically without weakening an existing file's permissions.
|