fix(targets): validate apm.yml target: at parse time, share normalization with CLI (closes #820)#987
Conversation
2cbe2bb to
3952924
Compare
APM Review Panel VerdictDisposition: APPROVE (with two minor pre-merge fixes) Per-persona findingsPython Architect: The PR is a targeted fix with one new pure function, two call-site updates, and a fallback removal. Not a major architectural change; one class diagram + one flow diagram apply. 1. OO / class diagramclassDiagram
direction LR
class APMPackage {
<<Dataclass>>
+target Union[str, List[str], None]
+from_apm_yml(path) APMPackage
}
class TargetParamType {
<<ClickParamType>>
+convert(value, param, ctx)
}
class parse_target_field {
<<Pure>>
+value Union[str,List,None]
+source_path Optional[Path]
returns Union[str,List,None]
}
class normalize_target_list {
<<Pure>>
+value Union[str,List,None]
returns Optional[List[str]]
}
class detect_target {
<<IOBoundary>>
+project_root Path
returns Tuple[TargetType, str]
}
class active_targets {
<<Pure>>
+project_root Path
+explicit_target Union[str,List,None]
returns List[TargetProfile]
}
class active_targets_user_scope {
<<Pure>>
+explicit_target Union[str,List,None]
returns List[TargetProfile]
}
TargetParamType ..> parse_target_field : delegates (new)
APMPackage ..> parse_target_field : calls at parse time (new)
active_targets ..> normalize_target_list : uses
active_targets_user_scope ..> normalize_target_list : uses
detect_target ..> APMPackage : indirectly consumes via caller
class parse_target_field:::touched
class TargetParamType:::touched
class APMPackage:::touched
class active_targets:::touched
class active_targets_user_scope:::touched
classDef touched fill:#fff3b0,stroke:#d47600
Note: 2. Execution flow diagramflowchart TD
A([User: apm install / apm compile]) --> B{Entry point?}
B -->|--target flag| C[TargetParamType.convert\ncommands/compile/cli.py]
B -->|apm.yml present| D[APMPackage.from_apm_yml\nmodels/apm_package.py]
B -->|neither| E[auto-detect from folders\ncore/target_detection.detect_target]
C --> F[parse_target_field\ncore/target_detection.py]
D --> F
F --> G{Valid tokens?}
G -->|No| H[ValueError:\nInvalid target in path: bogus...\nChoose from: agents,all,claude...]
G -->|Empty| H
G -->|all+others| H
H -->|compile outer except| I[logger.error: Error during compilation: ...]
H -->|install pipeline| J[propagates as RuntimeError\ninstall/pipeline.py]
G -->|Yes| K{Single token?}
K -->|Yes| L[return token as-is\nno alias resolution]
K -->|No, multi-token CSV or list| M[resolve aliases + dedupe\nreturn List or collapsed str]
L --> N[install/phases/targets.py\nresolve_targets]
M --> N
E --> N
N --> O{Targets list empty?}
O -->|Yes| P[ctx.logger.warning:\nNo scope targets resolved --\nnothing will be deployed]
O -->|No| Q[ctx.logger.verbose_detail:\nActive targets: ...]
Q --> R[integrators initialized\nFS mkdir for target dirs]
R --> S([deploy / compile output])
S --> T{compile: files written > 0?}
T -->|Yes| U[logger.success:\nCompilation completed successfully!]
T -->|No| V[logger.warning:\nCompilation completed but produced no output files.\nCheck target dirs or set target:]
3. Design patterns
One structural concern (non-blocking): One design smell (non-blocking): Single-token input is intentionally NOT alias-resolved ( CLI Logging Expert: Output routing is correct across all new paths. Key findings:
No blocking logging concerns. DevX UX Expert: The UX direction is correct -- converting silent zero-deployments into actionable errors is the right call. Specific findings:
Supply Chain Security Expert: Clean from a supply-chain perspective. Positive security delta:
No blocking security concerns. This PR improves APM's fail-closed posture on config validation. Auth Expert: Not activated -- all changed files ( OSS Growth Hacker: Strong trust signal worth amplifying. Findings:
CEO arbitrationThe panel is in strong agreement: this PR fixes a real and damaging bug (#820) correctly, using the right pattern (SSOT validator in Required actions before merge
Optional follow-ups
|
…tion with CLI (closes microsoft#820) apm install and apm compile exited 0 with success messages while deploying nothing whenever target: in apm.yml was a CSV string (e.g. "opencode,claude,copilot,agents"). --target was already validated by TargetParamType, but apm.yml bypassed it -- the raw value flowed through, downstream resolution silently produced [], and the install pipeline's verbose log gate hid the empty-targets state. This PR introduces parse_target_field as the single source of truth for the target field, used by both TargetParamType and APMPackage.from_apm_yml. active_targets and the user-scope mirror are simplified -- the asymmetric else [copilot] fallback is removed, since the parser is now the gatekeeper. phases/targets emits a warning when zero targets resolve; compile/cli drops the except Exception: pass swallow and gates the success message on a pattern-based file-count scan. Spec line 116 of manifest-schema is revised: unknown values now MUST raise a parse error. Three previously-silent inputs now fail loud: unknown tokens, empty values (target: "" / target: []), and "all" mixed with other targets. CHANGELOG migration note covers all three.
…ces (microsoft#820) Aligns the apm-usage skill with the updated manifest-schema.md spec so agents trained on the shipped resource give correct guidance for the new parse-time contract: - package-authoring.md gains a "Manifest fields: target: validation contract" section covering accepted forms (single token, list, CSV string) and the three fail-loud cases (unknown token, empty value, all mixed with other targets). - workflow.md's existing "Target auto-detection" section gains an invalid-input table mirroring the same contract. - commands.md is unchanged: it documents only the --target CLI flag, not the apm.yml manifest field. Required action from the PR review panel; satisfies repo Rule 4 (skill resources MUST track spec changes in the same PR).
73ce8fc to
cf4dd14
Compare
TL;DR
apm installandapm compileexited 0 with success messages while deploying nothing whenevertarget:inapm.ymlwas a CSV string (e.g.target: opencode,claude,copilot,agents). The CLI's--targetwas already validated byTargetParamType, butapm.ymlbypassed it entirely —data.get('target')flowed through unchanged, and downstream resolution silently produced[]. This PR introduces a single shared parser (parse_target_field) used by both entry points, removes the asymmetric[copilot]fallback, and fails loudly on unknown / empty /all-mixed inputs.Important
This is a manifest-contract change. Three previously-silent inputs now raise at parse time (see Trade-offs and the CHANGELOG
### Changedentry).Problem (WHY)
The bug spans three layers, each compounding the previous:
apm_package.pyfrom_apm_ymlstoredtarget=data.get('target')untouched. TheUnion[str, List[str]]type hint was advisory — no validation, no normalization.integration/targets.pyhad two branches; the list branch fell back to[copilot]for all-unknown input, but the single-string branch returned[]. A CSV string never matched any known token, hit the string branch, and silently produced[].install/phases/targets.py:52readif ctx.logger and _targets:— when_targets == []the "Active targets:" line never printed, so even--verbosegave no signal.commands/compile/cli.py:389wrappedAPMPackage.from_apm_ymlintry / except Exception: pass, so any future parse failure would be silently routed to auto-detect.apm compileprinted[+] Compilation completed successfully!even when zero files were emitted — the worst-case package-manager DX.Why these matter together: the user's
apm.ymlhadtarget: opencode,claude,copilot,agents(CSV); install/compile both exited 0;.opencode/,.claude/,.github/skills/were empty. The chain is documented in the maintainer's review-panel verdict on #820 which approved the C+ direction (shared normalization + fail-hard at parse + spec revision).Approach (WHAT)
parse_target_field(value, *, source_path=None)next toTargetParamType— the single source of truth for thetargetfield.TargetParamType.convertbecomes a thin delegator;APMPackage.from_apm_ymlcalls the parser with the apm.yml path so error messages name the file.active_targetsandactive_targets_user_scopecanonicalize string-or-list to one code path; the asymmetricelse [copilot]fallback is removed.phases/targets.pyalways emits — empty resolved targets routes throughlogger.warninginstead of falling silent.compile/cli.pydrops theexcept Exception: passswallow and gates the success message onsum(stats.*_files_written) > 0.manifest-schema.mdspec line revised:Unknown values MUST raise a parse error(previously: silently ignored). CHANGELOG### Changed+ migration note covers the three breaking inputs.Implementation (HOW)
src/apm_cli/core/target_detection.py— addsparse_target_field(same Union return shape as the oldTargetParamType.convertfor backward compat) and a_target_errorhelper that prefixes errors withInvalid 'target' in <path>:when a source path is supplied.TargetParamType.convertdelegates to the parser, catchingValueErrorand routing through Click'sself.fail().src/apm_cli/models/apm_package.py— wiresparse_target_field(data.get('target'), source_path=apm_yml_path)intofrom_apm_yml. Import is at module top; verified no circular dependency.src/apm_cli/integration/targets.py—active_targetsandactive_targets_user_scopecollapse string→list at the top of the explicit-target branch; theelse [copilot]fallback is dead code once the parser is upstream and is removed. Module docstring captures the resolver invariant once instead of duplicating the comment in both functions.src/apm_cli/install/phases/targets.py— log gate becomesif ctx.logger:; non-empty path unchanged, empty path emitslogger.warning.src/apm_cli/commands/compile/cli.py—try/exceptremoved; replaced with explicitif Path(APM_YML_FILENAME).exists():. Success message gated on a pattern-based scan (endswith(("_files_written", "_files_generated"))) so future targets pick up the guard automatically.docs/src/content/docs/reference/manifest-schema.md— spec line 116 rewritten per maintainer brief.CHANGELOG.md—### Changedentry with migration note covering all three breaking inputs (unknown token / empty value /allmixed).Diagrams
Legend: how a
target:value flows from either entry point through the shared parser to deployment, and where the previous bug short-circuited to silent zero-deployment.flowchart TD A["apm.yml target: opencode,claude,copilot,agents"] --> B[APMPackage.from_apm_yml] C["--target opencode,claude,copilot,agents"] --> D[TargetParamType.convert] B --> E[parse_target_field] D --> E E -->|"valid: [opencode, claude, vscode]"| F[active_targets] E -->|"unknown / empty / all+X"| G["ValueError -> exit 1"] F --> H["TargetProfile list"] H --> I[skill_integrator deploy] I --> J[".claude/skills, .github/skills, .opencode/skills"] K["BEFORE #820: data.get('target') = raw CSV string"] -.-> L["active_targets single-string branch"] L -.-> M["KNOWN_TARGETS.get('opencode,claude,copilot,agents') = None -> []"] M -.-> N["silent zero-deployment, exit 0"] style G fill:#ffd6d6,stroke:#c00 style N fill:#ffd6d6,stroke:#c00 style J fill:#d6ffd6,stroke:#0a0Trade-offs
target: "",target: [], andtarget: [all, claude]now raise instead of falling through to auto-detect. Migration note in CHANGELOG covers the fix (omit the field for auto-detect). Pre-1.0 is the right window for this contract change per the maintainer's verdict.--target copilotstill returns"copilot"(not the canonical"vscode"). Every downstream consumer (active_targets,agents_compiler,_CROSS_TARGET_MAPS,_TARGET_PREFIXES) already accepts both spellings, and changing this would visibly break ~10test_single_*CLI cases for zero functional benefit. This is the one asymmetry the "shared normalization" fix intentionally leaves; collapsing it is an independent decision.yaml.safe_loaddoes not exposeMarkobjects. Error messages name the apm.yml path (matching the existingfrom_apm_ymlerror format at line 198 —f"Invalid 'type' field in apm.yml: {e}"). Switching to a Mark-aware loader is out of scope and a separate decision.active_targets_user_scopecleaned in the same PR. Not on the maintainer's explicit list, but it carried the identical asymmetric bug (mirror ofactive_targets), and the user-scope testtest_explicit_unknown_returns_emptywas on the update list — fixing only one would leave the asymmetry the PR exists to remove.Benefits
apm install/apm compileno longer exit 0 when zero files deploy. Either the value parses and integration runs, or the parser raises with the offending token + apm.yml path.--target Xandtarget: Xnow resolve identically and reject the same inputs; the bug class is structurally eliminated.phases/targets.pywarns instead of falling silent;compileonly claims success when files were actually emitted.manifest-schema.mdline 116 previously said "silently ignored"; the codebase enforced that for the string branch and a different rule (fallback to copilot) for the list branch. Both now say the same thing.Validation
python -m pytest tests/unit tests/test_apm_package_models.py:End-to-end reproduction of the original #820 scenario (CSV target with a local
.apm/instructions/style.instructions.mdprimitive):Fail-hard cases (unknown token, empty string, empty list, all+other)
--target CLI flag still rejects unknown with Click's standard error (exit 2)
How to test
target: opencode,claude,copilot,agentsand one local primitive under.apm/instructions/. Runapm install --update --verbose. ObserveActive project targets: opencode, claude, copilotand files deployed under.claude/rules/and.github/instructions/.target: claude,bogus. Runapm install --update. Observe exit 1 with the bad token and the apm.yml path in the message.target: "", thentarget: [], thentarget: [all, claude]— each must exit 1 with a clear reason.target:line. Runapm install --update. Auto-detect from existing dirs still works (e.g..github/→ copilot).python -m pytest tests/unit tests/test_apm_package_models.py— full unit suite green.