feat(doctor): add swamp doctor workflows subcommand (swamp-club#330)#1376
Conversation
Add a new `swamp doctor workflows` subcommand that walks all workflow directories and attempts to load each YAML file through the same parse path used by the workflow loader. Reports parse and construction errors instead of silently skipping them, closing the gap where broken workflow files pass every existing preflight check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
Summary line inconsistency with
doctor extensions(src/presentation/renderers/workflow_doctor.ts):doctor extensionsrenders the final line as0 passed, 5 failed — OVERALL: FAIL, whiledoctor workflowsrenders two lines:Summary: 2/3 workflows loaded successfullythenResult: PASSED. Neither is wrong, but the inconsistency means users who run both commands see different summary idioms. Consider aligning toward the sameN passed, M failed — OVERALL: PASS/FAILshape for future consistency. -
"file"field in JSON output is absolute, but docs show relative (.claude/skills/swamp-troubleshooting/references/health-checks.mdline ~81): The skill reference shows"file": "workflows/workflow-abc.yaml"(repo-relative), but the actual implementation buildsfilePath = \${dir}/${entry.name}`wheredir` is an absolute path. A CI consumer parsing the JSON will see an absolute path. The doc example is slightly misleading; worth correcting to a realistic absolute path or noting that the value is absolute. -
Empty-report log path has double blank line before
Result:(workflow_doctor.ts,completedhandler empty branch): The non-empty branch emits\n${bold(cyan("Summary:"))} ...(one blank line before summary, no blank line beforeResult:). The empty branch emits\n No workflow files foundthen\n${bold(cyan("Result:"))} ...— giving two\n-prefixed lines in a row, which produces an extra blank line beforeResult:. Cosmetic only.
Verdict
PASS — both output modes are correctly implemented, exit codes are right, help text and examples are present and consistent, and the command fills a real gap (findAll() silently drops broken files that workflow validate never sees).
There was a problem hiding this comment.
Code Review
Well-structured PR that follows the existing doctor subcommand pattern closely. The architecture is clean: libswamp async generator → CLI command → renderer, with proper separation of concerns. All files have the license header, imports from libswamp/mod.ts are correct, and both output modes are supported.
Blocking Issues
None.
Suggestions
-
Double YAML parse in
src/libswamp/workflows/doctor.ts:126-129—tryExtractName()callsparseYaml(content)and then line 129 callsparseYaml(content)again for the same file. You could restructure to parse once and use the result for both name extraction andWorkflow.fromData(), falling back tofallbackName()only when the first parse throws. Not blocking since the extra parse is harmless for a diagnostic tool. -
Path construction via string interpolation (
src/libswamp/workflows/doctor.ts:107) —const filePath = \${dir}/${entry.name}`uses a forward-slash literal instead ofjoin(dir, entry.name)from@std/path. CLAUDE.md says to use@std/pathfor all path operations and never hand-roll with"/".basenameis already imported; addingjoinis trivial. This is a minor cross-platform concern — on Windowsdir` could have backslash separators, producing mixed-separator paths. -
Test cleanup pattern — The tests use
try/finallywith bareawait Deno.remove(tmpDir, { recursive: true })for cleanup. The CLAUDE.md testing section notes thatwithTempDircleanup uses.catch(() => {})to absorb EBUSY on Windows, and the siblingdoctor_repair_test.tsfollows this pattern. Consider adding.catch(() => {})to theDeno.removecalls or using awithTempDirhelper to match the established convention. -
Naming consistency — The new renderer file is
workflow_doctor.tswhile the existing one isdoctor_extensions.ts(noun order reversed). Not worth changing now, but worth noting for future doctor subcommands.
All suggestions are minor and don't block merge. The core logic, test coverage (8 + 7 tests), DDD layering, and CI integration are solid.
There was a problem hiding this comment.
Adversarial Review
Critical / High
-
src/libswamp/workflows/doctor.ts:107— Path construction uses string interpolation instead of@std/path.join()const filePath = `${dir}/${entry.name}`;
The file already imports
basenamefrom@std/pathbut does not importjoin. The project's CLAUDE.md explicitly mandates: "Use@std/path(dirname,basename,join,fromFileUrl,SEPARATOR) for all path operations. Never hand-roll withlastIndexOf("/"),split("/").pop(),URL.pathname, or"/"-prefixed concatenation." The siblingextensions/doctor.tscorrectly usesjoinfrom@std/path(line 21).Breaking example: On Windows,
dirarrives fromresolve()with\separators (e.g.C:\repos\workflows). The template literal producesC:\repos\workflows/my-workflow.yaml— a mixed-separator path. This path is both passed toDeno.readTextFile(which may handle it, but it's fragile) and emitted in the user-facingDoctorWorkflowResult.filefield, where it appears in log output and--jsonreports. Users trying to copy-paste that path on Windows will get inconsistent results.Fix: Add
jointo the existing@std/pathimport and replace line 107 with:const filePath = join(dir, entry.name);
Medium
-
src/libswamp/workflows/doctor.ts:95-98— Non-NotFoundreadDirerrors crash the generator with an unformatted stack traceOnly
Deno.errors.NotFoundis caught and skipped. APermissionDeniederror on a workflow directory (plausible for source-mounted or pulled extension dirs with wrong permissions) would throw through the async generator, crashconsumeStream, and produce a raw stack trace instead of the structured report the user expects.The
DoctorWorkflowsEventtype already declares a{ kind: "error" }variant, but it is never emitted. Consider catching permission errors and either yielding a per-directory error event or logging a warning and continuing. -
src/libswamp/workflows/doctor.ts:126+129— YAML is parsed twice on the success pathtryExtractName(content, filePath)on line 126 callsparseYaml(content)to extract the name. Then line 129 callsparseYaml(content)again to get the fullWorkflowData. On the success path both parses succeed, so the first is pure waste. On the failure path (broken YAML), the double-parse is harmless sincetryExtractNamecatches the error. Not a correctness bug, but for repos with many workflows it's double the parse work for no benefit. A single parse with the name extracted from the parsed object would eliminate this.
Low
-
src/libswamp/workflows/doctor.ts:44-47— Theerrorevent kind is declared but never emittedDoctorWorkflowsEventincludes{ kind: "error"; error: SwampError }and the renderer handles it (throw new UserError(e.error.message)), but the generator never yields this event. This is dead code. It's not harmful, but it means the"error"handler in both renderers is unreachable.
Verdict
FAIL — The hardcoded / path separator at doctor.ts:107 violates the project's explicit cross-platform path convention and produces broken paths on Windows, where this code is required to run per CLAUDE.md testing policy. Fix the path construction (a one-line change) and this is ready to merge.
- Use join() from @std/path instead of string interpolation for file
paths (cross-platform fix per CLAUDE.md)
- Parse YAML once on success path instead of double-parsing
- Handle PermissionDenied on workflow directories gracefully (log
warning and continue instead of crashing)
- Align summary format with doctor extensions: "N passed, M failed —
OVERALL: PASS/FAIL"
- Add .catch(() => {}) to test cleanup for Windows EBUSY
- Fix docs to show absolute paths in JSON output example
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
Log mode: missing blank line after header — The docs example in
health-checks.mdshows a blank line betweenChecking workflows...and the first file entry, but the renderer (src/presentation/renderers/workflow_doctor.ts:44) emits the header then the first file result with no gap. Cosmetic only; easy to fix by addingwriteOutput('')after the header is printed. -
health-checks.md: broken inline code span — Lines 220–221 of the updated file have a backtick code span split across two lines:
swamp workflow\nvalidate. In most Markdown renderers this renders fine (newline → space), but it's fragile. Should beswamp workflow validateon a single line.
Verdict
PASS — both output modes are correctly implemented, flag names and option descriptions are consistent with doctor audit and doctor extensions, JSON shape includes all fields a script would need, and exit-on-failure behaviour is correct.
There was a problem hiding this comment.
Code Review
Well-structured PR that follows project conventions closely. The new swamp doctor workflows subcommand cleanly mirrors the existing doctor audit and doctor extensions patterns — application-service generator, dual-mode renderer, proper libswamp export boundary, license headers, and comprehensive test coverage.
Blocking Issues
None.
Suggestions
-
File filter mismatch with the actual workflow loader (
src/libswamp/workflows/doctor.ts:96): The doctor scans all*.yamlfiles in workflow directories, butYamlWorkflowRepository.findAll()only loads files matchingworkflow-*.yaml(requires theworkflow-prefix). This means the doctor will attempt to parse and construct YAML files that the loader would never touch — potentially producing false-positive failures for non-workflow YAML files that happen to live in a workflow directory. Consider either adding theworkflow-prefix filter to align with the repository, or documenting the intentionally broader scope so users understand why the doctor flags files thatworkflow validatenever sees. -
Redundant YAML re-parse in error path (
src/libswamp/workflows/doctor.ts:132-136): WhenWorkflow.fromData()throws (not a YAML parse error), the catch block re-parses the same YAML content to extract the name. Since parsing succeeded in thetryblock, this second parse is guaranteed to succeed but is unnecessary work. You could capturedata.namebefore callingfromData()to avoid the re-parse. -
Potential directory overlap (
src/cli/commands/doctor_workflows.ts:93-97):yamlWorkflowsDiris hardcoded tojoin(repoDir, "workflows")whileworkflowsDircomes fromresolveWorkflowsDir(marker)which defaults to"extensions/workflows". If a user configuresSWAMP_WORKFLOWS_DIR=workflowsor setsworkflowsDir: "workflows"in their marker, both would resolve to the same directory, causing every workflow to be checked twice. Anew Set()dedup on the resolved absolute paths would be a cheap guard.
DDD Assessment
Clean layering: the doctorWorkflows async generator is a well-scoped application service in libswamp that orchestrates the Workflow domain entity for a single use case. DoctorWorkflowResult and DoctorWorkflowsReport are proper value objects. The renderer lives in the presentation layer and imports only from libswamp/mod.ts. No domain model leakage.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
Missing
workflow-prefix filter — false positives on non-workflow YAML files
src/libswamp/workflows/doctor.ts:96The existing
YamlWorkflowRepository.findAll()filters files withentry.name.startsWith("workflow-") && entry.name.endsWith(".yaml")(yaml_workflow_repository.ts:87-88). The doctor command only checks.endsWith(".yaml"):.filter((e) => e.isFile && e.name.endsWith(".yaml"))
Breaking example: If a user has a
config.yaml,README.yaml, orvalues.yamlin their workflows directory, the doctor will attempt to parse them as workflows, report them as failures, and exit 1 — even though the actual workflow loader would never touch those files.Suggested fix: Add
.startsWith("workflow-")to the filter to match the real loader's behavior, or document that the doctor intentionally scans all YAML (and explain why). -
Potential duplicate directory scanning
src/cli/commands/doctor_workflows.ts:74,76-79,93-97yamlWorkflowsDiris hardcoded tojoin(repoDir, "workflows"). IfresolveWorkflowsDir(marker)returns"workflows"(via env varSWAMP_WORKFLOWS_DIR=workflowsormarker.workflowsDir = "workflows"), thenworkflowsDirresolves to the same path. Both are added toworkflowDirsat lines 93-97 without deduplication, so every file in that directory would be checked twice and reported twice — inflating counts and producing confusing output.Suggested fix: Deduplicate the
workflowDirsarray (e.g.,[...new Set(workflowDirs)]).
Low
-
errorevent kind is defined but never emitted
src/libswamp/workflows/doctor.ts:49DoctorWorkflowsEventincludes{ kind: "error"; error: SwampError }, but thedoctorWorkflowsgenerator never yields an event withkind: "error". Unexpected errors (e.g., permission denied on a non-NotFound/non-PermissionDenied error) propagate as thrown exceptions rather than as error events. Theerrorhandlers in both renderers are dead code.This isn't a bug (the
HasTerminalsconstraint requires theerrorkind in the union), but the throw-vs-yield split means the CLI command has no chance to render the error through the renderer'serrorhandler — the exception bypasses the renderer entirely. -
AbortController signal is never aborted
src/cli/commands/doctor_workflows.ts:100The
AbortControlleris created but never wired toSIGINTor any other cancellation source. Compare withdoctor_audit.tswhich explicitly listens for SIGINT and aborts the controller. For workflow directories with many files, Ctrl+C during scanning would leave the async generator mid-iteration rather than cleanly exiting.Not a correctness issue since the process exits anyway on SIGINT, but inconsistent with
doctor_audit.ts.
Verdict
PASS — The implementation is solid, follows the established patterns well, has good test coverage, and handles the important error cases (missing directories, permission denied, broken YAML, invalid schemas). The medium findings are about false-positive noise and cosmetic duplication in output, not correctness or data integrity issues.
Summary
swamp doctor workflowssubcommand that walks all workflow directories (primary, extension, source-mounted, pulled) and checks whether each YAML file can be parsed and constructed into a valid Workflow domain objectYamlWorkflowRepository.findAll()silently skips — meaningswamp workflow validatenever sees these broken files--json(structured output for CI) and log (colored pass/fail per file) output modes; exits non-zero on any failureTest Plan
deno check,deno lint,deno fmtall passintegration/workflow_test.ts)🤖 Generated with Claude Code