diff --git a/inc/Runtime/AgentsMdSections.php b/inc/Runtime/AgentsMdSections.php index 4732debd..9a8acd15 100644 --- a/inc/Runtime/AgentsMdSections.php +++ b/inc/Runtime/AgentsMdSections.php @@ -93,36 +93,15 @@ private static function register_datamachine_section( string $wp ): void { $workspace_policy_intro = self::render_workspace_policy_intro($workspace_path); $workspace_policy_section = self::render_workspace_policy_section($workspace_path, $wp); - // Generate DMC's own command surface from reflection so the lists - // never drift from the registered truth (see #671, #734). The - // fallback pipe-lists are only used if a command class is somehow - // unavailable in this compose context. Core's `datamachine` - // namespace is NOT narrated here — core registers its own - // reflected AGENTS.md section (data-machine#2640); hand-copying it - // here is a layer inversion that silently drifts (e.g. the removed - // `datamachine analytics` line, now owned by data-machine-business). - $workspace_subcmds = CommandIntrospector::pipe_list( - '\\DataMachineCode\\Cli\\Commands\\WorkspaceCommand', - 'adopt|clone|list|show|path|hygiene|remove|worktree|read|write|grep|edit|git|patch|ls' - ); - $worktree_subcmds = CommandIntrospector::match_arm_pipe_list( - '\\DataMachineCode\\Cli\\Commands\\WorkspaceCommand', - 'worktree', - 'add|list|remove|prune|cleanup|cleanup-artifacts|reconcile-metadata|refresh-context|finalize|mark-cleanup-eligible' - ); - $github_subcmds = CommandIntrospector::pipe_list( - '\\DataMachineCode\\Cli\\Commands\\GitHubCommand', - 'issues|pulls|repos|status|view|close|review-flow|comment' - ); return <<@` handle convention. -- **Worktrees:** `{$wp} datamachine-code workspace worktree {$worktree_subcmds}` — create isolated branches, refresh agent context, attach lifecycle metadata, and clean up safely. -- **GitHub:** `{$wp} datamachine-code github {$github_subcmds}` — list/read GitHub state, manage issues and PRs, install review flows, and comment on reviews. +- **Workspace:** `{$wp} datamachine-code workspace --help` is the live lifecycle and file-I/O command reference. It enforces `@` handles and maintains workspace registry state. +- **Worktrees:** `{$wp} datamachine-code workspace worktree --help` is the live branch lifecycle reference. +- **GitHub:** `{$wp} datamachine-code github --help` is the live GitHub command reference. - **Editing inside a worktree:** any tool. Local agents on the same disk should use native file I/O and raw `git`; routing edits through workspace abilities is ceremony, not safety. - **Workspace lifecycle:** use `workspace clone` for primary checkout adoption/cloning and `workspace worktree add` for isolated branches. Use the CLI `--help` output for current flags and subcommands. {$workspace_policy_section} @@ -365,17 +344,20 @@ private static function render_workspace_inventory_section( string $wp ): string ksort($by_repo, SORT_NATURAL | SORT_FLAG_CASE); - $mode = apply_filters('datamachine_code_workspace_inventory_mode', 'compact'); - if ( ! in_array($mode, array( 'compact', 'full' ), true) ) { - $mode = 'compact'; + // Full snapshots remain available to sites that explicitly need them. + $mode = apply_filters('datamachine_code_workspace_inventory_mode', 'summary'); + if ( ! in_array($mode, array( 'summary', 'full' ), true) ) { + $mode = 'summary'; } $lines = array(); $attention_lines = array(); + $worktree_count = 0; foreach ( $by_repo as $repo => $bucket ) { $primary = is_array($bucket['primary']) ? $bucket['primary'] : array(); $worktrees = $bucket['worktrees']; $wt_count = count($worktrees); + $worktree_count += $wt_count; $branch = $primary['branch'] ?? null; $remote = $primary['remote'] ?? null; $branch_str = ( null !== $branch && '' !== $branch ) ? sprintf(' (`%s`)', $branch) : ''; @@ -386,17 +368,7 @@ private static function render_workspace_inventory_section( string $wp ): string $attention_lines[] = $attention; } - if ( 'compact' === $mode ) { - $suffix_parts = array(); - $suffix_parts[] = sprintf('%d %s', $wt_count, 1 === $wt_count ? 'worktree' : 'worktrees'); - if ( null !== $remote && '' !== $remote ) { - $suffix_parts[] = $remote; - } - $freshness_badge = self::format_primary_freshness_badge($freshness); - if ( '' !== $freshness_badge ) { - $suffix_parts[] = $freshness_badge; - } - $lines[] = sprintf('- **%s**%s — %s', $repo, $branch_str, implode(' · ', $suffix_parts)); + if ( 'summary' === $mode ) { continue; } @@ -427,18 +399,34 @@ private static function render_workspace_inventory_section( string $wp ): string } $body = implode("\n", $lines); - $attention_block = self::render_primary_freshness_attention_block($attention_lines); - $generated_at = gmdate('c'); + $attention_block = 'full' === $mode + ? self::render_primary_freshness_attention_block($attention_lines) + : self::render_primary_freshness_summary($attention_lines, $wp); $workspace_path = $listing['path']; $agent_slug = self::resolve_agent_slug(); $agent_suffix = '' !== $agent_slug ? ' --agent=' . $agent_slug : ''; + if ( 'summary' === $mode ) { + $primary_count = count($by_repo); + $primary_label = 1 === $primary_count ? 'checkout' : 'checkouts'; + $worktree_label = 1 === $worktree_count ? 'worktree' : 'worktrees'; + return <<` for one checkout, or `{$wp} datamachine-code workspace hygiene` for freshness and cleanup diagnostics. + +Snapshot summary: {$primary_count} primary {$primary_label} and {$worktree_count} {$worktree_label} under `{$workspace_path}`. + +{$attention_block} +MD; + } + return << 'short description', ... ]`, preserving declaration order. - * - * Each public method annotated with `@subcommand ` becomes an entry. - * The description is the first non-tag line of the method's PHPDoc summary. - * - * Returns an empty array when the class is unavailable or has no subcommands, - * so callers can fall back gracefully without fatals in any context. - * - * @param string $command_class Fully-qualified command class name. - * @return array Ordered map of subcommand => description. - */ - public static function subcommands( string $command_class ): array { - if ( ! class_exists('WP_CLI_Command', false) ) { - return array(); - } - - // class_exists() triggers autoloading; once it returns true the - // ReflectionClass constructor cannot throw, so no try/catch is needed. - if ( ! class_exists($command_class) ) { - return array(); - } - - $reflection = new \ReflectionClass($command_class); - $subcommands = array(); - - foreach ( $reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method ) { - if ( $method->isStatic() || $method->getDeclaringClass()->getName() !== $reflection->getName() ) { - continue; - } - - $doc = $method->getDocComment(); - if ( false === $doc || '' === $doc ) { - continue; - } - - $name = self::extract_subcommand_name($doc); - if ( '' === $name ) { - continue; - } - - $subcommands[ $name ] = self::extract_summary($doc); - } - - return $subcommands; - } - - /** - * Return only the subcommand names for a class, in declaration order. - * - * @param string $command_class Fully-qualified command class name. - * @return string[] Ordered list of subcommand names. - */ - public static function subcommand_names( string $command_class ): array { - return array_keys(self::subcommands($command_class)); - } - - /** - * Render a `a|b|c` pipe-list of a class's subcommand names. - * - * Falls back to the supplied default string when reflection yields nothing, - * so AGENTS.md never renders an empty command line. - * - * @param string $command_class Fully-qualified command class name. - * @param string $fallback Pipe-list to use when reflection is unavailable. - * @return string - */ - public static function pipe_list( string $command_class, string $fallback = '' ): string { - $names = self::subcommand_names($command_class); - if ( empty($names) ) { - return $fallback; - } - - return implode('|', $names); - } - - /** - * Render a `a|b|c` pipe-list of the string-literal `match` arm keys inside a - * command method, in declaration order, de-duplicated. - * - * Some `@subcommand` methods dispatch a second operand (e.g. `workspace - * worktree `) through an internal `match ( $operation ) { ... }` rather - * than separate annotated methods. The arm keys ARE the real operation - * surface, so reflecting them keeps AGENTS.md truthful without hand-typing - * the list (see Extra-Chill/data-machine-code#734). - * - * Falls back to the supplied default string when reflection yields nothing, - * so AGENTS.md never renders an empty command line. - * - * @param string $command_class Fully-qualified command class name. - * @param string $method Method whose body holds the dispatch `match`. - * @param string $fallback Pipe-list to use when reflection is unavailable. - * @return string - */ - public static function match_arm_pipe_list( string $command_class, string $method, string $fallback = '' ): string { - $keys = self::match_arm_keys($command_class, $method); - if ( empty($keys) ) { - return $fallback; - } - - return implode('|', $keys); - } - - /** - * Extract the string-literal arm keys of the first `match` expression in a - * method body, in source order and de-duplicated. - * - * Reads the method's source range via reflection and scans the `match (...)` - * block for `'literal' =>` / `"literal" =>` arm keys (including comma-grouped - * keys mapping to one result). The `default =>` arm is ignored. - * - * Returns an empty array when the class/method/source is unavailable, so - * callers can fall back gracefully without fatals in any context. - * - * @param string $command_class Fully-qualified command class name. - * @param string $method Method name to inspect. - * @return string[] Ordered, de-duplicated list of match arm keys. - */ - public static function match_arm_keys( string $command_class, string $method ): array { - if ( ! class_exists('WP_CLI_Command', false) ) { - return array(); - } - - // class_exists() triggers autoloading; once it returns true the - // ReflectionClass constructor cannot throw, so no try/catch is needed. - if ( ! class_exists($command_class) ) { - return array(); - } - - $reflection = new \ReflectionClass($command_class); - if ( ! $reflection->hasMethod($method) ) { - return array(); - } - - $reflection_method = $reflection->getMethod($method); - $file = $reflection_method->getFileName(); - $start_line = $reflection_method->getStartLine(); - $end_line = $reflection_method->getEndLine(); - - if ( false === $file || ! is_readable($file) || $start_line < 1 || $end_line < $start_line ) { - return array(); - } - - $source_lines = file($file, FILE_IGNORE_NEW_LINES); - if ( false === $source_lines ) { - return array(); - } - - $body = implode("\n", array_slice($source_lines, $start_line - 1, ( $end_line - $start_line ) + 1)); - - // Isolate the first `match (...)` block so we don't pick up unrelated - // associative arrays elsewhere in the method body. - if ( ! preg_match('/\bmatch\s*\(/', $body, $match_pos, PREG_OFFSET_CAPTURE) ) { - return array(); - } - $body = substr($body, (int) $match_pos[0][1]); - - // Each arm key is a single- or double-quoted literal immediately - // preceding `=>` (comma-grouped keys each match individually). - if ( ! preg_match_all('/([\'"])([^\'"\\\\]+)\1\s*=>/', $body, $arm_matches) ) { - return array(); - } - - $keys = array(); - foreach ( $arm_matches[2] as $key ) { - $key = trim($key); - if ( '' === $key || in_array($key, $keys, true) ) { - continue; - } - $keys[] = $key; - } - - return $keys; - } - - /** - * Pull the `@subcommand ` value out of a PHPDoc block. - * - * @param string $doc Raw docblock text. - * @return string Subcommand name, or '' when not annotated. - */ - private static function extract_subcommand_name( string $doc ): string { - if ( preg_match('/@subcommand\s+(\S+)/', $doc, $matches) ) { - return trim($matches[1]); - } - - return ''; - } - - /** - * Extract the first non-empty, non-tag summary line from a docblock. - * - * @param string $doc Raw docblock text. - * @return string Short description, or '' when none is present. - */ - private static function extract_summary( string $doc ): string { - $lines = preg_split('/\r\n|\r|\n/', $doc); - if ( false === $lines ) { - return ''; - } - - foreach ( $lines as $line ) { - $line = trim($line); - $line = ltrim($line, '/*'); - $line = trim($line); - - if ( '' === $line ) { - continue; - } - - // Skip annotation/usage lines; we only want the prose summary. - if ( '@' === $line[0] || '#' === $line[0] ) { - continue; - } - - return $line; - } - - return ''; - } -} diff --git a/tests/smoke-agents-md-sections.php b/tests/smoke-agents-md-sections.php index 0575b295..d03eada7 100644 --- a/tests/smoke-agents-md-sections.php +++ b/tests/smoke-agents-md-sections.php @@ -62,7 +62,6 @@ function assert_not_contains( string $needle, string $haystack, string $message } } - require_once dirname(__DIR__) . '/inc/Runtime/CommandIntrospector.php'; require_once dirname(__DIR__) . '/inc/Runtime/AgentsMdSections.php'; \DataMachineCode\Runtime\AgentsMdSections::register(); @@ -85,11 +84,10 @@ function assert_not_contains( string $needle, string $haystack, string $message $default, 'default workspace policy section missing' ); - assert_contains( - '- **Workspace:** `wp datamachine-code workspace adopt|clone|list|show|path|hygiene|remove|worktree|read|write|grep|edit|git|patch|ls`', - $default, - 'DMC workspace command facts missing' - ); + assert_contains('- **Workspace:** `wp datamachine-code workspace --help` is the live lifecycle and file-I/O command reference.', $default, 'workspace command discovery pointer missing'); + assert_contains('- **Worktrees:** `wp datamachine-code workspace worktree --help` is the live branch lifecycle reference.', $default, 'worktree command discovery pointer missing'); + assert_contains('- **GitHub:** `wp datamachine-code github --help` is the live GitHub command reference.', $default, 'GitHub command discovery pointer missing'); + assert_not_contains('adopt|clone|list|show|path|hygiene', $default, 'enumerated workspace commands returned'); add_test_filter( 'datamachine_code_workspace_policy_intro', @@ -108,7 +106,7 @@ static function (): string { assert_contains('Use local project policy for `unavailable; run datamachine-code workspace path to diagnose`. DMC owns workspace lifecycle', $filtered, 'workspace policy intro filter was not applied'); assert_contains('- **Local policy:** caller-owned workspace rules.', $filtered, 'workspace policy section filter was not applied'); assert_not_contains('- **Primary is read-only.** Never edit `/` (no `@slug`).', $filtered, 'default policy section remained after filter override'); - assert_contains('- **Workspace:** `wp datamachine-code workspace adopt|clone|list|show|path|hygiene|remove|worktree|read|write|grep|edit|git|patch|ls`', $filtered, 'DMC command facts changed after policy filter'); + assert_contains('- **Workspace:** `wp datamachine-code workspace --help` is the live lifecycle and file-I/O command reference.', $filtered, 'DMC command discovery pointer changed after policy filter'); fwrite(STDOUT, "agents-md sections smoke passed\n"); } diff --git a/tests/smoke-agents-md-workspace-freshness.php b/tests/smoke-agents-md-workspace-freshness.php index 5fbd4d16..db975f69 100644 --- a/tests/smoke-agents-md-workspace-freshness.php +++ b/tests/smoke-agents-md-workspace-freshness.php @@ -88,6 +88,10 @@ function is_multisite(): bool { } function apply_filters( string $hook_name, mixed $value, mixed ...$args ): mixed { + if ( 'datamachine_code_workspace_inventory_mode' === $hook_name && isset($GLOBALS['datamachine_code_test_inventory_mode']) ) { + return $GLOBALS['datamachine_code_test_inventory_mode']; + } + return $value; } @@ -107,7 +111,6 @@ function assert_not_contains( string $needle, string $haystack, string $message } } - require_once dirname(__DIR__) . '/inc/Runtime/CommandIntrospector.php'; require_once dirname(__DIR__) . '/inc/Runtime/AgentsMdSections.php'; \DataMachineCode\Runtime\AgentsMdSections::register(); @@ -117,13 +120,24 @@ function assert_not_contains( string $needle, string $haystack, string $message throw new RuntimeException('workspace-inventory section was not registered'); } - $rendered = $sections['workspace-inventory']['callback'](); + $render = $sections['workspace-inventory']['callback']; + $rendered = $render(); + + assert_contains('Live workspace state is intentionally not embedded here.', $rendered, 'live inventory pointer missing'); + assert_contains('workspace list` for the authoritative inventory', $rendered, 'workspace list pointer missing'); + assert_contains('Snapshot summary: 2 primary checkouts and 1 worktree under `/tmp/dmc-workspace`.', $rendered, 'deterministic workspace summary changed'); + assert_contains('Primary checkout attention: 1 checkout needs inspection.', $rendered, 'bounded primary attention summary missing'); + assert_not_contains('https://example.com/stale-repo.git', $rendered, 'default inventory embedded repository detail'); + if ( strlen($rendered) > 700 ) { + throw new RuntimeException('default workspace inventory exceeded its compact size budget'); + } - assert_contains('**Primary Checkout Attention**', $rendered, 'primary freshness attention block missing'); - assert_contains('These primary checkouts may be stale or unsafe to read.', $rendered, 'primary freshness guidance missing'); - assert_contains('- **stale-repo** primary is `stale` (branch `trunk`, upstream `origin/trunk`, behind 7, ahead 0). Refresh: `wp datamachine-code workspace git pull stale-repo --allow-primary-refresh`.', $rendered, 'stale primary details missing'); - assert_contains('- **stale-repo** (`trunk`) — 1 worktree · https://example.com/stale-repo.git · primary stale, behind 7', $rendered, 'compact stale primary badge missing'); - assert_not_contains('- **current-repo** primary is `current`', $rendered, 'current primary should not appear in attention block'); + $GLOBALS['datamachine_code_test_inventory_mode'] = 'full'; + $full = $render(); + assert_contains('Detailed snapshot from cloned repos in `/tmp/dmc-workspace`.', $full, 'detailed snapshot heading missing'); + assert_contains('**Primary Checkout Attention**', $full, 'detailed primary freshness attention block missing'); + assert_contains('- **stale-repo** primary is `stale` (branch `trunk`, upstream `origin/trunk`, behind 7, ahead 0). Refresh: `wp datamachine-code workspace git pull stale-repo --allow-primary-refresh`.', $full, 'detailed stale primary facts missing'); + assert_contains('- **stale-repo** (`trunk`) — https://example.com/stale-repo.git · primary stale, behind 7', $full, 'detailed repository facts missing'); fwrite(STDOUT, "agents-md workspace freshness smoke passed\n"); }