feature-map-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. feature_map/__init__.py +3 -0
  2. feature_map/__main__.py +3 -0
  3. feature_map/_version.py +1 -0
  4. feature_map/bootstrap.py +189 -0
  5. feature_map/cli.py +273 -0
  6. feature_map/commands/__init__.py +0 -0
  7. feature_map/commands/check_cmd.py +23 -0
  8. feature_map/commands/find_cmd.py +36 -0
  9. feature_map/commands/graph_cmd.py +18 -0
  10. feature_map/commands/impact_cmd.py +55 -0
  11. feature_map/commands/init_cmd.py +72 -0
  12. feature_map/commands/install_cmd.py +54 -0
  13. feature_map/commands/list_cmd.py +37 -0
  14. feature_map/commands/search_cmd.py +21 -0
  15. feature_map/commands/show_cmd.py +34 -0
  16. feature_map/commands/stats_cmd.py +85 -0
  17. feature_map/commands/validate_cmd.py +59 -0
  18. feature_map/config.py +24 -0
  19. feature_map/discover.py +59 -0
  20. feature_map/errors.py +17 -0
  21. feature_map/graph.py +79 -0
  22. feature_map/loader.py +62 -0
  23. feature_map/output.py +54 -0
  24. feature_map/path_extract.py +139 -0
  25. feature_map/path_normalize.py +100 -0
  26. feature_map/path_resolve.py +36 -0
  27. feature_map/paths.py +34 -0
  28. feature_map/share/schema/feature-map.schema.json +39 -0
  29. feature_map/share/skill/SKILL.md +58 -0
  30. feature_map/share/skill/references/authoring.md +37 -0
  31. feature_map/share/skill/references/commands.md +47 -0
  32. feature_map/share/skill/references/existing-repos.md +43 -0
  33. feature_map/share/templates/feature.yaml.tpl +17 -0
  34. feature_map/text_index.py +49 -0
  35. feature_map/validate.py +148 -0
  36. feature_map_cli-1.0.0.dist-info/METADATA +121 -0
  37. feature_map_cli-1.0.0.dist-info/RECORD +40 -0
  38. feature_map_cli-1.0.0.dist-info/WHEEL +4 -0
  39. feature_map_cli-1.0.0.dist-info/entry_points.txt +2 -0
  40. feature_map_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,139 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Iterable, List, Optional
