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,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from feature_map.paths import skill_dir as bundled_skill_dir
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _detect_deployed_skill(repo_root: Path) -> Path:
|
|
9
|
+
candidates = [
|
|
10
|
+
repo_root / ".agents" / "skills" / "feature-map",
|
|
11
|
+
repo_root / ".grok" / "skills" / "feature-map",
|
|
12
|
+
]
|
|
13
|
+
for path in candidates:
|
|
14
|
+
if (path / "SKILL.md").is_file():
|
|
15
|
+
return path
|
|
16
|
+
return candidates[0]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_install(repo_root: Path, as_json: bool = False):
|
|
20
|
+
shim = repo_root / "bin" / "feature-map"
|
|
21
|
+
features_dir = repo_root / ".features"
|
|
22
|
+
config_file = repo_root / ".feature-map.yaml"
|
|
23
|
+
skill_path = _detect_deployed_skill(repo_root)
|
|
24
|
+
on_path = shutil.which("feature-map")
|
|
25
|
+
|
|
26
|
+
shim_ok = shim.is_file() and os.access(shim, os.X_OK)
|
|
27
|
+
skill_ok = (skill_path / "SKILL.md").is_file()
|
|
28
|
+
bundled_ok = (bundled_skill_dir() / "SKILL.md").is_file()
|
|
29
|
+
features_ok = features_dir.is_dir()
|
|
30
|
+
config_ok = config_file.is_file()
|
|
31
|
+
|
|
32
|
+
payload = {
|
|
33
|
+
"ok": bundled_ok and (shim_ok or bool(on_path)),
|
|
34
|
+
"cli_on_path": {"command": "feature-map", "path": on_path, "exists": bool(on_path)},
|
|
35
|
+
"shim": {"path": str(shim), "exists": shim.is_file(), "executable": shim_ok},
|
|
36
|
+
"skill": {"path": str(skill_path), "exists": skill_ok},
|
|
37
|
+
"bundled_skill": {"path": str(bundled_skill_dir()), "exists": bundled_ok},
|
|
38
|
+
"features_dir": {"path": str(features_dir), "exists": features_ok},
|
|
39
|
+
"config": {"path": str(config_file), "exists": config_ok},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if as_json:
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
print("Feature Map install status:")
|
|
46
|
+
print(f" feature-map on PATH: {'ok' if on_path else 'missing'} ({on_path or 'not found'})")
|
|
47
|
+
print(f" shim: {'ok' if shim_ok else 'missing or not executable'} ({shim})")
|
|
48
|
+
print(f" skill: {'ok' if skill_ok else 'missing'} ({skill_path})")
|
|
49
|
+
print(f" bundled skill: {'ok' if bundled_ok else 'missing'} ({bundled_skill_dir()})")
|
|
50
|
+
print(f" .features/: {'ok' if features_ok else 'missing'} ({features_dir})")
|
|
51
|
+
print(f" .feature-map.yaml: {'ok' if config_ok else 'missing'} ({config_file})")
|
|
52
|
+
if not features_ok:
|
|
53
|
+
print('Suggestion: run "feature-map init" to bootstrap this repository.')
|
|
54
|
+
return payload
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from feature_map.loader import list_map_files
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_list(features_dir: Path, as_json: bool = False):
|
|
8
|
+
files = list_map_files(features_dir)
|
|
9
|
+
slugs = [path.stem for path in files]
|
|
10
|
+
|
|
11
|
+
if as_json:
|
|
12
|
+
items = []
|
|
13
|
+
for path in files:
|
|
14
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime).isoformat()
|
|
15
|
+
apps_count = 0
|
|
16
|
+
try:
|
|
17
|
+
import yaml as yaml_lib
|
|
18
|
+
|
|
19
|
+
data = yaml_lib.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
20
|
+
apps = data.get("apps")
|
|
21
|
+
if isinstance(apps, list):
|
|
22
|
+
apps_count = len(apps)
|
|
23
|
+
elif isinstance(apps, dict):
|
|
24
|
+
apps_count = len(apps)
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
items.append(
|
|
28
|
+
{
|
|
29
|
+
"slug": path.stem,
|
|
30
|
+
"file": path.name,
|
|
31
|
+
"mtime": mtime,
|
|
32
|
+
"apps_count": apps_count,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
return {"ok": True, "count": len(items), "features": items}
|
|
36
|
+
|
|
37
|
+
return {"features": slugs}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from feature_map.text_index import search_maps
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_search(features_dir: Path, query: str, as_json: bool = False):
|
|
7
|
+
results = search_maps(features_dir, query)
|
|
8
|
+
payload = {"ok": True, "query": query, "count": len(results), "results": results}
|
|
9
|
+
|
|
10
|
+
if as_json:
|
|
11
|
+
return payload
|
|
12
|
+
|
|
13
|
+
if not results:
|
|
14
|
+
print(f"No features matched '{query}'.")
|
|
15
|
+
return payload
|
|
16
|
+
|
|
17
|
+
for item in results:
|
|
18
|
+
print(item["feature"])
|
|
19
|
+
for snippet in item.get("snippets", []):
|
|
20
|
+
print(f" {snippet}")
|
|
21
|
+
return payload
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from feature_map.loader import load_map, load_map_text, resolve_map_path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_show(features_dir: Path, name: str, section=None, as_json: bool = False):
|
|
9
|
+
path = resolve_map_path(features_dir, name)
|
|
10
|
+
if as_json:
|
|
11
|
+
data = load_map(path)
|
|
12
|
+
if section:
|
|
13
|
+
if section not in data:
|
|
14
|
+
from feature_map.errors import CliError
|
|
15
|
+
|
|
16
|
+
raise CliError(
|
|
17
|
+
f'Section "{section}" not found in {path.stem}.',
|
|
18
|
+
suggestion=f"Available keys: {', '.join(sorted(data.keys()))}",
|
|
19
|
+
)
|
|
20
|
+
return {"ok": True, "feature": path.stem, "section": section, "data": data[section]}
|
|
21
|
+
return {"ok": True, "feature": path.stem, "data": data}
|
|
22
|
+
|
|
23
|
+
if section:
|
|
24
|
+
data = load_map(path)
|
|
25
|
+
if section not in data:
|
|
26
|
+
from feature_map.errors import CliError
|
|
27
|
+
|
|
28
|
+
raise CliError(
|
|
29
|
+
f'Section "{section}" not found in {path.stem}.',
|
|
30
|
+
suggestion=f"Available keys: {', '.join(sorted(data.keys()))}",
|
|
31
|
+
)
|
|
32
|
+
return yaml.dump({section: data[section]}, sort_keys=False)
|
|
33
|
+
|
|
34
|
+
return load_map_text(path)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from feature_map.loader import all_slugs, extract_related_slug, list_map_files
|
|
9
|
+
from feature_map.validate import RECOMMENDED_KEYS, REQUIRED_KEYS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_app_name(app) -> Optional[str]:
|
|
13
|
+
if not isinstance(app, str):
|
|
14
|
+
return None
|
|
15
|
+
app = app.strip()
|
|
16
|
+
if not app or app.startswith("("):
|
|
17
|
+
return None
|
|
18
|
+
match = re.match(r'^["\']?([a-zA-Z0-9_-]+)', app)
|
|
19
|
+
if match:
|
|
20
|
+
return match.group(1)
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_stats(features_dir: Path, as_json: bool = False):
|
|
25
|
+
slugs = all_slugs(features_dir)
|
|
26
|
+
apps_counter = Counter()
|
|
27
|
+
missing_sections = 0
|
|
28
|
+
broken_links = 0
|
|
29
|
+
parse_failures = 0
|
|
30
|
+
|
|
31
|
+
for path in list_map_files(features_dir):
|
|
32
|
+
try:
|
|
33
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
34
|
+
except yaml.YAMLError:
|
|
35
|
+
parse_failures += 1
|
|
36
|
+
missing_sections += len(REQUIRED_KEYS) + len(RECOMMENDED_KEYS)
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
for key in REQUIRED_KEYS + RECOMMENDED_KEYS:
|
|
43
|
+
if key not in data or data[key] in (None, "", []):
|
|
44
|
+
missing_sections += 1
|
|
45
|
+
|
|
46
|
+
apps = data.get("apps")
|
|
47
|
+
if isinstance(apps, list):
|
|
48
|
+
for app in apps:
|
|
49
|
+
name = normalize_app_name(app)
|
|
50
|
+
if name:
|
|
51
|
+
apps_counter[name] += 1
|
|
52
|
+
elif isinstance(apps, dict):
|
|
53
|
+
for app in apps:
|
|
54
|
+
name = normalize_app_name(app)
|
|
55
|
+
if name:
|
|
56
|
+
apps_counter[name] += 1
|
|
57
|
+
|
|
58
|
+
related = data.get("related_features") or []
|
|
59
|
+
if isinstance(related, list):
|
|
60
|
+
for entry in related:
|
|
61
|
+
slug = extract_related_slug(entry)
|
|
62
|
+
if slug and slug not in slugs:
|
|
63
|
+
broken_links += 1
|
|
64
|
+
|
|
65
|
+
payload = {
|
|
66
|
+
"ok": True,
|
|
67
|
+
"map_count": len(list(list_map_files(features_dir))),
|
|
68
|
+
"parse_failures": parse_failures,
|
|
69
|
+
"missing_sections": missing_sections,
|
|
70
|
+
"broken_related_features": broken_links,
|
|
71
|
+
"maps_per_app": dict(sorted(apps_counter.items())),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if as_json:
|
|
75
|
+
return payload
|
|
76
|
+
|
|
77
|
+
print(f"Maps: {payload['map_count']}")
|
|
78
|
+
print(f"Parse failures: {payload['parse_failures']}")
|
|
79
|
+
print(f"Missing sections: {payload['missing_sections']}")
|
|
80
|
+
print(f"Broken related_features: {payload['broken_related_features']}")
|
|
81
|
+
if apps_counter:
|
|
82
|
+
print("Maps per app:")
|
|
83
|
+
for app, count in sorted(apps_counter.items()):
|
|
84
|
+
print(f" {app}: {count}")
|
|
85
|
+
return payload
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from feature_map.loader import all_slugs, list_map_files
|
|
4
|
+
from feature_map.validate import validate_map_file
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_validate(features_dir: Path, strict: bool = False, as_json: bool = False):
|
|
8
|
+
slugs = all_slugs(features_dir)
|
|
9
|
+
all_errors = []
|
|
10
|
+
all_warnings = []
|
|
11
|
+
map_results = []
|
|
12
|
+
|
|
13
|
+
for path in list_map_files(features_dir):
|
|
14
|
+
errors, warnings = validate_map_file(path, slugs, strict=strict)
|
|
15
|
+
all_errors.extend(errors)
|
|
16
|
+
all_warnings.extend(warnings)
|
|
17
|
+
map_results.append(
|
|
18
|
+
{
|
|
19
|
+
"file": path.name,
|
|
20
|
+
"slug": path.stem,
|
|
21
|
+
"errors": errors,
|
|
22
|
+
"warnings": warnings,
|
|
23
|
+
"ok": len(errors) == 0,
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
result = {
|
|
28
|
+
"ok": len(all_errors) == 0 and (len(all_warnings) == 0 if strict else True),
|
|
29
|
+
"map_count": len(map_results),
|
|
30
|
+
"error_count": len(all_errors),
|
|
31
|
+
"warning_count": len(all_warnings),
|
|
32
|
+
"maps": map_results,
|
|
33
|
+
"errors": all_errors,
|
|
34
|
+
"warnings": all_warnings,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if not as_json:
|
|
38
|
+
if all_errors:
|
|
39
|
+
for err in all_errors:
|
|
40
|
+
print(f"ERROR: {err}")
|
|
41
|
+
if all_warnings:
|
|
42
|
+
for warn in all_warnings:
|
|
43
|
+
print(f"WARNING: {warn}")
|
|
44
|
+
if not all_errors and not all_warnings:
|
|
45
|
+
print(f"Validated {len(map_results)} feature maps: all passed.")
|
|
46
|
+
elif not all_errors:
|
|
47
|
+
print(
|
|
48
|
+
f"Validated {len(map_results)} feature maps: "
|
|
49
|
+
f"{len(all_warnings)} warning(s), 0 error(s)."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
exit_code = 0
|
|
53
|
+
if all_errors:
|
|
54
|
+
exit_code = 2 if strict else 1
|
|
55
|
+
elif strict and all_warnings:
|
|
56
|
+
exit_code = 2
|
|
57
|
+
|
|
58
|
+
result["exit_code"] = exit_code
|
|
59
|
+
return result, exit_code
|
feature_map/config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
DEFAULT_APPS = []
|
|
6
|
+
DEFAULT_REQUIRED_SECTIONS = ["purpose", "entry_points"]
|
|
7
|
+
DEFAULT_FEATURES_DIR = ".features"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_config(repo_root: Path) -> dict:
|
|
11
|
+
config_path = repo_root / ".feature-map.yaml"
|
|
12
|
+
config = {
|
|
13
|
+
"features_dir": DEFAULT_FEATURES_DIR,
|
|
14
|
+
"apps": list(DEFAULT_APPS),
|
|
15
|
+
"required_sections": list(DEFAULT_REQUIRED_SECTIONS),
|
|
16
|
+
}
|
|
17
|
+
if config_path.is_file():
|
|
18
|
+
with config_path.open(encoding="utf-8") as handle:
|
|
19
|
+
loaded = yaml.safe_load(handle) or {}
|
|
20
|
+
if isinstance(loaded, dict):
|
|
21
|
+
config.update(loaded)
|
|
22
|
+
if not isinstance(config.get("apps"), list):
|
|
23
|
+
config["apps"] = list(DEFAULT_APPS)
|
|
24
|
+
return config
|
feature_map/discover.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from feature_map.errors import FeaturesNotFoundError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _is_git_root(path: Path) -> bool:
|
|
8
|
+
return (path / ".git").exists()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_repo_root(start: Path) -> Path:
|
|
12
|
+
start = start.resolve()
|
|
13
|
+
try:
|
|
14
|
+
result = subprocess.run(
|
|
15
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
check=True,
|
|
19
|
+
cwd=start,
|
|
20
|
+
)
|
|
21
|
+
toplevel = result.stdout.strip()
|
|
22
|
+
if toplevel:
|
|
23
|
+
return Path(toplevel)
|
|
24
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
current = start
|
|
28
|
+
while current != current.parent:
|
|
29
|
+
if _is_git_root(current):
|
|
30
|
+
return current
|
|
31
|
+
current = current.parent
|
|
32
|
+
return start
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_features_dir(start: Path) -> Path:
|
|
36
|
+
start = start.resolve()
|
|
37
|
+
repo_root = find_repo_root(start)
|
|
38
|
+
|
|
39
|
+
if _is_git_root(repo_root):
|
|
40
|
+
root_candidate = repo_root / ".features"
|
|
41
|
+
if root_candidate.is_dir():
|
|
42
|
+
return root_candidate
|
|
43
|
+
|
|
44
|
+
current = start
|
|
45
|
+
while True:
|
|
46
|
+
candidate = current / ".features"
|
|
47
|
+
if candidate.is_dir():
|
|
48
|
+
return candidate
|
|
49
|
+
if _is_git_root(current):
|
|
50
|
+
break
|
|
51
|
+
if current.parent == current:
|
|
52
|
+
break
|
|
53
|
+
current = current.parent
|
|
54
|
+
|
|
55
|
+
root_candidate = repo_root / ".features"
|
|
56
|
+
if root_candidate.is_dir():
|
|
57
|
+
return root_candidate
|
|
58
|
+
|
|
59
|
+
raise FeaturesNotFoundError()
|
feature_map/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class FeaturesNotFoundError(Exception):
|
|
2
|
+
def __init__(self, message=None, suggestion=None):
|
|
3
|
+
self.message = message or (
|
|
4
|
+
"No .features/ found. Run `feature-map init` from the repo root."
|
|
5
|
+
)
|
|
6
|
+
self.suggestion = suggestion or (
|
|
7
|
+
'Run "feature-map init" to scaffold .features/, the agent skill, and a bin shim.'
|
|
8
|
+
)
|
|
9
|
+
super().__init__(self.message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CliError(Exception):
|
|
13
|
+
def __init__(self, message, suggestion=None, exit_code=1):
|
|
14
|
+
self.message = message
|
|
15
|
+
self.suggestion = suggestion
|
|
16
|
+
self.exit_code = exit_code
|
|
17
|
+
super().__init__(message)
|
feature_map/graph.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict, List, Optional, Set
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from feature_map.loader import extract_related_slug, list_map_files
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_graph(features_dir: Path) -> Dict[str, List[str]]:
|
|
10
|
+
graph: Dict[str, List[str]] = {}
|
|
11
|
+
for path in list_map_files(features_dir):
|
|
12
|
+
slug = path.stem
|
|
13
|
+
graph.setdefault(slug, [])
|
|
14
|
+
try:
|
|
15
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
16
|
+
except yaml.YAMLError:
|
|
17
|
+
continue
|
|
18
|
+
related = data.get("related_features") or []
|
|
19
|
+
if not isinstance(related, list):
|
|
20
|
+
continue
|
|
21
|
+
for entry in related:
|
|
22
|
+
target = extract_related_slug(entry)
|
|
23
|
+
if target:
|
|
24
|
+
graph[slug].append(target)
|
|
25
|
+
return graph
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def graph_data(
|
|
29
|
+
features_dir: Path,
|
|
30
|
+
root: Optional[str] = None,
|
|
31
|
+
) -> dict:
|
|
32
|
+
full_graph = build_graph(features_dir)
|
|
33
|
+
nodes: Set[str] = set()
|
|
34
|
+
edges = []
|
|
35
|
+
|
|
36
|
+
if root:
|
|
37
|
+
root = root.replace(".yaml", "")
|
|
38
|
+
visited = set()
|
|
39
|
+
stack = [root]
|
|
40
|
+
|
|
41
|
+
def visit(node):
|
|
42
|
+
if node in visited:
|
|
43
|
+
return
|
|
44
|
+
visited.add(node)
|
|
45
|
+
nodes.add(node)
|
|
46
|
+
for target in full_graph.get(node, []):
|
|
47
|
+
edges.append({"from": node, "to": target})
|
|
48
|
+
visit(target)
|
|
49
|
+
|
|
50
|
+
visit(root)
|
|
51
|
+
for edge in edges:
|
|
52
|
+
nodes.add(edge["to"])
|
|
53
|
+
else:
|
|
54
|
+
nodes = set(full_graph.keys())
|
|
55
|
+
for source, targets in full_graph.items():
|
|
56
|
+
nodes.add(source)
|
|
57
|
+
for target in targets:
|
|
58
|
+
nodes.add(target)
|
|
59
|
+
edges.append({"from": source, "to": target})
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
"nodes": sorted(nodes),
|
|
63
|
+
"edges": edges,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_mermaid(data: dict) -> str:
|
|
68
|
+
lines = ["graph LR"]
|
|
69
|
+
for edge in data.get("edges", []):
|
|
70
|
+
lines.append(f" {edge['from']} --> {edge['to']}")
|
|
71
|
+
return "\n".join(lines) + "\n"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_dot(data: dict) -> str:
|
|
75
|
+
lines = ["digraph feature_map {"]
|
|
76
|
+
for edge in data.get("edges", []):
|
|
77
|
+
lines.append(f' "{edge["from"]}" -> "{edge["to"]}";')
|
|
78
|
+
lines.append("}")
|
|
79
|
+
return "\n".join(lines) + "\n"
|
feature_map/loader.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from feature_map.errors import CliError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_slug(name: str) -> str:
|
|
13
|
+
return name.lower().replace(" ", "_").replace("-", "_")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def list_map_files(features_dir: Path):
|
|
17
|
+
return sorted(features_dir.glob("*.yaml"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_map_path(features_dir: Path, name: str) -> Path:
|
|
21
|
+
slug = normalize_slug(name)
|
|
22
|
+
exact = features_dir / f"{slug}.yaml"
|
|
23
|
+
if exact.exists():
|
|
24
|
+
return exact
|
|
25
|
+
fallback = features_dir / f"{name}.yaml"
|
|
26
|
+
if fallback.exists():
|
|
27
|
+
return fallback
|
|
28
|
+
for path in features_dir.glob("*.yaml"):
|
|
29
|
+
if path.stem == slug or path.stem == name:
|
|
30
|
+
return path
|
|
31
|
+
raise CliError(
|
|
32
|
+
f'Feature "{name}" not found.',
|
|
33
|
+
suggestion='Run "feature-map list" to see available features.',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_map(path: Path) -> dict:
|
|
38
|
+
text = path.read_text(encoding="utf-8")
|
|
39
|
+
try:
|
|
40
|
+
data = yaml.safe_load(text)
|
|
41
|
+
except yaml.YAMLError as exc:
|
|
42
|
+
raise CliError(f"Failed to parse {path.name}: {exc}") from exc
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
raise CliError(f"Feature map {path.name} must be a YAML mapping.")
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_map_text(path: Path) -> str:
|
|
49
|
+
return path.read_text(encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_related_slug(entry) -> Optional[str]:
|
|
53
|
+
if not isinstance(entry, str):
|
|
54
|
+
return None
|
|
55
|
+
match = re.match(r'^["\']?([a-zA-Z0-9_-]+)', entry.strip())
|
|
56
|
+
if not match:
|
|
57
|
+
return None
|
|
58
|
+
return normalize_slug(match.group(1))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def all_slugs(features_dir: Path) -> set[str]:
|
|
62
|
+
return {path.stem for path in list_map_files(features_dir)}
|
feature_map/output.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from feature_map.errors import CliError, FeaturesNotFoundError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def emit(data, as_json=False, stream=None):
|
|
10
|
+
stream = stream or sys.stdout
|
|
11
|
+
if as_json:
|
|
12
|
+
json.dump(data, stream, indent=2, default=str)
|
|
13
|
+
stream.write("\n")
|
|
14
|
+
elif isinstance(data, str):
|
|
15
|
+
stream.write(data)
|
|
16
|
+
if not data.endswith("\n"):
|
|
17
|
+
stream.write("\n")
|
|
18
|
+
elif isinstance(data, dict) and "features" in data and len(data) == 1:
|
|
19
|
+
yaml.dump(data, stream, default_flow_style=False)
|
|
20
|
+
else:
|
|
21
|
+
yaml.dump(data, stream, default_flow_style=False, sort_keys=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def emit_error(error, as_json=False, stream=None):
|
|
25
|
+
stream = stream or (sys.stdout if as_json else sys.stderr)
|
|
26
|
+
if isinstance(error, CliError):
|
|
27
|
+
message = error.message
|
|
28
|
+
suggestion = error.suggestion
|
|
29
|
+
exit_code = error.exit_code
|
|
30
|
+
elif isinstance(error, FeaturesNotFoundError):
|
|
31
|
+
message = error.message
|
|
32
|
+
suggestion = error.suggestion
|
|
33
|
+
exit_code = 1
|
|
34
|
+
else:
|
|
35
|
+
message = str(error)
|
|
36
|
+
suggestion = None
|
|
37
|
+
exit_code = 1
|
|
38
|
+
|
|
39
|
+
if as_json:
|
|
40
|
+
payload = {
|
|
41
|
+
"ok": False,
|
|
42
|
+
"error": message,
|
|
43
|
+
"exit_code": exit_code,
|
|
44
|
+
}
|
|
45
|
+
if suggestion:
|
|
46
|
+
payload["suggestion"] = suggestion
|
|
47
|
+
json.dump(payload, stream, indent=2)
|
|
48
|
+
stream.write("\n")
|
|
49
|
+
else:
|
|
50
|
+
stream.write(f"Error: {message}\n")
|
|
51
|
+
if suggestion:
|
|
52
|
+
stream.write(f"Suggestion: {suggestion}\n")
|
|
53
|
+
return exit_code
|
|
54
|
+
|