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.
- feature_map/__init__.py +3 -0
- feature_map/__main__.py +3 -0
- feature_map/_version.py +1 -0
- feature_map/bootstrap.py +189 -0
- feature_map/cli.py +273 -0
- feature_map/commands/__init__.py +0 -0
- feature_map/commands/check_cmd.py +23 -0
- feature_map/commands/find_cmd.py +36 -0
- feature_map/commands/graph_cmd.py +18 -0
- feature_map/commands/impact_cmd.py +55 -0
- feature_map/commands/init_cmd.py +72 -0
- feature_map/commands/install_cmd.py +54 -0
- feature_map/commands/list_cmd.py +37 -0
- feature_map/commands/search_cmd.py +21 -0
- feature_map/commands/show_cmd.py +34 -0
- feature_map/commands/stats_cmd.py +85 -0
- feature_map/commands/validate_cmd.py +59 -0
- feature_map/config.py +24 -0
- feature_map/discover.py +59 -0
- feature_map/errors.py +17 -0
- feature_map/graph.py +79 -0
- feature_map/loader.py +62 -0
- feature_map/output.py +54 -0
- feature_map/path_extract.py +139 -0
- feature_map/path_normalize.py +100 -0
- feature_map/path_resolve.py +36 -0
- feature_map/paths.py +34 -0
- feature_map/share/schema/feature-map.schema.json +39 -0
- feature_map/share/skill/SKILL.md +58 -0
- feature_map/share/skill/references/authoring.md +37 -0
- feature_map/share/skill/references/commands.md +47 -0
- feature_map/share/skill/references/existing-repos.md +43 -0
- feature_map/share/templates/feature.yaml.tpl +17 -0
- feature_map/text_index.py +49 -0
- feature_map/validate.py +148 -0
- feature_map_cli-1.0.0.dist-info/METADATA +121 -0
- feature_map_cli-1.0.0.dist-info/RECORD +40 -0
- feature_map_cli-1.0.0.dist-info/WHEEL +4 -0
- feature_map_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|