Skip to content

Commit f2f5629

Browse files
committed
feat: add DisplayResolver for applying sparse binding.yaml overlays to resolve module display metadata.
1 parent ba42957 commit f2f5629

File tree

4 files changed

+263
-0
lines changed

4 files changed

+263
-0
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.4.0] - 2026-03-23
6+
7+
### Added
8+
9+
- **`DisplayResolver`** (`apcore_toolkit.display`) — sparse binding.yaml display overlay (§5.13). Merges per-surface presentation fields (alias, description, guidance, tags, documentation) into `ScannedModule.metadata["display"]` for downstream CLI/MCP/A2A surfaces.
10+
- Resolution chain per field: surface-specific override > `display` default > binding-level field > scanner value.
11+
- `resolve(modules, *, binding_path=..., binding_data=...)` — accepts pre-parsed dict or a path to a `.binding.yaml` file / directory of `*.binding.yaml` files. `binding_data` takes precedence over `binding_path`.
12+
- MCP alias auto-sanitization: replaces characters outside `[a-zA-Z0-9_-]` with `_`; prepends `_` if result starts with a digit.
13+
- MCP alias hard limit: raises `ValueError` if sanitized alias exceeds 64 characters.
14+
- CLI alias validation: warns and falls back to `display.alias` when user-explicitly-set alias does not match `^[a-z][a-z0-9_-]*$` (module_id fallback always accepted without warning).
15+
- `suggested_alias` in `ScannedModule.metadata` (emitted by `simplify_ids=True` scanner) used as fallback when no `display.alias` is set.
16+
- Match-count logging: `INFO` for match count, `WARNING` when binding map loaded but zero modules matched.
17+
- **New feature spec**: `docs/features/display-overlay.md`
18+
19+
### Tests
20+
21+
- 30 new tests in `tests/test_display_resolver.py` covering: no-binding fallthrough, alias-only overlay, surface-specific overrides, MCP sanitization, MCP 64-char limit, `suggested_alias` fallback, sparse overlay (10 modules / 1 binding), tags resolution, `binding_path` file and directory loading, guidance chain, CLI invalid alias warning and fallback, `binding_data` vs `binding_path` precedence.
22+
23+
---
24+
525
## [0.3.1] - 2026-03-22
626

727
### Changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ Available in:
157157
| `WriteResult` | Dataclass representing the outcome of a writer operation |
158158
| `WriteError` | Exception raised when a writer fails due to I/O or other errors |
159159
| `Verifier` / `VerifyResult` | Protocol and result type for pluggable output verification |
160+
| `DisplayResolver` | Sparse binding.yaml display overlay — resolves surface-facing alias, description, guidance, tags into `metadata["display"]` (§5.13) |
160161

161162
---
162163

