fde-workflow-cli 0.1.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.
@@ -0,0 +1,257 @@
1
+ """`fde-workflow path`: open paths, move them through their life cycle, link execution work."""
2
+
3
+ import datetime as dt
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from fde_workflow_cli import clock
10
+ from fde_workflow_cli.commands.common import (
11
+ RootOption,
12
+ find_path,
13
+ open_store,
14
+ parse_date,
15
+ record,
16
+ require,
17
+ user_errors,
18
+ )
19
+ from fde_workflow_cli.errors import FdeError
20
+ from fde_workflow_cli.model import DecisionType, DirectionPath, PathStatus, StageName
21
+
22
+ app = typer.Typer(help="Manage paths: hypotheses that move the KPIs.", no_args_is_help=True)
23
+
24
+ TRANSITIONS: dict[PathStatus, set[PathStatus]] = {
25
+ PathStatus.PROPOSED: {PathStatus.ACTIVE, PathStatus.DISCARDED},
26
+ PathStatus.ACTIVE: {PathStatus.MEASURING, PathStatus.DISCARDED},
27
+ PathStatus.MEASURING: {PathStatus.VALIDATED, PathStatus.DISCARDED, PathStatus.ACTIVE},
28
+ PathStatus.VALIDATED: set(),
29
+ PathStatus.DISCARDED: set(),
30
+ }
31
+ STATUSES_AT_ADD = (PathStatus.PROPOSED, PathStatus.ACTIVE, PathStatus.MEASURING)
32
+
33
+
34
+ def parse_expects(items: list[str]) -> dict[str, float]:
35
+ expects: dict[str, float] = {}
36
+ for item in items:
37
+ kpi_id, separator, value = item.partition("=")
38
+ if not separator:
39
+ raise FdeError(f"--expects {item!r} must look like KPI_ID=VALUE.")
40
+ try:
41
+ expects[kpi_id.strip()] = float(value)
42
+ except ValueError as error:
43
+ raise FdeError(f"--expects {item!r}: {value!r} is not a number.") from error
44
+ return expects
45
+
46
+
47
+ def normalise_ref(root: Path, ref: str) -> str:
48
+ """URLs stay as they are; files become POSIX paths relative to the repository root."""
49
+ if "://" in ref:
50
+ return ref
51
+ path = Path(ref)
52
+ absolute = (path if path.is_absolute() else Path.cwd() / path).resolve()
53
+ if absolute.is_relative_to(root.resolve()):
54
+ return absolute.relative_to(root.resolve()).as_posix()
55
+ return path.as_posix()
56
+
57
+
58
+ @app.command()
59
+ def add(
60
+ path_id: Annotated[str, typer.Argument(help="Kebab-case id, e.g. auto-triage.")],
61
+ hypothesis: Annotated[
62
+ str, typer.Option(help='"If we do X, we expect KPI Y to go from A to B."')
63
+ ],
64
+ expects: Annotated[
65
+ list[str], typer.Option("--expects", help="KPI_ID=VALUE expected; repeat per KPI.")
66
+ ],
67
+ reason: Annotated[
68
+ str | None, typer.Option(help="Why the path opens (not needed with --inferred).")
69
+ ] = None,
70
+ status: Annotated[
71
+ PathStatus, typer.Option(help="Initial status: proposed, active or measuring.")
72
+ ] = PathStatus.PROPOSED,
73
+ activated: Annotated[
74
+ str | None, typer.Option(help="When work started, YYYY-MM-DD (default: today).")
75
+ ] = None,
76
+ maturity: Annotated[
77
+ StageName | None, typer.Option(help="The path's own stage, when it differs.")
78
+ ] = None,
79
+ link: Annotated[
80
+ list[str] | None, typer.Option("--link", help="Execution artifact; repeat it.")
81
+ ] = None,
82
+ inferred: Annotated[
83
+ bool, typer.Option("--inferred", help="Inferred from existing work; needs confirmation.")
84
+ ] = False,
85
+ root: RootOption = None,
86
+ ) -> None:
87
+ """Open a path; records a path-open decision unless it is --inferred."""
88
+ with user_errors():
89
+ store = open_store(root)
90
+ direction = store.load()
91
+ if direction.path(path_id):
92
+ raise FdeError(f"Path {path_id} already exists: use `fde-workflow path update`.")
93
+ if status not in STATUSES_AT_ADD:
94
+ raise FdeError("A path starts as proposed, active or measuring; use `path move` later.")
95
+ if not inferred:
96
+ reason = require(reason, "--reason", "to open a path (or use --inferred)")
97
+ started: dt.date | None = None
98
+ if activated:
99
+ started = parse_date(activated, "--activated")
100
+ elif status is not PathStatus.PROPOSED:
101
+ started = clock.today()
102
+ direction.paths.append(
103
+ DirectionPath(
104
+ id=path_id,
105
+ hypothesis=hypothesis,
106
+ expects=parse_expects(expects),
107
+ status=status,
108
+ activated=started,
109
+ maturity=maturity,
110
+ confirmed=not inferred,
111
+ links=[normalise_ref(store.root, ref) for ref in link or []],
112
+ )
113
+ )
114
+ store.save(direction)
115
+ if not inferred:
116
+ record(
117
+ store,
118
+ DecisionType.PATH_OPEN,
119
+ f"Open path {path_id}: {hypothesis}",
120
+ reason or "",
121
+ refs=[path_id],
122
+ )
123
+ typer.echo(f"Added path {path_id} ({status}{', inferred' if inferred else ''}).")
124
+
125
+
126
+ @app.command()
127
+ def update(
128
+ path_id: Annotated[str, typer.Argument()],
129
+ hypothesis: Annotated[str | None, typer.Option()] = None,
130
+ expects: Annotated[
131
+ list[str] | None, typer.Option("--expects", help="Replaces every expectation.")
132
+ ] = None,
133
+ maturity: Annotated[StageName | None, typer.Option()] = None,
134
+ learning: Annotated[str | None, typer.Option()] = None,
135
+ root: RootOption = None,
136
+ ) -> None:
137
+ """Change a path's hypothesis, expectations, maturity or learning."""
138
+ with user_errors():
139
+ store = open_store(root)
140
+ direction = store.load()
141
+ path = find_path(direction, path_id)
142
+ if hypothesis is not None:
143
+ path.hypothesis = hypothesis
144
+ if expects is not None:
145
+ path.expects = parse_expects(expects)
146
+ if maturity is not None:
147
+ path.maturity = maturity
148
+ if learning is not None:
149
+ path.learning = learning
150
+ store.save(direction)
151
+ typer.echo(f"Updated path {path_id}.")
152
+
153
+
154
+ @app.command()
155
+ def move(
156
+ path_id: Annotated[str, typer.Argument()],
157
+ status: Annotated[PathStatus, typer.Argument(help="The new status.")],
158
+ reason: Annotated[str, typer.Option(help="Why the status changes.")],
159
+ learning: Annotated[
160
+ str | None, typer.Option(help="What the path taught us (required to discard).")
161
+ ] = None,
162
+ approved_by: Annotated[str | None, typer.Option("--approved-by")] = None,
163
+ root: RootOption = None,
164
+ ) -> None:
165
+ """Move a path: proposed → active → measuring → validated | discarded."""
166
+ with user_errors():
167
+ store = open_store(root)
168
+ direction = store.load()
169
+ path = find_path(direction, path_id)
170
+ if status not in TRANSITIONS[path.status]:
171
+ allowed = ", ".join(sorted(TRANSITIONS[path.status])) or "none, it is final"
172
+ raise FdeError(
173
+ f"Path {path_id} cannot go from {path.status} to {status} (allowed: {allowed})."
174
+ )
175
+ if status is PathStatus.DISCARDED and not learning:
176
+ raise FdeError("--learning is required to discard a path: what did it teach us?")
177
+ path.status = status
178
+ if status is PathStatus.ACTIVE and path.activated is None:
179
+ path.activated = clock.today()
180
+ if learning:
181
+ path.learning = learning
182
+ store.save(direction)
183
+ if status is PathStatus.VALIDATED:
184
+ record(
185
+ store,
186
+ DecisionType.PATH_VALIDATE,
187
+ f"Validate path {path_id}",
188
+ reason,
189
+ approved_by or "",
190
+ [path_id],
191
+ )
192
+ if status is PathStatus.DISCARDED:
193
+ record(
194
+ store,
195
+ DecisionType.PATH_DISCARD,
196
+ f"Discard path {path_id}",
197
+ reason,
198
+ approved_by or "",
199
+ [path_id],
200
+ )
201
+ typer.echo(f"Path {path_id} is now {status}.")
202
+
203
+
204
+ @app.command()
205
+ def link(
206
+ refs: Annotated[
207
+ list[str], typer.Argument(help="PATH_ID REF..., or REF... with --pre-framework.")
208
+ ],
209
+ pre_framework: Annotated[
210
+ bool, typer.Option("--pre-framework", help="Mark artifacts as pre-framework instead.")
211
+ ] = False,
212
+ root: RootOption = None,
213
+ ) -> None:
214
+ """Link execution artifacts (files or URLs) to a path, or mark them pre-framework."""
215
+ with user_errors():
216
+ store = open_store(root)
217
+ direction = store.load()
218
+ known = direction.artifacts.pre_framework
219
+ if pre_framework:
220
+ targets = [normalise_ref(store.root, ref) for ref in refs]
221
+ known.extend(ref for ref in dict.fromkeys(targets) if ref not in known)
222
+ message = f"Marked {len(targets)} artifact(s) as pre-framework."
223
+ else:
224
+ if len(refs) < 2:
225
+ raise FdeError("Usage: fde-workflow path link PATH_ID REF... (or --pre-framework).")
226
+ path = find_path(direction, refs[0])
227
+ targets = [normalise_ref(store.root, ref) for ref in refs[1:]]
228
+ path.links.extend(ref for ref in dict.fromkeys(targets) if ref not in path.links)
229
+ direction.artifacts.pre_framework = [ref for ref in known if ref not in targets]
230
+ message = f"Linked {len(targets)} artifact(s) to {path.id}."
231
+ store.save(direction)
232
+ typer.echo(message)
233
+
234
+
235
+ @app.command()
236
+ def confirm(
237
+ path_id: Annotated[str, typer.Argument()],
238
+ reason: Annotated[
239
+ str, typer.Option(help="Why it is confirmed.")
240
+ ] = "Confirmed a path inferred from existing work.",
241
+ root: RootOption = None,
242
+ ) -> None:
243
+ """Mark an inferred path as confirmed; records a path-open decision."""
244
+ with user_errors():
245
+ store = open_store(root)
246
+ direction = store.load()
247
+ path = find_path(direction, path_id)
248
+ path.confirmed = True
249
+ store.save(direction)
250
+ record(
251
+ store,
252
+ DecisionType.PATH_OPEN,
253
+ f"Confirm path {path_id}: {path.hypothesis}",
254
+ reason,
255
+ refs=[path_id],
256
+ )
257
+ typer.echo(f"Path {path_id} confirmed.")
@@ -0,0 +1,123 @@
1
+ """Commands on the whole `.fde/`: init, detect, validate, status and context."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from fde_workflow_cli import clock, detect, rules, summary
10
+ from fde_workflow_cli.commands.common import RootOption, open_store, user_errors
11
+ from fde_workflow_cli.errors import FdeError
12
+ from fde_workflow_cli.model import Artifacts, Direction, Project, StageState
13
+ from fde_workflow_cli.store import Store, locate
14
+
15
+ app = typer.Typer(help="Name the project, its customer and its sponsor.", no_args_is_help=True)
16
+
17
+
18
+ def init(
19
+ root: RootOption = None,
20
+ name: Annotated[
21
+ str | None, typer.Option(help="Project name (default: the directory's name).")
22
+ ] = None,
23
+ preset: Annotated[
24
+ detect.Preset, typer.Option(help="Where execution artifacts live (auto detects it).")
25
+ ] = detect.Preset.AUTO,
26
+ glob: Annotated[
27
+ list[str] | None,
28
+ typer.Option(
29
+ "--glob", help="Where execution artifacts live; repeat it; overrides --preset."
30
+ ),
31
+ ] = None,
32
+ ) -> None:
33
+ """Create .fde/ with an empty direction in stage discovery."""
34
+ with user_errors():
35
+ directory = (root or Path.cwd()).resolve()
36
+ if root is None and locate(directory) != directory:
37
+ raise FdeError(
38
+ f"{locate(directory)} already has .fde/: run the commands from there, "
39
+ "or pass --root to create a separate direction on purpose."
40
+ )
41
+ store = Store(directory)
42
+ globs = glob or detect.globs_for(preset, directory)
43
+ store.create(
44
+ Direction(
45
+ project=Project(name=name or directory.name),
46
+ stage=StageState(entered=clock.today()),
47
+ artifacts=Artifacts(globs=globs),
48
+ )
49
+ )
50
+ typer.echo(
51
+ f"Created {store.dir} (stage discovery; artifact globs: {', '.join(globs) or 'none'})."
52
+ )
53
+
54
+
55
+ def detect_command(root: RootOption = None) -> None:
56
+ """Print the repository's methodology, artifacts and deployment signals as JSON."""
57
+ with user_errors():
58
+ store = open_store(root)
59
+ direction = store.load() if store.exists() else None
60
+ typer.echo(json.dumps(detect.report(store.root, direction), indent=2))
61
+
62
+
63
+ def validate(root: RootOption = None) -> None:
64
+ """Check .fde/ against the framework's rules; exit 1 when a rule is broken."""
65
+ with user_errors():
66
+ store = open_store(root)
67
+ issues = rules.check(store.load(), store.measurements(), store.dir)
68
+ for issue in issues:
69
+ typer.echo(f"{'✗' if issue.level == 'error' else '!'} [{issue.rule}] {issue.message}")
70
+ errors = sum(issue.level == "error" for issue in issues)
71
+ warnings = len(issues) - errors
72
+ if errors:
73
+ typer.echo(f"Invalid: {errors} error(s), {warnings} warning(s).")
74
+ raise typer.Exit(1)
75
+ typer.echo(f"Valid with {warnings} warning(s)." if warnings else "Valid.")
76
+
77
+
78
+ def status(root: RootOption = None) -> None:
79
+ """Summarise the direction for people."""
80
+ with user_errors():
81
+ store = open_store(root)
82
+ direction = store.load()
83
+ measurements = store.measurements()
84
+ issues = rules.check(direction, measurements, store.dir)
85
+ typer.echo(summary.render(direction, measurements, issues))
86
+
87
+
88
+ def context(
89
+ root: RootOption = None,
90
+ brief: Annotated[bool, typer.Option("--brief", help="Only what every session needs.")] = False,
91
+ ) -> None:
92
+ """Summarise the direction for Claude."""
93
+ with user_errors():
94
+ store = open_store(root)
95
+ direction = store.load()
96
+ measurements = store.measurements()
97
+ issues = rules.check(direction, measurements, store.dir)
98
+ typer.echo(summary.context(direction, measurements, issues, brief=brief))
99
+
100
+
101
+ @app.command("set")
102
+ def set_project(
103
+ name: Annotated[str | None, typer.Option(help="Project name.")] = None,
104
+ customer: Annotated[str | None, typer.Option(help="The customer.")] = None,
105
+ sponsor: Annotated[
106
+ str | None, typer.Option(help="Who approves direction changes and gate decisions.")
107
+ ] = None,
108
+ root: RootOption = None,
109
+ ) -> None:
110
+ """Set the project's name, customer or sponsor."""
111
+ with user_errors():
112
+ store = open_store(root)
113
+ direction = store.load()
114
+ changes = {
115
+ k: v
116
+ for k, v in {"name": name, "customer": customer, "sponsor": sponsor}.items()
117
+ if v is not None
118
+ }
119
+ if not changes:
120
+ raise FdeError("Nothing to change: pass --name, --customer or --sponsor.")
121
+ direction.project = direction.project.model_copy(update=changes)
122
+ store.save(direction)
123
+ typer.echo("Project updated.")
@@ -0,0 +1,155 @@
1
+ """`fde-workflow stage`: set the project stage, its gate criteria and gate decisions."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from fde_workflow_cli import clock
8
+ from fde_workflow_cli.commands.common import (
9
+ RootOption,
10
+ open_store,
11
+ parse_date,
12
+ record,
13
+ require,
14
+ user_errors,
15
+ )
16
+ from fde_workflow_cli.errors import FdeError
17
+ from fde_workflow_cli.model import (
18
+ STAGE_ORDER,
19
+ DecisionType,
20
+ GateDecision,
21
+ Outcome,
22
+ StageName,
23
+ StageState,
24
+ )
25
+
26
+ app = typer.Typer(help="Set the project stage and record gate decisions.", no_args_is_help=True)
27
+
28
+
29
+ @app.command("set")
30
+ def set_(
31
+ stage: Annotated[StageName, typer.Argument(help="The stage the project is in.")],
32
+ reason: Annotated[str, typer.Option(help="Why the stage is set without a gate.")],
33
+ outcome: Annotated[
34
+ Outcome | None, typer.Option(help="Required when the stage is closed.")
35
+ ] = None,
36
+ entered: Annotated[
37
+ str | None, typer.Option(help="When the project got there, YYYY-MM-DD (default: today).")
38
+ ] = None,
39
+ inferred: Annotated[
40
+ bool, typer.Option("--inferred", help="Inferred from evidence; needs confirmation.")
41
+ ] = False,
42
+ approved_by: Annotated[str | None, typer.Option("--approved-by")] = None,
43
+ root: RootOption = None,
44
+ ) -> None:
45
+ """Set the stage directly, without a gate (e.g. when adopting an existing project)."""
46
+ with user_errors():
47
+ store = open_store(root)
48
+ direction = store.load()
49
+ direction.stage = StageState(
50
+ current=stage,
51
+ entered=parse_date(entered, "--entered") if entered else clock.today(),
52
+ outcome=outcome,
53
+ confirmed=not inferred,
54
+ )
55
+ store.save(direction)
56
+ suffix = " (inferred)" if inferred else ""
57
+ record(
58
+ store,
59
+ DecisionType.STAGE_SET,
60
+ f"Stage set to {stage}{suffix}",
61
+ reason,
62
+ approved_by or "",
63
+ )
64
+ typer.echo(f"Stage set to {stage}{suffix}.")
65
+
66
+
67
+ @app.command()
68
+ def confirm(root: RootOption = None) -> None:
69
+ """Mark an inferred stage as confirmed."""
70
+ with user_errors():
71
+ store = open_store(root)
72
+ direction = store.load()
73
+ direction.stage.confirmed = True
74
+ store.save(direction)
75
+ typer.echo(f"Stage {direction.stage.current} confirmed.")
76
+
77
+
78
+ @app.command()
79
+ def criteria(
80
+ items: Annotated[list[str], typer.Argument(help="One argument per gate criterion.")],
81
+ reason: Annotated[str | None, typer.Option(help="Why agreed criteria change.")] = None,
82
+ approved_by: Annotated[
83
+ str | None, typer.Option("--approved-by", help="Who approves the change.")
84
+ ] = None,
85
+ root: RootOption = None,
86
+ ) -> None:
87
+ """Set the current stage's gate criteria; replacing agreed criteria records a decision."""
88
+ with user_errors():
89
+ store = open_store(root)
90
+ direction = store.load()
91
+ stage = direction.stage
92
+ replacing = bool(stage.gate_criteria) and stage.gate_criteria != items
93
+ if replacing:
94
+ reason = require(reason, "--reason", "to change agreed gate criteria")
95
+ approved_by = require(approved_by, "--approved-by", "to change agreed gate criteria")
96
+ stage.gate_criteria = list(items)
97
+ store.save(direction)
98
+ if replacing:
99
+ record(
100
+ store,
101
+ DecisionType.STAGE_GATE,
102
+ f"Gate criteria of {stage.current} changed",
103
+ reason or "",
104
+ approved_by or "",
105
+ )
106
+ typer.echo(f"Gate criteria of {stage.current}: {len(items)}.")
107
+
108
+
109
+ @app.command()
110
+ def decide(
111
+ gate: Annotated[GateDecision, typer.Argument(help="go, recycle, hold or kill.")],
112
+ reason: Annotated[str, typer.Option(help="The evidence behind the decision.")],
113
+ approved_by: Annotated[str, typer.Option("--approved-by", help="The sponsor.")],
114
+ to: Annotated[
115
+ StageName | None, typer.Option("--to", help="With go: skip to a later stage.")
116
+ ] = None,
117
+ root: RootOption = None,
118
+ ) -> None:
119
+ """Record a gate decision and apply it to the stage."""
120
+ with user_errors():
121
+ store = open_store(root)
122
+ direction = store.load()
123
+ before = direction.stage
124
+ if before.current is StageName.CLOSED:
125
+ raise FdeError("The project is closed.")
126
+ if to is not None and gate is not GateDecision.GO:
127
+ raise FdeError("--to only applies to go.")
128
+ today = clock.today()
129
+ if gate is GateDecision.GO:
130
+ position = STAGE_ORDER.index(before.current)
131
+ target = to or STAGE_ORDER[position + 1]
132
+ if STAGE_ORDER.index(target) <= position:
133
+ raise FdeError(f"--to must be a stage after {before.current}.")
134
+ if target is StageName.CLOSED and before.current is not StageName.PRODUCTION:
135
+ raise FdeError("Only go from production closes a project; otherwise use kill.")
136
+ outcome = Outcome.COMPLETED if target is StageName.CLOSED else None
137
+ direction.stage = StageState(current=target, entered=today, outcome=outcome)
138
+ elif gate is GateDecision.KILL:
139
+ direction.stage = StageState(
140
+ current=StageName.CLOSED, entered=today, outcome=Outcome.DISCONTINUED
141
+ )
142
+ else:
143
+ before.on_hold = gate is GateDecision.HOLD
144
+ store.save(direction)
145
+ after = direction.stage.current
146
+ record(
147
+ store,
148
+ DecisionType.STAGE_GATE,
149
+ f"{gate} at {before.current}" + (f" → {after}" if after != before.current else ""),
150
+ reason,
151
+ approved_by,
152
+ )
153
+ typer.echo(
154
+ f"Recorded {gate}; stage is {after}{' (on hold)' if direction.stage.on_hold else ''}."
155
+ )
@@ -0,0 +1,123 @@
1
+ """Find the execution methodology and its artifacts in a repository. Read-only."""
2
+
3
+ import subprocess
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+
7
+ from fde_workflow_cli.model import Direction
8
+
9
+
10
+ class Preset(StrEnum):
11
+ AUTO = "auto"
12
+ SUPERPOWERS = "superpowers"
13
+ SPEC_KIT = "spec-kit"
14
+ NONE = "none"
15
+
16
+
17
+ PRESET_GLOBS: dict[Preset, list[str]] = {
18
+ Preset.SUPERPOWERS: ["docs/superpowers/specs/**", "docs/superpowers/plans/**"],
19
+ Preset.SPEC_KIT: ["specs/**"],
20
+ Preset.NONE: [],
21
+ }
22
+ DEPLOYMENT_MARKERS = (
23
+ "Dockerfile",
24
+ "docker-compose.yml",
25
+ "compose.yaml",
26
+ "Procfile",
27
+ "fly.toml",
28
+ "vercel.json",
29
+ "netlify.toml",
30
+ "render.yaml",
31
+ "app.yaml",
32
+ "serverless.yml",
33
+ "k8s",
34
+ "helm",
35
+ "terraform",
36
+ )
37
+ DEPLOYMENT_WORKFLOW_WORDS = ("deploy", "release", "publish")
38
+ READMES = ("README.md", "README.rst", "README.txt", "README")
39
+
40
+
41
+ def detect_methodology(root: Path) -> Preset:
42
+ if (root / "docs" / "superpowers").is_dir():
43
+ return Preset.SUPERPOWERS
44
+ if (root / ".specify").is_dir() or (root / "specs").is_dir():
45
+ return Preset.SPEC_KIT
46
+ return Preset.NONE
47
+
48
+
49
+ def globs_for(preset: Preset, root: Path) -> list[str]:
50
+ resolved = detect_methodology(root) if preset is Preset.AUTO else preset
51
+ return list(PRESET_GLOBS[resolved])
52
+
53
+
54
+ def artifact_files(root: Path, globs: list[str]) -> list[str]:
55
+ """Files under `root` matching any glob, as sorted POSIX paths relative to `root`."""
56
+ found: set[str] = set()
57
+ for pattern in globs:
58
+ for path in root.glob(pattern):
59
+ if path.is_file():
60
+ found.add(path.relative_to(root).as_posix())
61
+ return sorted(found)
62
+
63
+
64
+ def first_commit_date(root: Path, relative: str) -> str | None:
65
+ """Date (YYYY-MM-DD) of the commit that added the file, or None outside git history."""
66
+ try:
67
+ result = subprocess.run( # noqa: S603 - fixed read-only git query
68
+ ["git", "log", "--diff-filter=A", "--format=%cs", "--", relative], # noqa: S607
69
+ cwd=root,
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=30,
73
+ check=False,
74
+ )
75
+ except (OSError, subprocess.TimeoutExpired):
76
+ return None
77
+ dates = result.stdout.split()
78
+ return dates[-1] if result.returncode == 0 and dates else None
79
+
80
+
81
+ def deployment_signals(root: Path) -> list[str]:
82
+ signals = [name for name in DEPLOYMENT_MARKERS if (root / name).exists()]
83
+ workflows = root / ".github" / "workflows"
84
+ if workflows.is_dir():
85
+ signals += sorted(
86
+ f".github/workflows/{path.name}"
87
+ for path in workflows.iterdir()
88
+ if any(word in path.name.lower() for word in DEPLOYMENT_WORKFLOW_WORDS)
89
+ )
90
+ return signals
91
+
92
+
93
+ def linked_to(direction: Direction | None, relative: str) -> str | None:
94
+ """The path id that links the artifact, "pre-framework", or None."""
95
+ if direction is None:
96
+ return None
97
+ for path in direction.paths:
98
+ if relative in path.links:
99
+ return path.id
100
+ if relative in direction.artifacts.pre_framework:
101
+ return "pre-framework"
102
+ return None
103
+
104
+
105
+ def report(root: Path, direction: Direction | None) -> dict[str, object]:
106
+ methodology = detect_methodology(root)
107
+ globs = direction.artifacts.globs if direction else PRESET_GLOBS[methodology]
108
+ return {
109
+ "root": str(root),
110
+ "fde_initialised": direction is not None,
111
+ "methodology": methodology.value,
112
+ "globs": globs,
113
+ "readme": next((name for name in READMES if (root / name).is_file()), None),
114
+ "deployment_signals": deployment_signals(root),
115
+ "artifacts": [
116
+ {
117
+ "path": relative,
118
+ "first_commit": first_commit_date(root, relative),
119
+ "linked_to": linked_to(direction, relative),
120
+ }
121
+ for relative in artifact_files(root, globs)
122
+ ],
123
+ }