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
feature_map/__init__.py
ADDED
feature_map/__main__.py
ADDED
feature_map/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
feature_map/bootstrap.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Phase 2 repo bootstrap: deploy skill, config, AGENTS.md, and optional shim."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from feature_map.paths import skill_dir as bundled_skill_dir
|
|
9
|
+
|
|
10
|
+
AGENTS_MARKER = "<!-- feature-map:start -->"
|
|
11
|
+
AGENTS_END = "<!-- feature-map:end -->"
|
|
12
|
+
LEGACY_AGENTS_MARKERS = (
|
|
13
|
+
("<!-- featuremap:start -->", "<!-- featuremap:end -->"),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
AGENTS_BLOCK = f"""{AGENTS_MARKER}
|
|
17
|
+
## Feature Map
|
|
18
|
+
|
|
19
|
+
**ALWAYS** use Feature Map before feature work, debugging, PRDs, or plans.
|
|
20
|
+
Do not implement from a cold grep when a map exists.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
./bin/feature-map list
|
|
24
|
+
./bin/feature-map search <keyword>
|
|
25
|
+
./bin/feature-map find <path-fragment>
|
|
26
|
+
./bin/feature-map <feature-name>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Maps in `.features/*.yaml` are the authoritative cross-app architecture source.
|
|
30
|
+
Keep them dense: fields, not essays.
|
|
31
|
+
|
|
32
|
+
If `list` is empty or search/find miss the area you are about to change,
|
|
33
|
+
**scour the existing code and author maps first** (skill:
|
|
34
|
+
`feature-map` → `references/existing-repos.md`). Cluster by user-visible
|
|
35
|
+
capability, not by file. Prefer `entry_points` that exist on disk, then
|
|
36
|
+
run `feature-map validate` and `feature-map check`.
|
|
37
|
+
{AGENTS_END}
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
SHIM_SCRIPT = """#!/usr/bin/env bash
|
|
41
|
+
set -euo pipefail
|
|
42
|
+
SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
|
43
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
44
|
+
|
|
45
|
+
if [ -x "$ROOT/.venv/bin/feature-map" ]; then
|
|
46
|
+
exec "$ROOT/.venv/bin/feature-map" "$@"
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# Global install, but not this shim (bin/ may be on PATH).
|
|
50
|
+
if command -v feature-map >/dev/null 2>&1; then
|
|
51
|
+
CAND="$(command -v feature-map)"
|
|
52
|
+
CAND_ABS="$(cd "$(dirname "$CAND")" && pwd)/$(basename "$CAND")"
|
|
53
|
+
if [ "$CAND_ABS" != "$SELF" ]; then
|
|
54
|
+
exec "$CAND" "$@"
|
|
55
|
+
fi
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
if python3 -c "import feature_map" >/dev/null 2>&1; then
|
|
59
|
+
exec python3 -m feature_map "$@"
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
echo "feature-map is not installed. Try: pip install feature-map-cli" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
DEFAULT_CONFIG = """# Feature Map CLI configuration
|
|
67
|
+
features_dir: .features
|
|
68
|
+
apps: []
|
|
69
|
+
required_sections:
|
|
70
|
+
- purpose
|
|
71
|
+
- entry_points
|
|
72
|
+
min_cli_version: "1.0.0"
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def detect_skill_parent(repo_root: Path) -> Path:
|
|
77
|
+
agents = repo_root / ".agents" / "skills"
|
|
78
|
+
grok = repo_root / ".grok" / "skills"
|
|
79
|
+
if grok.exists() and not agents.exists():
|
|
80
|
+
return grok
|
|
81
|
+
return agents
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def copy_skill(dest_parent: Path, force: bool = False) -> Path:
|
|
85
|
+
source = bundled_skill_dir()
|
|
86
|
+
dest = dest_parent / "feature-map"
|
|
87
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
if not source.is_dir():
|
|
90
|
+
raise FileNotFoundError(f"Bundled skill not found at {source}")
|
|
91
|
+
|
|
92
|
+
for path in source.rglob("*"):
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
continue
|
|
95
|
+
relative = path.relative_to(source)
|
|
96
|
+
target = dest / relative
|
|
97
|
+
if target.exists() and not force:
|
|
98
|
+
continue
|
|
99
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
shutil.copy2(path, target)
|
|
101
|
+
return dest
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_config(repo_root: Path, force: bool = False) -> Path:
|
|
105
|
+
target = repo_root / ".feature-map.yaml"
|
|
106
|
+
if target.exists() and not force:
|
|
107
|
+
return target
|
|
108
|
+
target.write_text(DEFAULT_CONFIG, encoding="utf-8")
|
|
109
|
+
return target
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_shim(repo_root: Path, force: bool = False) -> Path:
|
|
113
|
+
bin_dir = repo_root / "bin"
|
|
114
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
target = bin_dir / "feature-map"
|
|
116
|
+
if target.exists() and not force:
|
|
117
|
+
return target
|
|
118
|
+
target.write_text(SHIM_SCRIPT, encoding="utf-8")
|
|
119
|
+
target.chmod(target.stat().st_mode | 0o111)
|
|
120
|
+
return target
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _replace_marked_block(text: str, block: str):
|
|
124
|
+
pairs = ((AGENTS_MARKER, AGENTS_END),) + LEGACY_AGENTS_MARKERS
|
|
125
|
+
for start_tok, end_tok in pairs:
|
|
126
|
+
if start_tok in text and end_tok in text:
|
|
127
|
+
start = text.index(start_tok)
|
|
128
|
+
end = text.index(end_tok) + len(end_tok)
|
|
129
|
+
new_text = text[:start] + block.rstrip() + text[end:]
|
|
130
|
+
if not new_text.endswith("\n"):
|
|
131
|
+
new_text += "\n"
|
|
132
|
+
return new_text
|
|
133
|
+
if start_tok in text:
|
|
134
|
+
return text
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def append_agents_snippet(repo_root: Path) -> Path:
|
|
139
|
+
target = repo_root / "AGENTS.md"
|
|
140
|
+
block = AGENTS_BLOCK.strip() + "\n"
|
|
141
|
+
if target.exists():
|
|
142
|
+
text = target.read_text(encoding="utf-8")
|
|
143
|
+
replaced = _replace_marked_block(text, block)
|
|
144
|
+
if replaced is not None:
|
|
145
|
+
if replaced != text:
|
|
146
|
+
target.write_text(replaced, encoding="utf-8")
|
|
147
|
+
return target
|
|
148
|
+
if not text.endswith("\n"):
|
|
149
|
+
text += "\n"
|
|
150
|
+
target.write_text(text + "\n" + block, encoding="utf-8")
|
|
151
|
+
else:
|
|
152
|
+
target.write_text("# Agent instructions\n\n" + block, encoding="utf-8")
|
|
153
|
+
return target
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def ensure_features_dir(repo_root: Path, features_dir_name: str = ".features") -> Path:
|
|
157
|
+
features_dir = repo_root / features_dir_name
|
|
158
|
+
features_dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
gitkeep = features_dir / ".gitkeep"
|
|
160
|
+
if not any(features_dir.glob("*.yaml")) and not gitkeep.exists():
|
|
161
|
+
gitkeep.write_text("", encoding="utf-8")
|
|
162
|
+
return features_dir
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def bootstrap_repo(
|
|
166
|
+
repo_root: Path,
|
|
167
|
+
*,
|
|
168
|
+
upgrade_skill: bool = False,
|
|
169
|
+
agents: bool = True,
|
|
170
|
+
shim: bool = True,
|
|
171
|
+
force: bool = False,
|
|
172
|
+
) -> dict:
|
|
173
|
+
features_dir = ensure_features_dir(repo_root)
|
|
174
|
+
skill_parent = detect_skill_parent(repo_root)
|
|
175
|
+
skill_path = copy_skill(skill_parent, force=upgrade_skill or force)
|
|
176
|
+
config_path = write_config(repo_root, force=False)
|
|
177
|
+
shim_path = write_shim(repo_root, force=force) if shim else None
|
|
178
|
+
agents_path = append_agents_snippet(repo_root) if agents else None
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"ok": True,
|
|
182
|
+
"repo_root": str(repo_root),
|
|
183
|
+
"features_dir": str(features_dir),
|
|
184
|
+
"skill": str(skill_path),
|
|
185
|
+
"config": str(config_path),
|
|
186
|
+
"shim": str(shim_path) if shim_path else None,
|
|
187
|
+
"agents": str(agents_path) if agents_path else None,
|
|
188
|
+
"upgraded_skill": bool(upgrade_skill or force),
|
|
189
|
+
}
|
feature_map/cli.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from feature_map._version import __version__
|
|
6
|
+
from feature_map.commands.check_cmd import run_check
|
|
7
|
+
from feature_map.commands.find_cmd import run_find
|
|
8
|
+
from feature_map.commands.graph_cmd import run_graph
|
|
9
|
+
from feature_map.commands.impact_cmd import run_impact
|
|
10
|
+
from feature_map.commands.init_cmd import run_bootstrap, run_init_map
|
|
11
|
+
from feature_map.commands.install_cmd import run_install
|
|
12
|
+
from feature_map.commands.list_cmd import run_list
|
|
13
|
+
from feature_map.commands.search_cmd import run_search
|
|
14
|
+
from feature_map.commands.show_cmd import run_show
|
|
15
|
+
from feature_map.commands.stats_cmd import run_stats
|
|
16
|
+
from feature_map.commands.validate_cmd import run_validate
|
|
17
|
+
from feature_map.config import load_config
|
|
18
|
+
from feature_map.discover import find_features_dir, find_repo_root
|
|
19
|
+
from feature_map.errors import CliError, FeaturesNotFoundError
|
|
20
|
+
from feature_map.output import emit, emit_error
|
|
21
|
+
|
|
22
|
+
COMMANDS = {
|
|
23
|
+
"list",
|
|
24
|
+
"show",
|
|
25
|
+
"search",
|
|
26
|
+
"find",
|
|
27
|
+
"graph",
|
|
28
|
+
"validate",
|
|
29
|
+
"check",
|
|
30
|
+
"impact",
|
|
31
|
+
"stats",
|
|
32
|
+
"init",
|
|
33
|
+
"install",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
OPTIONAL_FEATURES_COMMANDS = {"init", "install"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_parser():
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="feature-map",
|
|
42
|
+
description="Feature Map CLI — cross-app architecture research tool",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("--json", action="store_true", help="Machine-readable JSON output")
|
|
45
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
46
|
+
|
|
47
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
48
|
+
|
|
49
|
+
subparsers.add_parser("list", help="List all feature slugs")
|
|
50
|
+
subparsers.add_parser("install", help="Verify install and repo setup")
|
|
51
|
+
|
|
52
|
+
show_parser = subparsers.add_parser("show", help="Show a feature map")
|
|
53
|
+
show_parser.add_argument("name", help="Feature slug")
|
|
54
|
+
show_parser.add_argument("--section", help="Show a single top-level section")
|
|
55
|
+
|
|
56
|
+
search_parser = subparsers.add_parser("search", help="Full-text search across maps")
|
|
57
|
+
search_parser.add_argument("query", help="Search query")
|
|
58
|
+
|
|
59
|
+
find_parser = subparsers.add_parser("find", help="Find maps referencing a path fragment")
|
|
60
|
+
find_parser.add_argument("fragment", help="Path or file fragment")
|
|
61
|
+
|
|
62
|
+
graph_parser = subparsers.add_parser("graph", help="Related-features graph")
|
|
63
|
+
graph_parser.add_argument("name", nargs="?", help="Optional root feature for subgraph")
|
|
64
|
+
graph_parser.add_argument(
|
|
65
|
+
"--format",
|
|
66
|
+
choices=["mermaid", "json", "dot"],
|
|
67
|
+
default="mermaid",
|
|
68
|
+
help="Output format",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
validate_parser = subparsers.add_parser("validate", help="Validate feature maps")
|
|
72
|
+
validate_parser.add_argument(
|
|
73
|
+
"--strict",
|
|
74
|
+
action="store_true",
|
|
75
|
+
help="Treat warnings as failures (exit 2)",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
subparsers.add_parser("check", help="Check entry-point path staleness")
|
|
79
|
+
|
|
80
|
+
impact_parser = subparsers.add_parser("impact", help="Features referencing a file")
|
|
81
|
+
impact_parser.add_argument("file", help="File path fragment")
|
|
82
|
+
impact_parser.add_argument(
|
|
83
|
+
"--transitive",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="Include transitive related_features",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
subparsers.add_parser("stats", help="Coverage statistics")
|
|
89
|
+
|
|
90
|
+
init_parser = subparsers.add_parser(
|
|
91
|
+
"init",
|
|
92
|
+
help="Bootstrap this repo, or scaffold a feature map when <name> is given",
|
|
93
|
+
)
|
|
94
|
+
init_parser.add_argument(
|
|
95
|
+
"name",
|
|
96
|
+
nargs="?",
|
|
97
|
+
help="Feature slug to scaffold (omit to bootstrap the repo)",
|
|
98
|
+
)
|
|
99
|
+
init_parser.add_argument("--force", action="store_true", help="Overwrite existing files")
|
|
100
|
+
init_parser.add_argument(
|
|
101
|
+
"--upgrade-skill",
|
|
102
|
+
action="store_true",
|
|
103
|
+
help="Refresh the agent skill from this package version",
|
|
104
|
+
)
|
|
105
|
+
init_parser.add_argument(
|
|
106
|
+
"--no-agents",
|
|
107
|
+
dest="agents",
|
|
108
|
+
action="store_false",
|
|
109
|
+
help="Do not write or update AGENTS.md",
|
|
110
|
+
)
|
|
111
|
+
init_parser.add_argument(
|
|
112
|
+
"--no-shim",
|
|
113
|
+
dest="shim",
|
|
114
|
+
action="store_false",
|
|
115
|
+
help="Do not write bin/feature-map",
|
|
116
|
+
)
|
|
117
|
+
init_parser.set_defaults(agents=True, shim=True)
|
|
118
|
+
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"bare_name",
|
|
121
|
+
nargs="?",
|
|
122
|
+
help="Feature name (show alias when no subcommand)",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return parser
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_context(optional=False):
|
|
129
|
+
start = Path.cwd()
|
|
130
|
+
repo_root = find_repo_root(start)
|
|
131
|
+
config = load_config(repo_root)
|
|
132
|
+
try:
|
|
133
|
+
features_dir = find_features_dir(start)
|
|
134
|
+
except FeaturesNotFoundError:
|
|
135
|
+
if optional:
|
|
136
|
+
features_dir = repo_root / config.get("features_dir", ".features")
|
|
137
|
+
else:
|
|
138
|
+
raise
|
|
139
|
+
return features_dir, repo_root, config
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def dispatch(args):
|
|
143
|
+
command = args.command
|
|
144
|
+
as_json = args.json
|
|
145
|
+
optional = command in OPTIONAL_FEATURES_COMMANDS
|
|
146
|
+
features_dir, repo_root, config = resolve_context(optional=optional)
|
|
147
|
+
|
|
148
|
+
if command is None and args.bare_name:
|
|
149
|
+
if args.bare_name in COMMANDS:
|
|
150
|
+
parser = build_parser()
|
|
151
|
+
parser.error(f'command "{args.bare_name}" requires explicit subcommand syntax')
|
|
152
|
+
command = "show"
|
|
153
|
+
args.name = args.bare_name
|
|
154
|
+
|
|
155
|
+
if command == "list":
|
|
156
|
+
return 0, run_list(features_dir, as_json=as_json)
|
|
157
|
+
|
|
158
|
+
if command == "install":
|
|
159
|
+
return 0, run_install(repo_root, as_json=as_json)
|
|
160
|
+
|
|
161
|
+
if command == "show":
|
|
162
|
+
return 0, run_show(features_dir, args.name, section=args.section, as_json=as_json)
|
|
163
|
+
|
|
164
|
+
if command == "search":
|
|
165
|
+
return 0, run_search(features_dir, args.query, as_json=as_json)
|
|
166
|
+
|
|
167
|
+
if command == "find":
|
|
168
|
+
return 0, run_find(features_dir, args.fragment, as_json=as_json)
|
|
169
|
+
|
|
170
|
+
if command == "graph":
|
|
171
|
+
result = run_graph(
|
|
172
|
+
features_dir,
|
|
173
|
+
name=getattr(args, "name", None),
|
|
174
|
+
fmt=getattr(args, "format", "mermaid"),
|
|
175
|
+
as_json=as_json,
|
|
176
|
+
)
|
|
177
|
+
return 0, result
|
|
178
|
+
|
|
179
|
+
if command == "validate":
|
|
180
|
+
result, exit_code = run_validate(features_dir, strict=args.strict, as_json=as_json)
|
|
181
|
+
return exit_code, result
|
|
182
|
+
|
|
183
|
+
if command == "check":
|
|
184
|
+
return 0, run_check(features_dir, repo_root, config.get("apps", []), as_json=as_json)
|
|
185
|
+
|
|
186
|
+
if command == "impact":
|
|
187
|
+
return 0, run_impact(
|
|
188
|
+
features_dir,
|
|
189
|
+
args.file,
|
|
190
|
+
transitive=args.transitive,
|
|
191
|
+
as_json=as_json,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if command == "stats":
|
|
195
|
+
return 0, run_stats(features_dir, as_json=as_json)
|
|
196
|
+
|
|
197
|
+
if command == "init":
|
|
198
|
+
if getattr(args, "name", None):
|
|
199
|
+
return 0, run_init_map(
|
|
200
|
+
features_dir,
|
|
201
|
+
args.name,
|
|
202
|
+
force=args.force,
|
|
203
|
+
as_json=as_json,
|
|
204
|
+
)
|
|
205
|
+
return 0, run_bootstrap(
|
|
206
|
+
repo_root,
|
|
207
|
+
upgrade_skill=args.upgrade_skill,
|
|
208
|
+
agents=args.agents,
|
|
209
|
+
shim=args.shim,
|
|
210
|
+
force=args.force,
|
|
211
|
+
as_json=as_json,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
parser = build_parser()
|
|
215
|
+
parser.print_help()
|
|
216
|
+
return 1, None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def preprocess_argv(argv):
|
|
220
|
+
argv = list(argv)
|
|
221
|
+
if not argv:
|
|
222
|
+
return argv
|
|
223
|
+
|
|
224
|
+
json_flag = False
|
|
225
|
+
cleaned = []
|
|
226
|
+
for arg in argv:
|
|
227
|
+
if arg == "--json":
|
|
228
|
+
json_flag = True
|
|
229
|
+
else:
|
|
230
|
+
cleaned.append(arg)
|
|
231
|
+
if json_flag:
|
|
232
|
+
cleaned.insert(0, "--json")
|
|
233
|
+
argv = cleaned
|
|
234
|
+
|
|
235
|
+
idx = 0
|
|
236
|
+
while idx < len(argv) and argv[idx].startswith("-"):
|
|
237
|
+
idx += 1
|
|
238
|
+
if idx < len(argv) and argv[idx] not in COMMANDS:
|
|
239
|
+
argv.insert(idx, "show")
|
|
240
|
+
return argv
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main(argv=None):
|
|
244
|
+
argv = preprocess_argv(argv or sys.argv[1:])
|
|
245
|
+
parser = build_parser()
|
|
246
|
+
args = parser.parse_args(argv)
|
|
247
|
+
as_json = args.json
|
|
248
|
+
|
|
249
|
+
if args.command is None and not args.bare_name:
|
|
250
|
+
parser.print_help()
|
|
251
|
+
return 1
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
exit_code, result = dispatch(args)
|
|
255
|
+
if result is not None:
|
|
256
|
+
graph_json = (
|
|
257
|
+
args.command == "graph" and getattr(args, "format", None) == "json"
|
|
258
|
+
)
|
|
259
|
+
if as_json or graph_json:
|
|
260
|
+
emit(result, as_json=True)
|
|
261
|
+
elif isinstance(result, str):
|
|
262
|
+
sys.stdout.write(result)
|
|
263
|
+
if not result.endswith("\n"):
|
|
264
|
+
sys.stdout.write("\n")
|
|
265
|
+
elif args.command == "list":
|
|
266
|
+
emit(result, as_json=False)
|
|
267
|
+
return exit_code
|
|
268
|
+
except (CliError, FeaturesNotFoundError) as exc:
|
|
269
|
+
return emit_error(exc, as_json=as_json)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from feature_map.path_extract import check_paths
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_check(features_dir: Path, repo_root: Path, apps, as_json: bool = False):
|
|
7
|
+
issues = check_paths(features_dir, repo_root, apps)
|
|
8
|
+
payload = {
|
|
9
|
+
"ok": True,
|
|
10
|
+
"issue_count": len(issues),
|
|
11
|
+
"issues": issues,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if as_json:
|
|
15
|
+
return payload
|
|
16
|
+
|
|
17
|
+
if not issues:
|
|
18
|
+
print("No stale paths detected.")
|
|
19
|
+
return payload
|
|
20
|
+
|
|
21
|
+
for issue in issues:
|
|
22
|
+
print(f"{issue['feature']}: missing {issue['path']}")
|
|
23
|
+
return payload
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from feature_map.loader import list_map_files
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_find(features_dir: Path, fragment: str, as_json: bool = False):
|
|
7
|
+
results = []
|
|
8
|
+
fragment_lower = fragment.lower()
|
|
9
|
+
|
|
10
|
+
for path in list_map_files(features_dir):
|
|
11
|
+
matches = []
|
|
12
|
+
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
13
|
+
if fragment_lower in line.lower():
|
|
14
|
+
matches.append({"line": line_no, "text": line.strip()})
|
|
15
|
+
if matches:
|
|
16
|
+
results.append({"feature": path.stem, "matches": matches})
|
|
17
|
+
|
|
18
|
+
payload = {
|
|
19
|
+
"ok": True,
|
|
20
|
+
"fragment": fragment,
|
|
21
|
+
"count": len(results),
|
|
22
|
+
"results": results,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if as_json:
|
|
26
|
+
return payload
|
|
27
|
+
|
|
28
|
+
if not results:
|
|
29
|
+
print(f"No features reference '{fragment}'.")
|
|
30
|
+
return payload
|
|
31
|
+
|
|
32
|
+
for item in results:
|
|
33
|
+
print(item["feature"])
|
|
34
|
+
for match in item["matches"][:5]:
|
|
35
|
+
print(f" L{match['line']}: {match['text']}")
|
|
36
|
+
return payload
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from feature_map.graph import format_dot, format_mermaid, graph_data
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def run_graph(features_dir, name=None, fmt="mermaid", as_json=False):
|
|
5
|
+
data = graph_data(features_dir, root=name)
|
|
6
|
+
|
|
7
|
+
if fmt == "json" or as_json:
|
|
8
|
+
return {"ok": True, **data}
|
|
9
|
+
|
|
10
|
+
if fmt == "dot":
|
|
11
|
+
return format_dot(data)
|
|
12
|
+
|
|
13
|
+
if fmt == "mermaid":
|
|
14
|
+
return format_mermaid(data)
|
|
15
|
+
|
|
16
|
+
from feature_map.errors import CliError
|
|
17
|
+
|
|
18
|
+
raise CliError(f'Unknown graph format "{fmt}".', suggestion="Use mermaid, json, or dot.")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from feature_map.graph import build_graph
|
|
4
|
+
from feature_map.loader import list_map_files
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_impact(
|
|
8
|
+
features_dir: Path,
|
|
9
|
+
file_fragment: str,
|
|
10
|
+
transitive: bool = False,
|
|
11
|
+
as_json: bool = False,
|
|
12
|
+
):
|
|
13
|
+
fragment_lower = file_fragment.lower()
|
|
14
|
+
direct = []
|
|
15
|
+
|
|
16
|
+
for path in list_map_files(features_dir):
|
|
17
|
+
text = path.read_text(encoding="utf-8")
|
|
18
|
+
if fragment_lower in text.lower():
|
|
19
|
+
direct.append(path.stem)
|
|
20
|
+
|
|
21
|
+
affected = set(direct)
|
|
22
|
+
if transitive:
|
|
23
|
+
graph = build_graph(features_dir)
|
|
24
|
+
reverse = {}
|
|
25
|
+
for source, targets in graph.items():
|
|
26
|
+
for target in targets:
|
|
27
|
+
reverse.setdefault(target, []).append(source)
|
|
28
|
+
|
|
29
|
+
stack = list(direct)
|
|
30
|
+
while stack:
|
|
31
|
+
node = stack.pop()
|
|
32
|
+
for parent in reverse.get(node, []):
|
|
33
|
+
if parent not in affected:
|
|
34
|
+
affected.add(parent)
|
|
35
|
+
stack.append(parent)
|
|
36
|
+
|
|
37
|
+
results = sorted(affected)
|
|
38
|
+
payload = {
|
|
39
|
+
"ok": True,
|
|
40
|
+
"file": file_fragment,
|
|
41
|
+
"transitive": transitive,
|
|
42
|
+
"count": len(results),
|
|
43
|
+
"features": results,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if as_json:
|
|
47
|
+
return payload
|
|
48
|
+
|
|
49
|
+
if not results:
|
|
50
|
+
print(f"No features reference '{file_fragment}'.")
|
|
51
|
+
return payload
|
|
52
|
+
|
|
53
|
+
for feature in results:
|
|
54
|
+
print(feature)
|
|
55
|
+
return payload
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from feature_map.bootstrap import bootstrap_repo, ensure_features_dir
|
|
4
|
+
from feature_map.errors import CliError
|
|
5
|
+
from feature_map.loader import normalize_slug
|
|
6
|
+
from feature_map.paths import template_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_init_map(features_dir: Path, name: str, force: bool = False, as_json: bool = False):
|
|
10
|
+
slug = normalize_slug(name)
|
|
11
|
+
if not slug:
|
|
12
|
+
raise CliError(f'Invalid feature name "{name}".')
|
|
13
|
+
|
|
14
|
+
features_dir.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
target = features_dir / f"{slug}.yaml"
|
|
16
|
+
if target.exists() and not force:
|
|
17
|
+
raise CliError(
|
|
18
|
+
f"Feature map already exists: {target.name}",
|
|
19
|
+
suggestion="Use --force to overwrite.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
tpl = template_path()
|
|
23
|
+
if not tpl.is_file():
|
|
24
|
+
raise CliError("Template not found in the feature-map package.")
|
|
25
|
+
|
|
26
|
+
content = tpl.read_text(encoding="utf-8")
|
|
27
|
+
content = content.replace("{{feature_name}}", slug)
|
|
28
|
+
content = content.replace("{{FEATURE_TITLE}}", name.replace("_", " ").title())
|
|
29
|
+
|
|
30
|
+
target.write_text(content, encoding="utf-8")
|
|
31
|
+
payload = {"ok": True, "feature": slug, "path": str(target)}
|
|
32
|
+
|
|
33
|
+
if as_json:
|
|
34
|
+
return payload
|
|
35
|
+
|
|
36
|
+
print(f"Created {target}")
|
|
37
|
+
return payload
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_bootstrap(
|
|
41
|
+
repo_root: Path,
|
|
42
|
+
*,
|
|
43
|
+
upgrade_skill: bool = False,
|
|
44
|
+
agents: bool = True,
|
|
45
|
+
shim: bool = True,
|
|
46
|
+
force: bool = False,
|
|
47
|
+
as_json: bool = False,
|
|
48
|
+
):
|
|
49
|
+
payload = bootstrap_repo(
|
|
50
|
+
repo_root,
|
|
51
|
+
upgrade_skill=upgrade_skill,
|
|
52
|
+
agents=agents,
|
|
53
|
+
shim=shim,
|
|
54
|
+
force=force,
|
|
55
|
+
)
|
|
56
|
+
if as_json:
|
|
57
|
+
return payload
|
|
58
|
+
|
|
59
|
+
print("Initialized Feature Map in this repository:")
|
|
60
|
+
print(f" .features/: {payload['features_dir']}")
|
|
61
|
+
print(f" skill: {payload['skill']}")
|
|
62
|
+
print(f" config: {payload['config']}")
|
|
63
|
+
if payload.get("shim"):
|
|
64
|
+
print(f" shim: {payload['shim']}")
|
|
65
|
+
if payload.get("agents"):
|
|
66
|
+
print(f" AGENTS.md: {payload['agents']}")
|
|
67
|
+
return payload
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Backward-compatible alias used by older call sites / tests
|
|
71
|
+
def run_init(features_dir: Path, name: str, force: bool = False, as_json: bool = False):
|
|
72
|
+
return run_init_map(features_dir, name, force=force, as_json=as_json)
|