Skip to content

Commit f1b2a83

Browse files
authored
fix: reject zero cleanup report limits (#499)
1 parent 981c3c1 commit f1b2a83

7 files changed

Lines changed: 60 additions & 12 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,7 +1794,7 @@ private function registerAbilities(): void {
17941794
),
17951795
'limit' => array(
17961796
'type' => 'integer',
1797-
'description' => 'Page size for bounded dry-run, direct apply, budgeted apply, or job-backed apply.',
1797+
'description' => 'Positive page size for bounded dry-run, direct apply, budgeted apply, or job-backed apply.',
17981798
),
17991799
'offset' => array(
18001800
'type' => 'integer',
@@ -1835,7 +1835,7 @@ private function registerAbilities(): void {
18351835
'properties' => array(
18361836
'limit' => array(
18371837
'type' => 'integer',
1838-
'description' => 'Maximum active_no_signal rows to inspect in this page. Defaults to 25.',
1838+
'description' => 'Positive maximum active_no_signal rows to inspect in this page. Defaults to 25.',
18391839
),
18401840
'offset' => array(
18411841
'type' => 'integer',
@@ -1878,7 +1878,7 @@ private function registerAbilities(): void {
18781878
),
18791879
'limit' => array(
18801880
'type' => 'integer',
1881-
'description' => 'Maximum active_no_signal rows to inspect in this page. Defaults to 25.',
1881+
'description' => 'Positive maximum active_no_signal rows to inspect in this page. Defaults to 25.',
18821882
),
18831883
'offset' => array(
18841884
'type' => 'integer',
@@ -1918,7 +1918,7 @@ private function registerAbilities(): void {
19181918
),
19191919
'limit' => array(
19201920
'type' => 'integer',
1921-
'description' => 'Maximum active_no_signal rows to inspect in this page. Defaults to 25.',
1921+
'description' => 'Positive maximum active_no_signal rows to inspect in this page. Defaults to 25.',
19221922
),
19231923
'offset' => array(
19241924
'type' => 'integer',
@@ -1966,7 +1966,7 @@ private function registerAbilities(): void {
19661966
),
19671967
'limit' => array(
19681968
'type' => 'integer',
1969-
'description' => 'Maximum worktrees to scan in a dry-run page. Defaults to ' . Workspace::ARTIFACT_CLEANUP_DEFAULT_LIMIT . '. Use 0 to disable the cap (still bounded by exhaustive=false unless you also pass exhaustive=true).',
1969+
'description' => 'Positive maximum worktrees to scan in a dry-run page. Defaults to ' . Workspace::ARTIFACT_CLEANUP_DEFAULT_LIMIT . '. Use exhaustive=true for the explicit unbounded full audit mode.',
19701970
),
19711971
'offset' => array(
19721972
'type' => 'integer',

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2145,9 +2145,10 @@ private function renderGitOperationResult( string $operation, array $result, arr
21452145
* [--limit=<count>]
21462146
* : For `cleanup --dry-run`, `cleanup-artifacts --dry-run`,
21472147
* `reconcile-metadata`, and `active-no-signal-report`,
2148-
* maximum worktrees to scan in this page. Artifact cleanup defaults to
2149-
* 100; metadata reconciliation uses this only when pagination is requested.
2150-
* Use 0 plus `--exhaustive` for a full artifact audit.
2148+
* positive maximum worktrees to scan in this page. Artifact cleanup defaults
2149+
* to 100; metadata reconciliation uses this only when pagination is
2150+
* requested. Use `--exhaustive` instead of `--limit=0` for a full artifact
2151+
* audit.
21512152
*
21522153
* [--offset=<count>]
21532154
* : For `cleanup --dry-run`, `cleanup-artifacts --dry-run`,
@@ -2163,7 +2164,8 @@ private function renderGitOperationResult( string $operation, array $result, arr
21632164
*
21642165
* [--exhaustive]
21652166
* : For `cleanup-artifacts --dry-run`, scan every worktree AND run per-worktree
2166-
* git safety probes. Slow on huge workspaces; use sparingly.
2167+
* git safety probes. This is the explicit unbounded artifact audit mode;
2168+
* slow on huge workspaces, so use sparingly.
21672169
*
21682170
* [--safety-probes]
21692171
* : For `cleanup-artifacts --dry-run`, run per-worktree git safety probes

inc/Workspace/Workspace.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1349,8 +1349,11 @@ function () use ( $repo, $branch, $wt_path, $force ) {
13491349
*/
13501350
public function worktree_active_no_signal_report( array $opts = array() ): array|\WP_Error {
13511351
$started_at = microtime(true);
1352-
$limit = array_key_exists('limit', $opts) ? max(1, (int) $opts['limit']) : 25;
1352+
$limit = array_key_exists('limit', $opts) ? (int) $opts['limit'] : 25;
13531353
$offset = array_key_exists('offset', $opts) ? max(0, (int) $opts['offset']) : 0;
1354+
if ( $limit <= 0 ) {
1355+
return new \WP_Error('invalid_active_no_signal_limit', 'Active/no-signal report --limit must be greater than 0.', array( 'status' => 400 ));
1356+
}
13541357
if ( isset($opts['until_budget']) && '' !== trim( (string) $opts['until_budget']) ) {
13551358
$budget_seconds = $this->parse_worktree_metadata_reconciliation_budget(trim( (string) $opts['until_budget']));
13561359
if ( is_wp_error($budget_seconds) ) {

inc/Workspace/WorkspaceArtifactCleanup.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,14 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
4343
$exhaustive = ! empty($opts['exhaustive']);
4444
$limit = isset($opts['limit']) ? (int) $opts['limit'] : self::ARTIFACT_CLEANUP_DEFAULT_LIMIT;
4545
$offset = isset($opts['offset']) ? max(0, (int) $opts['offset']) : 0;
46-
// Allow callers to opt out of bounded mode entirely.
46+
if ( $limit < 0 ) {
47+
return new \WP_Error('invalid_artifact_cleanup_limit', 'Artifact cleanup --limit must be greater than 0. Use --exhaustive for an unbounded full artifact audit.', array( 'status' => 400 ));
48+
}
49+
if ( ! $exhaustive && $limit <= 0 ) {
50+
return new \WP_Error('invalid_artifact_cleanup_limit', 'Artifact cleanup --limit must be greater than 0. Use --exhaustive for an unbounded full artifact audit.', array( 'status' => 400 ));
51+
}
52+
// Allow callers to opt out of bounded mode entirely only through the
53+
// explicit exhaustive path, which also enables safety probes.
4754
if ( $exhaustive ) {
4855
$limit = 0;
4956
}
@@ -191,7 +198,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
191198
* `next_offset` continuation when the scan is partial.
192199
*
193200
* @param bool $force Whether to allow dirty/unpushed worktrees.
194-
* @param array $opts Options: `limit` (0 = unbounded), `offset`,
201+
* @param array $opts Options: `limit` (0 = unbounded internal exhaustive mode), `offset`,
195202
* `only_handles` (array<string>|null), `safety_probes`.
196203
* @return array{candidates: array<int,array>, skipped: array<int,array>, pagination: ?array<string,mixed>}|\WP_Error
197204
*/

tests/smoke-worktree-cleanup-artifacts.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,18 @@ function apply_filters( string $hook_name, $value ) // phpcs:ignore Generic.Cod
236236
$assert('unpushed_commits', $skip_reasons['demo@unpushed'] ?? '', 'exhaustive unpushed worktree is protected');
237237
$assert('active_symlink_target', $skip_reasons['demo@active'] ?? '', 'exhaustive active plugin symlink target is protected');
238238

239+
$exhaustive_limit_zero = $workspace->worktree_cleanup_artifacts(array( 'dry_run' => true, 'limit' => 0, 'exhaustive' => true ));
240+
$assert(false, is_wp_error($exhaustive_limit_zero), 'limit=0 is accepted only with exhaustive artifact cleanup');
241+
$assert('exhaustive', $exhaustive_limit_zero['pagination']['mode'] ?? '', 'limit=0 plus exhaustive reports exhaustive mode');
242+
243+
$limit_zero = $workspace->worktree_cleanup_artifacts(array( 'dry_run' => true, 'limit' => 0 ));
244+
$assert(true, is_wp_error($limit_zero), 'limit=0 without exhaustive is rejected');
245+
$assert('invalid_artifact_cleanup_limit', $limit_zero->code ?? '', 'limit=0 rejection uses explicit artifact cleanup error');
246+
$negative_limit = $workspace->worktree_cleanup_artifacts(array( 'dry_run' => true, 'limit' => -1 ));
247+
$assert(true, is_wp_error($negative_limit), 'negative artifact cleanup limit is rejected');
248+
$negative_exhaustive_limit = $workspace->worktree_cleanup_artifacts(array( 'dry_run' => true, 'limit' => -1, 'exhaustive' => true ));
249+
$assert(true, is_wp_error($negative_exhaustive_limit), 'negative artifact cleanup limit is rejected even with exhaustive');
250+
239251
// Pagination smoke: limit=1 returns a partial scan with continuation.
240252
$page_one = $workspace->worktree_cleanup_artifacts(array( 'dry_run' => true, 'limit' => 1, 'offset' => 0 ));
241253
$assert(false, is_wp_error($page_one), 'page-1 dry-run succeeds');

tests/smoke-worktree-cleanup-cli.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,9 @@ public function execute( array $input ): array
653653
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '<plan|apply|run|status|resume|cancel|evidence>'), 'workspace cleanup synopsis exposes DB-backed and task-backed cleanup operations');
654654
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '[--dry-run]'), 'task-backed cleanup synopsis keeps synchronous dry-run review');
655655
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, 'apply runs freeze eligible candidates'), 'workspace cleanup limit help clarifies artifact apply scoping');
656+
datamachine_code_cleanup_assert(str_contains($doc_comment, 'positive maximum worktrees to scan'), 'worktree limit help requires positive page sizes');
657+
datamachine_code_cleanup_assert(str_contains($doc_comment, 'Use `--exhaustive` instead of `--limit=0`'), 'worktree limit help points unbounded artifact scans to exhaustive mode');
658+
datamachine_code_cleanup_assert(str_contains($doc_comment, 'explicit unbounded artifact audit mode'), 'worktree exhaustive help documents unbounded artifact audit mode');
656659
datamachine_code_cleanup_assert(str_contains($doc_comment, 'Daily cleanup path: DB-backed plan, then apply only those rows after revalidation'), 'worktree examples point daily cleanup to DB-backed run_id controller path');
657660
datamachine_code_cleanup_assert(str_contains($doc_comment, 'workspace cleanup plan --mode=retention'), 'worktree examples include DB-backed cleanup plan');
658661
datamachine_code_cleanup_assert(str_contains($doc_comment, 'workspace cleanup run --mode=retention'), 'worktree examples include task-backed cleanup run');
@@ -932,6 +935,12 @@ public function execute( array $input ): array
932935
datamachine_code_cleanup_assert(str_contains(WP_CLI::$successes[0] ?? '', 'low-level escape hatch'), 'cleanup-artifacts dry-run demotes apply-plan wording');
933936
datamachine_code_cleanup_assert(! str_contains(WP_CLI::$successes[0] ?? '', 'Save JSON'), 'cleanup-artifacts dry-run does not normalize saving plan files');
934937

938+
WP_CLI::$logs = array();
939+
WP_CLI::$successes = array();
940+
$command->worktree(array( 'cleanup-artifacts' ), array( 'dry-run' => true, 'limit' => 0, 'exhaustive' => true, 'format' => 'json' ));
941+
datamachine_code_cleanup_assert(0 === (int) ( $artifact_ability->last_input['limit'] ?? -1 ), 'cleanup-artifacts forwards limit=0 when exhaustive is explicit');
942+
datamachine_code_cleanup_assert(true === ( $artifact_ability->last_input['exhaustive'] ?? false ), 'cleanup-artifacts forwards exhaustive flag');
943+
935944
$artifact_plan_file = sys_get_temp_dir() . '/dmc-artifact-cleanup-plan-' . bin2hex(random_bytes(3)) . '.json';
936945
file_put_contents($artifact_plan_file, wp_json_encode($artifact_json));
937946
WP_CLI::$logs = array();

tests/smoke-worktree-metadata-reconcile.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,13 @@ function () use ( $tmp ) {
558558
$assert(true, ! is_wp_error($budgeted_active_report) && ( $budgeted_active_report['success'] ?? false ), 'budgeted active/no-signal report succeeds');
559559
$assert(true, (bool) ( $budgeted_active_report['pagination']['partial'] ?? false ), 'budgeted active/no-signal report returns partial pagination');
560560
$assert(true, isset($budgeted_active_report['evidence']['budget']['budget_exhausted']), 'budgeted active/no-signal report exposes budget evidence');
561+
$bad_active_zero = $ws->worktree_active_no_signal_report(array( 'limit' => 0, 'offset' => 0 ));
562+
$assert(true, is_wp_error($bad_active_zero), 'active/no-signal report rejects limit=0');
563+
$assert('invalid_active_no_signal_limit', $bad_active_zero->code ?? '', 'active/no-signal limit=0 rejection uses explicit error code');
564+
$bad_active_negative = $ws->worktree_active_no_signal_report(array( 'limit' => -1, 'offset' => 0 ));
565+
$assert(true, is_wp_error($bad_active_negative), 'active/no-signal report rejects negative limits');
566+
$bad_active_budget = $ws->worktree_active_no_signal_report(array( 'limit' => 0, 'until_budget' => '1s' ));
567+
$assert(true, is_wp_error($bad_active_budget), 'budgeted active/no-signal report still requires a positive page size');
561568
$run('git remote set-url origin https://github.com/acme/demo.git', $primary);
562569
$finalized_dry_run = $ws->worktree_active_no_signal_finalized_apply(array( 'dry_run' => true, 'limit' => 50, 'offset' => 0 ));
563570
$run(sprintf('git remote set-url origin %s', escapeshellarg($remote)), $primary);
@@ -625,6 +632,14 @@ function () use ( $tmp ) {
625632
$assert(true, (bool) ( $budgeted_page['pagination']['partial'] ?? false ), 'budgeted metadata reconciliation dry-run returns partial pagination');
626633
$assert(true, isset($budgeted_page['evidence']['budget']['budget_exhausted']), 'budgeted metadata reconciliation dry-run exposes budget evidence');
627634

635+
$bad_reconcile_zero = $ws->worktree_reconcile_metadata(array( 'dry_run' => true, 'limit' => 0, 'offset' => 0 ));
636+
$assert(true, is_wp_error($bad_reconcile_zero), 'metadata reconciliation rejects limit=0 when pagination is requested');
637+
$assert('invalid_metadata_reconcile_limit', $bad_reconcile_zero->code ?? '', 'metadata reconciliation limit=0 rejection keeps explicit error code');
638+
$bad_reconcile_negative = $ws->worktree_reconcile_metadata(array( 'dry_run' => true, 'limit' => -1, 'offset' => 0 ));
639+
$assert(true, is_wp_error($bad_reconcile_negative), 'metadata reconciliation rejects negative limits');
640+
$bad_reconcile_budget = $ws->worktree_reconcile_metadata(array( 'apply' => true, 'limit' => 0, 'until_budget' => '1s' ));
641+
$assert(true, is_wp_error($bad_reconcile_budget), 'budgeted metadata reconciliation direct apply requires a positive page size');
642+
628643
\DataMachine\Engine\Tasks\TaskScheduler::$batches = array();
629644
$job_backed = $ws->worktree_reconcile_metadata(array( 'apply' => true, 'via_jobs' => true, 'limit' => 3 ));
630645
$assert(true, ! is_wp_error($job_backed) && ( $job_backed['success'] ?? false ), 'job-backed reconciliation scheduling succeeds');

0 commit comments

Comments
 (0)