4
+
5
+ import yaml
6
+
7
+ from feature_map.path_normalize import normalize_path_candidate
8
+ from feature_map.path_resolve import resolve_candidate_paths
9
+
10
+ __all__ = [
11
+ "normalize_path_candidate",
12
+ "resolve_candidate_paths",
13
+ "extract_paths_from_map",
14
+ "collect_corpus_strings",
15
+ "check_paths",
16
+ ]
17
+
18
+
19
+ def _extract_from_value(value) -> List[str]:
20
+ paths: List[str] = []
21
+ if isinstance(value, str):
22
+ candidate = normalize_path_candidate(value)
23
+ if candidate:
24
+ paths.append(candidate)
25
+ elif isinstance(value, list):
26
+ for item in value:
27
+ paths.extend(_extract_from_value(item))
28
+ elif isinstance(value, dict):
29
+ for key, item in value.items():
30
+ if isinstance(key, str):
31
+ key_path = normalize_path_candidate(key)
32
+ if key_path:
33
+ paths.append(key_path)
34
+ paths.extend(_extract_from_value(item))
35
+ return paths
36
+
37
+
38
+ def extract_paths_from_map(data: dict) -> List[str]:
39
+ paths: List[str] = []
40
+ seen = set()
41
+
42
+ def add(path: Optional[str]):
43
+ if path and path not in seen:
44
+ seen.add(path)
45
+ paths.append(path)
46
+
47
+ for section in ("entry_points", "core_components"):
48
+ section_data = data.get(section)
49
+ if section_data is None:
50
+ continue
51
+ if section == "core_components" and isinstance(section_data, dict):
52
+ for val in section_data.values():
53
+ for path in _extract_from_value(val):
54
+ add(path)
55
+ else:
56
+ for path in _extract_from_value(section_data):
57
+ add(path)
58
+
59
+ return paths
60
+
61
+
62
+ def _text_scan_section_strings(text: str, section: str) -> List[str]:
63
+ """Extract list-ish string lines from a section when YAML parse fails."""
64
+ strings: List[str] = []
65
+ match = re.search(rf"^{re.escape(section)}:\s*\n", text, re.MULTILINE)
66
+ if not match:
67
+ return strings
68
+ start = match.end()
69
+ rest = text[start:]
70
+ end_match = re.search(r"^\w[\w_]*:\s", rest, re.MULTILINE)
71
+ block = rest[: end_match.start()] if end_match else rest
72
+ for line in block.splitlines():
73
+ stripped = line.strip()
74
+ if stripped.startswith("- "):
75
+ strings.append(stripped[2:].strip().strip('"').strip("'"))
76
+ return strings
77
+
78
+
79
+ def collect_corpus_strings(features_dir: Path) -> List[tuple]:
80
+ """Return (feature_slug, raw_string) pairs from entry_points + core_components."""
81
+ corpus: List[tuple] = []
82
+ for map_path in sorted(features_dir.glob("*.yaml")):
83
+ text = map_path.read_text(encoding="utf-8")
84
+ try:
85
+ data = yaml.safe_load(text) or {}
86
+ if isinstance(data, dict):
87
+ for section in ("entry_points", "core_components"):
88
+ section_data = data.get(section)
89
+ if section_data is None:
90
+ continue
91
+ for raw in _flatten_strings(section_data):
92
+ corpus.append((map_path.stem, raw))
93
+ continue
94
+ except yaml.YAMLError:
95
+ pass
96
+ for section in ("entry_points", "core_components"):
97
+ for raw in _text_scan_section_strings(text, section):
98
+ corpus.append((map_path.stem, raw))
99
+ return corpus
100
+
101
+
102
+ def _flatten_strings(value) -> List[str]:
103
+ out: List[str] = []
104
+ if isinstance(value, str):
105
+ out.append(value)
106
+ elif isinstance(value, list):
107
+ for item in value:
108
+ out.extend(_flatten_strings(item))
109
+ elif isinstance(value, dict):
110
+ for key, item in value.items():
111
+ if isinstance(key, str):
112
+ out.append(key)
113
+ out.extend(_flatten_strings(item))
114
+ return out
115
+
116
+
117
+ def check_paths(features_dir: Path, repo_root: Path, apps: List[str]) -> List[dict]:
118
+ issues = []
119
+ for map_path in sorted(features_dir.glob("*.yaml")):
120
+ try:
121
+ data = yaml.safe_load(map_path.read_text(encoding="utf-8")) or {}
122
+ except yaml.YAMLError:
123
+ continue
124
+ if not isinstance(data, dict):
125
+ continue
126
+ for clean_path in extract_paths_from_map(data):
127
+ exists = any(
128
+ candidate.exists()
129
+ for candidate in resolve_candidate_paths(clean_path, repo_root, apps)
130
+ )
131
+ if not exists:
132
+ issues.append(
133
+ {
134
+ "feature": map_path.stem,
135
+ "path": clean_path,
136
+ "status": "missing",
137
+ }
138
+ )
139
+ return issues
@@ -0,0 +1,100 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
5
+ PATH_EXTENSIONS = (".rb", ".js", ".erb", ".svelte", ".ts", ".tsx", ".py", ".go", ".yaml", ".yml")
6
+ PATH_PREFIXES = ("app/", "src/", "lib/", "config/", "pkg/", "internal/", "rails/")
7
+ EXT_PATTERN = r"\.(?:rb|js|erb|svelte|ts|tsx|py|go|yaml|yml)"
8
+ LINE_RANGE_RE = re.compile(r":\d+(?:-\d+)?$")
9
+
10
+
11
+ def looks_like_file_path(value: str) -> bool:
12
+ if not value or " " in value:
13
+ return False
14
+ if LINE_RANGE_RE.search(value):
15
+ return False
16
+ if ":" in value and not value.startswith(("rails/", "app/", "src/", "config/", "lib/")):
17
+ # reject stray colon annotations (e.g. line ranges not yet stripped)
18
+ if re.search(r":\d", value):
19
+ return False
20
+ if value.endswith(PATH_EXTENSIONS):
21
+ return True
22
+ if any(value.startswith(prefix) for prefix in PATH_PREFIXES):
23
+ return True
24
+ if "/" in value:
25
+ last = value.rsplit("/", 1)[-1]
26
+ if "." in last and not last.endswith("."):
27
+ return True
28
+ return False
29
+
30
+
31
+ def passes_normalized_invariants(path: str) -> bool:
32
+ if not path or " " in path:
33
+ return False
34
+ if re.search(r":\d", path):
35
+ return False
36
+ if "—" in path or "–" in path:
37
+ return False
38
+ if "mounts" in path.lower():
39
+ return False
40
+ return looks_like_file_path(path)
41
+
42
+
43
+ def strip_line_range(value: str) -> str:
44
+ return LINE_RANGE_RE.sub("", value).strip()
45
+
46
+
47
+ def normalize_path_candidate(value: str) -> Optional[str]:
48
+ if not isinstance(value, str):
49
+ return None
50
+
51
+ value = value.strip().strip('"').strip("'")
52
+ if not value or value.startswith("("):
53
+ return None
54
+ if value.lower().startswith("see "):
55
+ return None
56
+ if value.startswith(HTTP_METHODS):
57
+ return None
58
+
59
+ if ": " in value:
60
+ left, _right = value.split(": ", 1)
61
+ left = left.strip()
62
+ if "/" in left or left.endswith(PATH_EXTENSIONS):
63
+ value = left
64
+
65
+ for sep in (" — ", " – ", " - ", " (", "("):
66
+ if sep in value:
67
+ value = value.split(sep, 1)[0].strip()
68
+
69
+ if "#" in value:
70
+ value = value.split("#", 1)[0].strip()
71
+
72
+ value = strip_line_range(value)
73
+
74
+ value = value.rstrip("/")
75
+ if not value:
76
+ return None
77
+ if value.startswith("config/") and " " in value:
78
+ return None
79
+ if "," in value:
80
+ return None
81
+
82
+ ext_match = re.match(rf"^(\S+{EXT_PATTERN})(?:\s|$)", value)
83
+ if ext_match:
84
+ value = ext_match.group(1)
85
+ value = strip_line_range(value)
86
+ elif " " in value:
87
+ first = value.split(" ", 1)[0].strip()
88
+ first = strip_line_range(first)
89
+ if looks_like_file_path(first):
90
+ value = first
91
+ else:
92
+ return None
93
+
94
+ value = strip_line_range(value)
95
+ if not looks_like_file_path(value):
96
+ return None
97
+ if re.match(r"^[A-Za-z_]+Controller$", value):
98
+ return None
99
+
100
+ return value
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+ from typing import Iterable, List
3
+
4
+ PATH_EXTENSIONS = (".rb", ".js", ".erb", ".svelte", ".ts", ".tsx", ".py", ".go", ".yaml", ".yml")
5
+
6
+
7
+ def resolve_candidate_paths(path_str: str, repo_root: Path, apps: Iterable[str]) -> List[Path]:
8
+ """Expand a pre-normalized path to filesystem candidates. Does not re-normalize."""
9
+ path_str = path_str.strip()
10
+ apps = [app for app in apps if isinstance(app, str) and app]
11
+ candidates: List[Path] = []
12
+ seen = set()
13
+
14
+ def add(candidate: Path):
15
+ key = str(candidate)
16
+ if key not in seen:
17
+ seen.add(key)
18
+ candidates.append(candidate)
19
+
20
+ add(repo_root / path_str)
21
+ for app in apps:
22
+ add(repo_root / app / path_str)
23
+ prefix = f"{app}/"
24
+ if path_str.startswith(prefix):
25
+ add(repo_root / path_str)
26
+ add(repo_root / path_str[len(prefix) :])
27
+
28
+ # Rails-style fallbacks when the consumer repo lists a rails app.
29
+ if "rails" in apps:
30
+ if path_str.startswith("app/"):
31
+ add(repo_root / "rails" / path_str)
32
+ if "/" not in path_str and path_str.endswith(PATH_EXTENSIONS):
33
+ add(repo_root / "rails" / "app" / "javascript" / "controllers" / path_str)
34
+ add(repo_root / "rails" / "app" / "controllers" / path_str)
35
+
36
+ return candidates
feature_map/paths.py ADDED
@@ -0,0 +1,34 @@
1
+ """Locate bundled schema, templates, and agent skill assets."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def package_dir() -> Path:
7
+ return Path(__file__).resolve().parent
8
+
9
+
10
+ def source_share_dir() -> Path:
11
+ """Repo-root share/feature_map when running from a source checkout."""
12
+ return package_dir().parent.parent / "share" / "feature_map"
13
+
14
+
15
+ def assets_root() -> Path:
16
+ bundled = package_dir() / "share"
17
+ if (bundled / "schema").is_dir() or (bundled / "templates").is_dir():
18
+ return bundled
19
+ share = source_share_dir()
20
+ if share.is_dir():
21
+ return share
22
+ return bundled
23
+
24
+
25
+ def schema_path() -> Path:
26
+ return assets_root() / "schema" / "feature-map.schema.json"
27
+
28
+
29
+ def template_path() -> Path:
30
+ return assets_root() / "templates" / "feature.yaml.tpl"
31
+
32
+
33
+ def skill_dir() -> Path:
34
+ return assets_root() / "skill"
@@ -0,0 +1,39 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Feature Map",
4
+ "type": "object",
5
+ "required": ["feature_name", "purpose", "entry_points"],
6
+ "properties": {
7
+ "feature_name": {
8
+ "type": "string",
9
+ "minLength": 1
10
+ },
11
+ "purpose": {
12
+ "type": "string",
13
+ "minLength": 1
14
+ },
15
+ "entry_points": {
16
+ "type": "array",
17
+ "minItems": 1,
18
+ "items": {
19
+ "type": "string"
20
+ }
21
+ },
22
+ "apps": {
23
+ "type": ["array", "object"]
24
+ },
25
+ "user_flow": {
26
+ "type": "object"
27
+ },
28
+ "related_features": {
29
+ "type": "array",
30
+ "items": {
31
+ "type": "string"
32
+ }
33
+ },
34
+ "notes": {
35
+ "type": "string"
36
+ }
37
+ },
38
+ "additionalProperties": true
39
+ }
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: feature-map
3
+ version: 1.0.0
4
+ description: "Research cross-app architecture via the Feature Map CLI before feature work, debugging, PRDs, or implementation plans. Run list, show, search, find, graph, validate, and check against .features/*.yaml. On existing repos with no maps, scour the code and author maps first."
5
+ ---
6
+
7
+ # Feature Map
8
+
9
+ Authoritative cross-app architecture lives in `.features/*.yaml`. **Always**
10
+ use this skill before touching a feature. Do not plan, debug, or implement
11
+ from a cold grep when a map exists — or when one should exist and does not.
12
+
13
+ Maps are **fields, not a wiki**. Prefer `list` / `search` / `show --section`
14
+ over reading a long markdown doc. That keeps token use on paths and purpose,
15
+ not narrative. When writing or updating a map, same rule: cover every door
16
+ and coupling; do not write narrative (`references/authoring.md`, Density).
17
+ See the package `GUIDE.md` ("Why this, not a wiki").
18
+
19
+ ## When to invoke
20
+
21
+ - Before feature implementation, debugging, PRDs, implementation plans, or pre-mortems
22
+ - When unsure which feature map applies
23
+ - After shipping changes that affect architecture (update maps, then validate)
24
+ - When `list` is empty or `search`/`find` miss: **scour the repo and author maps** before other work (see `references/existing-repos.md`)
25
+
26
+ ## Research sequence
27
+
28
+ ```bash
29
+ feature-map list
30
+ feature-map search <keyword> # when the feature name is unclear
31
+ feature-map find <path-fragment>
32
+ feature-map <feature-name> # or: show <feature-name>
33
+ feature-map graph <feature> # cross-cutting dependencies
34
+ ```
35
+
36
+ `./bin/feature-map` is a repo-local shim for the same CLI (created by `feature-map init`).
37
+
38
+ Use `show <name> --section entry_points` (or `purpose`, `user_flow`, etc.) to limit token use on large maps.
39
+
40
+ If the list is empty, or nothing matches the area you are about to change,
41
+ **stop**. Follow `references/existing-repos.md`: inventory apps, cluster
42
+ capabilities, `feature-map init <slug>`, fill real paths, `validate` + `check`.
43
+ Then resume the research sequence.
44
+
45
+ ## After implementation
46
+
47
+ 1. Patch the fields that changed. Do not grow the map with prose.
48
+ 2. Run `feature-map validate`
49
+ 3. Run `feature-map check`
50
+
51
+ ## Commands
52
+
53
+ See `references/commands.md` for the full CLI reference.
54
+
55
+ ## Authoring
56
+
57
+ See `references/authoring.md` for shape, density, and conventions.
58
+ See `references/existing-repos.md` when adopting Feature Map on a codebase that already exists.
@@ -0,0 +1,37 @@
1
+ # Authoring Feature Maps
2
+
3
+ Feature maps live in `.features/<slug>.yaml` at the repo root.
4
+
5
+ ## Required sections
6
+
7
+ - `feature_name` — must match the filename stem (normalized)
8
+ - `purpose` — one sentence: what it does. Skip motivation unless it changes where you look.
9
+ - `entry_points` — primary doors only (routes, screens, jobs, CLIs); real paths
10
+ - `apps` — app names as a list, not descriptions (recommended; warning if missing)
11
+
12
+ ## Recommended sections
13
+
14
+ - `user_flow` — one line per distinct path: `Actor → step → result`. Add `alt`/`error` only if code diverges.
15
+ - `related_features` — `slug (coupling)`; parenthetical is a phrase
16
+
17
+ ## Optional
18
+
19
+ - `notes` — caveats and unknowns only; omit the key if none. Never history, README, or process tips. `validate` does not warn when this key is absent.
20
+
21
+ ## Density
22
+
23
+ Index, not essay. Completeness is doors, apps, and couplings. Delete a sentence if it would not change which file the next agent opens. Do not drop a real door, app, or related slug to stay short. Do not add narrative keys (`overview`, `history`, `architecture`, `background`). `core_components` is the only extra path group `check` understands.
24
+
25
+ ## Conventions
26
+
27
+ - Use underscores in filenames: `user_signup.yaml`
28
+ - `related_features` entries should start with a resolvable slug
29
+ - Run `feature-map validate` and `feature-map check` after edits
30
+ - Scaffold new maps: `feature-map init <name>`
31
+ - Bootstrap a new repo: `feature-map init`
32
+ - Existing repo with no maps: scour the code first (`existing-repos.md`); do not skip maps because the codebase predates Feature Map
33
+ - Patch fields in place when the architecture changes; do not append prose
34
+
35
+ ## Schema
36
+
37
+ JSON Schema ships with the package at `share/feature_map/schema/feature-map.schema.json`.
@@ -0,0 +1,47 @@
1
+ # Feature Map CLI Reference
2
+
3
+ Invocation: `feature-map` (on PATH) or `./bin/feature-map` (repo-local shim).
4
+
5
+ Global flags: `--json`, `--version`
6
+
7
+ ## Commands
8
+
9
+ | Command | Description |
10
+ |---------|-------------|
11
+ | `list [--json]` | All feature slugs; JSON includes mtime and app count |
12
+ | `show <name> [--section <key>] [--json]` | Full map or one top-level section |
13
+ | `<name>` | Alias for `show <name>` |
14
+ | `search <query> [--json]` | Full-text search across all maps |
15
+ | `find <path-fragment> [--json]` | Reverse lookup by path string |
16
+ | `graph [name] [--format mermaid\|json\|dot]` | `related_features` graph |
17
+ | `validate [--strict] [--json]` | Structural validation |
18
+ | `check [--json]` | Staleness check for entry-point paths |
19
+ | `impact <file> [--transitive] [--json]` | Features referencing a file |
20
+ | `stats [--json]` | Coverage statistics |
21
+ | `init` | Bootstrap `.features/`, agent skill, config, AGENTS.md, shim |
22
+ | `init <name> [--force]` | Scaffold `.features/<name>.yaml` |
23
+ | `init --upgrade-skill` | Refresh the agent skill from the installed package |
24
+ | `install [--json]` | Verify install and repo setup |
25
+
26
+ ## Examples
27
+
28
+ ```bash
29
+ feature-map list
30
+ feature-map auth
31
+ feature-map show auth --section entry_points
32
+ feature-map search billing
33
+ feature-map find src/app.py
34
+ feature-map graph auth --format mermaid
35
+ feature-map validate
36
+ feature-map check --json
37
+ feature-map impact src/app.py
38
+ feature-map stats --json
39
+ feature-map init
40
+ feature-map init billing --force
41
+ ```
42
+
43
+ ## Exit codes
44
+
45
+ - `0` — success
46
+ - `1` — user error (e.g. feature not found)
47
+ - `2` — validation failure (`validate --strict`)
@@ -0,0 +1,43 @@
1
+ # Bootstrapping maps on an existing repository
2
+
3
+ `feature-map init` creates the workflow. It does **not** invent maps from
4
+ source. On a repo that already has code, you must **scour the tree and
5
+ author** `.features/*.yaml` before doing feature work.
6
+
7
+ ## When this applies
8
+
9
+ - `feature-map list` is empty
10
+ - `search` / `find` miss the area you are about to change
11
+ - `.feature-map.yaml` `apps` is `[]` but the repo has multiple packages
12
+
13
+ Do not skip this and "just grep". Author maps first, then implement.
14
+
15
+ ## Playbook
16
+
17
+ 1. **Inventory apps** — top-level directories and manifests (`Gemfile`,
18
+ `package.json`, `pyproject.toml`, `go.mod`, `pubspec.yaml`, `apps/`,
19
+ `packages/`). Write them under `apps:` in `.feature-map.yaml`.
20
+ 2. **Find seams** — routes, app shells, domain models, jobs, CLIs, docs,
21
+ and test names. These are where features show up.
22
+ 3. **Cluster** — one map per user-visible capability or subsystem, not
23
+ per file. Cross-app journeys are one map with several `apps` and
24
+ `entry_points`.
25
+ 4. **Scaffold + fill** — `feature-map init <slug>`, then replace
26
+ placeholders with real paths and a one-sentence `purpose` you
27
+ verified in code (`authoring.md`, Density).
28
+ 5. **Verify** — `feature-map validate` and `feature-map check`. Prefer
29
+ paths that exist on disk. Record uncertainty in `notes`.
30
+ 6. **Stop the first pass** when every listed app appears on at least one
31
+ map and the README's product nouns `search` successfully.
32
+
33
+ Full narrative: the package `GUIDE.md` §3.4 (existing repos).
34
+
35
+ ## Anti-patterns
36
+
37
+ - One map per source file
38
+ - Invented `entry_points` that `check` would mark missing
39
+ - `entry_points` as a file inventory (doors only)
40
+ - Essays in `purpose`, `user_flow`, or `notes`
41
+ - Extra narrative keys (`overview`, `history`, `architecture`)
42
+ - `related_features` that do not start with a real slug
43
+ - Planning or coding a feature that has no map "because the repo is old"
@@ -0,0 +1,17 @@
1
+ feature_name: {{feature_name}}
2
+ purpose: "One sentence: what {{FEATURE_TITLE}} does."
3
+
4
+ entry_points:
5
+ - path/to/primary/entry_point
6
+ - path/to/secondary/surface
7
+
8
+ apps:
9
+ - app_name
10
+
11
+ user_flow:
12
+ primary: "Actor → action → result."
13
+
14
+ related_features:
15
+ - related_feature_slug (coupling)
16
+
17
+ # notes: caveats/unknowns only — omit this key if none
@@ -0,0 +1,49 @@
1
+ from pathlib import Path
2
+ from typing import Dict, List
3
+
4
+ import yaml
5
+
6
+ from feature_map.loader import list_map_files
7
+
8
+
9
+ def _flatten(value, prefix="") -> List[str]:
10
+ lines = []
11
+ if isinstance(value, dict):
12
+ for key, item in value.items():
13
+ lines.extend(_flatten(item, f"{prefix}{key}."))
14
+ elif isinstance(value, list):
15
+ for item in value:
16
+ lines.extend(_flatten(item, prefix))
17
+ elif value is not None:
18
+ lines.append(f"{prefix}{value}")
19
+ return lines
20
+
21
+
22
+ def build_text_index(features_dir: Path) -> Dict[str, str]:
23
+ index = {}
24
+ for path in list_map_files(features_dir):
25
+ text = path.read_text(encoding="utf-8")
26
+ index[path.stem] = text
27
+ try:
28
+ data = yaml.safe_load(text) or {}
29
+ if isinstance(data, dict):
30
+ index[path.stem] = "\n".join(_flatten(data))
31
+ except yaml.YAMLError:
32
+ pass
33
+ return index
34
+
35
+
36
+ def search_maps(features_dir: Path, query: str) -> List[dict]:
37
+ query_lower = query.lower()
38
+ results = []
39
+ for slug, text in build_text_index(features_dir).items():
40
+ if query_lower not in text.lower():
41
+ continue
42
+ snippets = []
43
+ for line in text.splitlines():
44
+ if query_lower in line.lower():
45
+ snippets.append(line.strip())
46
+ if len(snippets) >= 3:
47
+ break
48
+ results.append({"feature": slug, "snippets": snippets})
49
+ return results