docs/features/display-overlay.md

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Display Overlay
2+
3+
The `DisplayResolver` applies a sparse `binding.yaml` overlay to a list of `ScannedModule` objects, resolving surface-facing presentation fields — alias, description, guidance, tags, and documentation — into `metadata["display"]` for downstream CLI, MCP, and A2A consumers.
4+
5+
## Overview
6+
7+
Scanners produce raw `ScannedModule` metadata from code. The display overlay layer sits between scanning and surface emission: it reads a `binding.yaml` (or a directory of `*.binding.yaml` files) and merges human-curated presentation overrides on top of the scanned values without modifying the underlying schema or target.
8+
9+
This approach is intentionally **sparse**: a binding file only needs to contain the fields you want to override. Unset fields fall through to sensible defaults.
10+
11+
## `DisplayResolver`
12+
13+
**Class**: `apcore_toolkit.display.DisplayResolver`
14+
**Module**: `apcore_toolkit/display/resolver.py`
15+
16+
### Method Signature
17+
18+
```python
19+
from apcore_toolkit.display import DisplayResolver
20+
21+
resolver = DisplayResolver()
22+
resolved_modules = resolver.resolve(
23+
modules,
24+
*,
25+
binding_path=None, # str | Path | None
26+
binding_data=None, # dict | None
27+
)
28+
```
29+
30+
**Parameters**
31+
32+
| Parameter | Type | Description |
33+
|-----------|------|-------------|
34+
| `modules` | `list[ScannedModule]` | Modules to resolve. Returned list has the same length and order. |
35+
| `binding_path` | `str \| Path \| None` | Path to a single `.binding.yaml` file **or** a directory containing `*.binding.yaml` files. Ignored when `binding_data` is provided. |
36+
| `binding_data` | `dict \| None` | Pre-parsed binding dict. Takes precedence over `binding_path` when both are supplied. |
37+
38+
**Returns**: `list[ScannedModule]` — the same modules with `metadata["display"]` populated.
39+
40+
### Resolution Chain
41+
42+
For each presentation field (`alias`, `description`, `guidance`, `tags`, `documentation`), the resolver walks the following chain and uses the **first non-absent value**:
43+
44+
1. **Surface-specific override** — e.g., `modules.<id>.cli.alias`
45+
2. **`display` default** — e.g., `modules.<id>.display.alias`
46+
3. **Binding-level field** — top-level `alias`, `description`, etc. in the binding entry
47+
4. **Scanner value** — the value already present in the `ScannedModule` (e.g., `description`, `tags`)
48+
49+
If none of the above is set, the field is omitted from `metadata["display"]`.
50+
51+
### `suggested_alias` Fallback
52+
53+
When a scanner runs with `simplify_ids=True`, it may emit `suggested_alias` inside `ScannedModule.metadata`. `DisplayResolver` uses this as an additional fallback for `alias` **before** falling back to `module_id`:
54+
55+
```
56+
surface override > display.alias > binding alias > suggested_alias > module_id
57+
```
58+
59+
No warning is emitted when `suggested_alias` is used as the alias source.
60+
61+
## Output Structure
62+
63+
After resolution, each module's `metadata["display"]` contains a flat dict with optional per-surface sub-dicts:
64+
65+
```python
66+
module.metadata["display"] = {
67+
# Shared fields (resolved from the chain above)
68+
"alias": "get-user",
69+
"description": "Retrieve a user by their unique identifier.",
70+
"documentation": "Returns 404 if the user does not exist.",
71+
"guidance": "Prefer this over listing all users when the ID is known.",
72+
"tags": ["users", "read-only"],
73+
74+
# Surface-specific overrides (only present when the binding sets them)
75+
"cli": {
76+
"alias": "get-user",
77+
"description": "Fetch a user record (CLI variant).",
78+
"guidance": "Pass --id as a positional argument.",
79+
"tags": ["users"],
80+
},
81+
"mcp": {
82+
"alias": "get_user",
83+
"description": "Retrieve a user by ID (MCP tool).",
84+
"guidance": "Provide the numeric user ID.",
85+
"tags": ["users", "read-only"],
86+
"documentation": "See /docs/api/users for the full schema.",
87+
},
88+
"a2a": {
89+
"alias": "get-user",
90+
"description": "Agent-to-agent: retrieve a user record.",
91+
"tags": ["users"],
92+
},
93+
}
94+
```
95+
96+
The three surfaces are `cli`, `mcp`, and `a2a`. A surface sub-dict is only added to `metadata["display"]` when at least one surface-specific field is present in the binding.
97+
98+
## Alias Constraints by Surface
99+
100+
### MCP Alias
101+
102+
MCP tool names must conform to `[a-zA-Z0-9_-]+` with a maximum of 64 characters.
103+
104+
`DisplayResolver` applies automatic sanitization to the resolved MCP alias:
105+
106+
1. Replace every character outside `[a-zA-Z0-9_-]` with `_`.
107+
2. If the sanitized result starts with a digit, prepend `_`.
108+
3. If the final sanitized alias exceeds 64 characters, raise `ValueError`.
109+
110+
```python
111+
# "users.get user" → "users_get_user" (dot and space replaced)
112+
# "1get-user" → "_1get-user" (leading digit prefixed)
113+
# "a" * 65 → ValueError (exceeds 64-char limit)
114+
```
115+
116+
### CLI Alias
117+
118+
CLI command names must match `^[a-z][a-z0-9_-]*$`.
119+
120+
`DisplayResolver` does **not** silently sanitize CLI aliases. Instead:
121+
122+
- If a CLI alias was set **explicitly** in the binding and does not match the pattern, a `WARNING` is logged and the resolver falls back to the `display.alias` value (or the next item in the resolution chain).
123+
- If the alias originates from the scanner value or `suggested_alias` (i.e., was never explicitly set in the binding), it is accepted without warning even if it contains uppercase letters or other non-conforming characters — the downstream CLI adapter is expected to normalise it.
124+
125+
## Match-Count Logging
126+
127+
After resolving all modules against a binding map, `DisplayResolver` logs:
128+
129+
| Level | Condition | Message |
130+
|-------|-----------|---------|
131+
| `INFO` | Always (when a binding is loaded) | `"DisplayResolver: matched N of M modules"` |
132+
| `WARNING` | Binding loaded but zero modules matched | `"DisplayResolver: binding map loaded but no modules matched — check module IDs"` |
133+
134+
No log is emitted when neither `binding_path` nor `binding_data` is provided (pass-through mode).
135+
136+
## Binding File Format
137+
138+
`binding_path` accepts:
139+
140+
- A **single file** with extension `.binding.yaml` (or `.yaml`).
141+
- A **directory**, in which case all `*.binding.yaml` files are loaded and merged. Files are processed in lexicographic order; later files win on key collision.
142+
143+
`binding_data` takes precedence over `binding_path` when both are supplied.
144+
145+
### Sample `binding.yaml`
146+
147+
The top-level key is the `module_id`. All fields are optional — include only what you want to override.
148+
149+
```yaml
150+
# users.get_user.binding.yaml
151+
152+
module_id: users.get_user
153+
154+
# Binding-level fields (lowest override priority, above scanner value)
155+
alias: get-user
156+
description: Retrieve a user by their unique identifier.
157+
documentation: Returns 404 if the user does not exist.
158+
guidance: Prefer this over listing all users when the ID is known.
159+
tags:
160+
- users
161+
- read-only
162+
163+
# display block: shared defaults, override the binding-level fields above
164+
display:
165+
alias: get-user
166+
description: Retrieve a user record.
167+
guidance: Prefer this over listing all users when the ID is known.
168+
tags:
169+
- users
170+
171+
# Surface-specific overrides (highest priority)
172+
cli:
173+
alias: get-user
174+
description: Fetch a user record.
175+
guidance: Pass --id as a positional argument.
176+
tags:
177+
- users
178+
179+
mcp:
180+
alias: get_user
181+
description: Retrieve a user by ID.
182+
guidance: Provide the numeric user ID in the `id` field.
183+
documentation: See /docs/api/users for the full schema.
184+
tags:
185+
- users
186+
- read-only
187+
188+
a2a:
189+
alias: get-user
190+
description: Agent-to-agent user retrieval.
191+
tags:
192+
- users
193+
```
194+
195+
## Code Example
196+
197+
```python
198+
from apcore_toolkit.display import DisplayResolver
199+
200+
# modules is a list[ScannedModule] from any scanner
201+
resolver = DisplayResolver()
202+
203+
# Option A: point to a directory of *.binding.yaml files
204+
resolved = resolver.resolve(modules, binding_path="./bindings")
205+
206+
# Option B: point to a single file
207+
resolved = resolver.resolve(modules, binding_path="./bindings/users.get_user.binding.yaml")
208+
209+
# Option C: supply pre-parsed data (e.g. from a config system)
210+
binding_data = {
211+
"users.get_user": {
212+
"display": {"alias": "get-user"},
213+
"mcp": {"alias": "get_user"},
214+
}
215+
}
216+
resolved = resolver.resolve(modules, binding_data=binding_data)
217+
218+
# Inspect the result
219+
for m in resolved:
220+
display = m.metadata.get("display", {})
221+
print(m.module_id, "→", display.get("alias"), display.get("mcp", {}).get("alias"))
222+
```
223+
224+
## Integration with `simplify_ids`
225+
226+
When the scanner is configured with `simplify_ids=True`, each `ScannedModule.metadata` may contain a `suggested_alias` key. `DisplayResolver` picks this up automatically as the alias fallback:
227+
228+
```python
229+
from fastapi_apcore import get_scanner
230+
from apcore_toolkit.display import DisplayResolver
231+
232+
scanner = get_scanner(app, simplify_ids=True)
233+
modules = scanner.scan()
234+
235+
resolver = DisplayResolver()
236+
resolved = resolver.resolve(modules, binding_path="./bindings")
237+
238+
# Modules without a binding entry still get a clean alias from suggested_alias
239+
for m in resolved:
240+
print(m.metadata["display"].get("alias")) # e.g. "get_user" not "users__get_user"
241+
```

docs/features/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
| **[Output Writers](output-writers.md)** | Export metadata to YAML bindings, source code wrappers, or direct Registry registration — with optional output verification. |
1313
| **[Formatting](formatting.md)** | Convert data structures into beautiful, human-readable Markdown. |
1414
| **[AI Enhancement](../ai-enhancement.md)** | Pluggable `Enhancer` protocol with built-in `AIEnhancer` for local SLMs; [apcore-refinery](https://github.com/aiperceivable/apcore-refinery) recommended for production. |
15+
| **[Display Overlay](display-overlay.md)** | Sparse `binding.yaml` overlay that resolves surface-facing alias, description, guidance, and tags into `metadata["display"]` for CLI, MCP, and A2A surfaces (§5.13). |
1516

1617
## Design Philosophy
1718

0 commit comments

Comments
 (0)