wintersolve 0.3.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,13 @@
1
+ """WinterSolve: offline-first repository intelligence for developers.
2
+
3
+ The public entry points are the ``wintersolve`` command (see ``wintersolve.cli``)
4
+ and the analyzer functions in ``wintersolve.modules``, which return plain
5
+ dataclasses you can render or serialise however you like.
6
+ """
7
+
8
+ from wintersolve.logging_config import configure_logging, get_logger, set_log_level
9
+
10
+ # Single source of truth for the version; pyproject.toml reads it at build time.
11
+ __version__ = "0.3.0"
12
+
13
+ __all__ = ["__version__", "configure_logging", "get_logger", "set_log_level"]
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m wintersolve`` as an alternative to the ``wintersolve`` script."""
2
+
3
+ from wintersolve.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
wintersolve/cli.py ADDED
@@ -0,0 +1,324 @@
1
+ """Command-line interface for WinterSolve.
2
+
3
+ Each command does three things and nothing more: collect input, call one
4
+ analyzer, and hand the result to a renderer. Analysis logic lives in
5
+ ``wintersolve.modules``; output formatting lives in ``wintersolve.report``.
6
+
7
+ Exit codes:
8
+ 0 success
9
+ 1 the analysis itself failed (unexpected error, unreadable input)
10
+ 2 invalid usage, or the target could not be analyzed (missing path, no Git)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import sys
17
+ from collections.abc import Callable
18
+ from enum import Enum
19
+ from pathlib import Path
20
+ from typing import Annotated, NoReturn, TypeVar
21
+
22
+ import typer
23
+ from rich.console import Console
24
+ from rich.markup import escape
25
+ from rich.table import Table
26
+
27
+ from wintersolve import __version__, configure_logging, get_logger
28
+ from wintersolve.modules.brain import build_brain_report
29
+ from wintersolve.modules.debugger import analyze_error_file, analyze_error_text
30
+ from wintersolve.modules.docs_assistant import suggest_docs
31
+ from wintersolve.modules.explainer import explain_file
32
+ from wintersolve.modules.reviewer import review_changes
33
+ from wintersolve.modules.scanner import scan_project
34
+ from wintersolve.project import resolve_project_path
35
+ from wintersolve.report import (
36
+ render_brain_report,
37
+ render_debug_analysis,
38
+ render_docs_suggestions,
39
+ render_explanation,
40
+ render_review_result,
41
+ render_scan_report,
42
+ )
43
+ from wintersolve.workflows.registry import get_workflows
44
+
45
+ logger = get_logger("wintersolve.cli")
46
+
47
+ EXIT_FAILURE = 1
48
+ EXIT_USAGE = 2
49
+
50
+ app = typer.Typer(
51
+ name="wintersolve",
52
+ help="Offline-first repository intelligence: understand, debug, document, and review code.",
53
+ epilog="Docs and examples: https://github.com/harshitkrhere/WinterSolve",
54
+ add_completion=False,
55
+ rich_markup_mode="rich",
56
+ no_args_is_help=True,
57
+ )
58
+ console = Console()
59
+ error_console = Console(stderr=True)
60
+
61
+ T = TypeVar("T")
62
+
63
+
64
+ class ReportFormat(str, Enum):
65
+ text = "text"
66
+ markdown = "markdown"
67
+ json = "json"
68
+
69
+
70
+ # Reusable argument definitions so every command validates paths the same way.
71
+ ProjectArgument = Annotated[
72
+ Path,
73
+ typer.Argument(
74
+ help="Project directory to analyze.",
75
+ exists=True,
76
+ file_okay=False,
77
+ dir_okay=True,
78
+ readable=True,
79
+ resolve_path=True,
80
+ ),
81
+ ]
82
+
83
+
84
+ def _version_callback(value: bool) -> None:
85
+ if value:
86
+ console.print(f"WinterSolve {__version__}")
87
+ raise typer.Exit()
88
+
89
+
90
+ @app.callback()
91
+ def _configure(
92
+ version: Annotated[ # noqa: ARG001 - handled eagerly by _version_callback
93
+ bool,
94
+ typer.Option(
95
+ "--version",
96
+ "-V",
97
+ help="Show the version and exit.",
98
+ callback=_version_callback,
99
+ is_eager=True,
100
+ ),
101
+ ] = False,
102
+ verbose: Annotated[
103
+ bool,
104
+ typer.Option("--verbose", "-v", help="Show debug logging on stderr."),
105
+ ] = False,
106
+ ) -> None:
107
+ """WinterSolve: understand any repository from the terminal, no account required."""
108
+ if verbose:
109
+ configure_logging(level=logging.DEBUG)
110
+
111
+
112
+ def main() -> None:
113
+ """Console script entry point (``wintersolve`` and ``python -m wintersolve``)."""
114
+ app()
115
+
116
+
117
+ # ------------------------------------------------------------------------ commands
118
+
119
+
120
+ @app.command()
121
+ def scan(
122
+ path: ProjectArgument = Path(),
123
+ output_format: Annotated[
124
+ ReportFormat,
125
+ typer.Option("--format", "-f", help="Output format.", case_sensitive=False),
126
+ ] = ReportFormat.text,
127
+ output: Annotated[
128
+ Path | None,
129
+ typer.Option("--output", "-o", help="Write the report to this file instead of stdout."),
130
+ ] = None,
131
+ ) -> None:
132
+ """Quick repository health check: languages, stack, layout, and hygiene gaps."""
133
+ result = _run(lambda: scan_project(path))
134
+ _emit(
135
+ render_scan_report(result, output_format=output_format.value), output_format.value, output
136
+ )
137
+
138
+
139
+ @app.command()
140
+ def brain(
141
+ path: ProjectArgument = Path(),
142
+ output_format: Annotated[
143
+ ReportFormat,
144
+ typer.Option("--format", "-f", help="Output format.", case_sensitive=False),
145
+ ] = ReportFormat.text,
146
+ output: Annotated[
147
+ Path | None,
148
+ typer.Option("--output", "-o", help="Write the report to this file instead of stdout."),
149
+ ] = None,
150
+ no_bandit: Annotated[
151
+ bool,
152
+ typer.Option("--no-bandit", help="Skip the optional Bandit pass (faster on large repos)."),
153
+ ] = False,
154
+ ) -> None:
155
+ """Full project intelligence report: architecture, commands, security, risks, next steps."""
156
+ result = _run(lambda: build_brain_report(path, run_bandit=not no_bandit))
157
+ _emit(
158
+ render_brain_report(result, output_format=output_format.value), output_format.value, output
159
+ )
160
+
161
+
162
+ @app.command()
163
+ def explain(
164
+ file: Annotated[
165
+ Path,
166
+ typer.Argument(
167
+ help="File to explain.",
168
+ exists=True,
169
+ file_okay=True,
170
+ dir_okay=False,
171
+ readable=True,
172
+ resolve_path=True,
173
+ ),
174
+ ],
175
+ project: Annotated[
176
+ Path,
177
+ typer.Option(
178
+ "--project",
179
+ "-p",
180
+ help="Project root; files outside it are refused.",
181
+ exists=True,
182
+ file_okay=False,
183
+ dir_okay=True,
184
+ readable=True,
185
+ resolve_path=True,
186
+ ),
187
+ ] = Path(),
188
+ ) -> None:
189
+ """Explain one file: what it is, what it defines, and what it depends on."""
190
+ try:
191
+ target = resolve_project_path(project, str(file))
192
+ except ValueError as error:
193
+ _usage_error(str(error))
194
+ result = _run(lambda: explain_file(target))
195
+ _emit(render_explanation(result))
196
+
197
+
198
+ @app.command()
199
+ def debug(
200
+ text: Annotated[
201
+ str | None,
202
+ typer.Option("--text", "-t", help="Error text to analyze.", rich_help_panel="Input"),
203
+ ] = None,
204
+ file: Annotated[
205
+ Path | None,
206
+ typer.Option(
207
+ "--file",
208
+ help="File containing the error output.",
209
+ exists=True,
210
+ file_okay=True,
211
+ dir_okay=False,
212
+ readable=True,
213
+ resolve_path=True,
214
+ rich_help_panel="Input",
215
+ ),
216
+ ] = None,
217
+ ) -> None:
218
+ """Analyze an error message, log, or stack trace. Reads stdin when piped."""
219
+ if text is not None and file is not None:
220
+ _usage_error("Provide only one of --text or --file.")
221
+
222
+ if file is not None:
223
+ result = _run(lambda: analyze_error_file(file))
224
+ elif text is not None:
225
+ result = _run(lambda: analyze_error_text(text))
226
+ elif not _stdin_is_interactive():
227
+ piped = sys.stdin.read()
228
+ if not piped.strip():
229
+ _usage_error("Nothing to analyze: stdin was empty.")
230
+ result = _run(lambda: analyze_error_text(piped, source="stdin"))
231
+ else:
232
+ _usage_error("Provide --text, --file, or pipe error output into the command.")
233
+
234
+ _emit(render_debug_analysis(result))
235
+
236
+
237
+ @app.command()
238
+ def docs(
239
+ path: ProjectArgument = Path(),
240
+ draft_readme: Annotated[
241
+ bool,
242
+ typer.Option("--draft-readme", help="Include a starter README draft in the output."),
243
+ ] = False,
244
+ ) -> None:
245
+ """Find missing README sections and hygiene files; optionally draft a README."""
246
+ result = _run(lambda: suggest_docs(path))
247
+ _emit(render_docs_suggestions(result, include_draft=draft_readme))
248
+
249
+
250
+ @app.command()
251
+ def review(path: ProjectArgument = Path()) -> None:
252
+ """Turn uncommitted Git changes into review risks and a pre-PR checklist."""
253
+ result = _run(lambda: review_changes(path))
254
+ _emit(render_review_result(result))
255
+ if not result.git_available:
256
+ raise typer.Exit(code=EXIT_USAGE)
257
+
258
+
259
+ @app.command()
260
+ def workflows() -> None:
261
+ """List available workflows and their output formats."""
262
+ table = Table(title="WinterSolve Workflows")
263
+ table.add_column("Name", style="cyan")
264
+ table.add_column("Summary", style="white")
265
+ table.add_column("Offline", justify="center")
266
+ table.add_column("Output Formats", style="green")
267
+ for workflow in get_workflows():
268
+ table.add_row(
269
+ workflow.name,
270
+ workflow.summary,
271
+ "yes" if workflow.offline else "no",
272
+ ", ".join(workflow.output_formats),
273
+ )
274
+ console.print(table)
275
+
276
+
277
+ # ------------------------------------------------------------------------- helpers
278
+
279
+
280
+ def _run(operation: Callable[[], T]) -> T:
281
+ """Run an analyzer and turn unexpected failures into a clean exit code 1.
282
+
283
+ ``typer.Exit`` is deliberately *not* caught here: it is how commands signal
284
+ a non-zero exit, and swallowing it would turn every exit into "Error:".
285
+ """
286
+ try:
287
+ return operation()
288
+ except typer.Exit:
289
+ raise
290
+ except Exception as error: # The CLI boundary reports failures; it never shows tracebacks.
291
+ logger.debug("Command failed", exc_info=error)
292
+ error_console.print(f"[red]Error:[/red] {escape(str(error))}")
293
+ raise typer.Exit(code=EXIT_FAILURE) from None
294
+
295
+
296
+ def _stdin_is_interactive() -> bool:
297
+ """True when stdin is a terminal rather than a pipe or file."""
298
+ return sys.stdin.isatty()
299
+
300
+
301
+ def _usage_error(message: str) -> NoReturn:
302
+ error_console.print(f"[red]Error:[/red] {escape(message)}")
303
+ raise typer.Exit(code=EXIT_USAGE)
304
+
305
+
306
+ def _emit(rendered: str, output_format: str = "text", output: Path | None = None) -> None:
307
+ """Print a report, or save it when ``--output`` was given.
308
+
309
+ Reports are printed as plain text: Rich markup and emoji codes are turned
310
+ off so a file called ``[id].tsx`` or a line containing ``:tada:`` comes out
311
+ exactly as written. JSON bypasses Rich entirely so it is safe to pipe.
312
+ """
313
+ if output is not None:
314
+ output.write_text(rendered + "\n", encoding="utf-8")
315
+ console.print(f"[green]Report saved to[/green] {escape(str(output))}")
316
+ return
317
+ if output_format == "json":
318
+ sys.stdout.write(rendered + "\n")
319
+ return
320
+ console.print(rendered, markup=False, highlight=False, emoji=False, soft_wrap=True)
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
@@ -0,0 +1,59 @@
1
+ """Quiet-by-default logging.
2
+
3
+ WinterSolve writes reports to stdout, so diagnostics go to stderr and only
4
+ warnings show unless ``--verbose`` raises the level. Loggers never propagate
5
+ to the root logger, which keeps host applications' logging untouched when
6
+ WinterSolve is used as a library.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import sys
13
+
14
+ DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
15
+ DEFAULT_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
16
+
17
+ _loggers: dict[str, logging.Logger] = {}
18
+
19
+
20
+ def get_logger(name: str = "wintersolve") -> logging.Logger:
21
+ """Return the named logger, creating and configuring it on first use."""
22
+ if name not in _loggers:
23
+ _loggers[name] = _create_logger(name)
24
+ return _loggers[name]
25
+
26
+
27
+ def _create_logger(name: str) -> logging.Logger:
28
+ logger = logging.getLogger(name)
29
+ logger.setLevel(logging.WARNING)
30
+ logger.propagate = False
31
+ if not logger.handlers:
32
+ handler = logging.StreamHandler(sys.stderr)
33
+ handler.setLevel(logging.WARNING)
34
+ handler.setFormatter(logging.Formatter(DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT))
35
+ logger.addHandler(handler)
36
+ return logger
37
+
38
+
39
+ def set_log_level(level: int) -> None:
40
+ """Change the level of every WinterSolve logger created so far."""
41
+ for logger in _loggers.values():
42
+ logger.setLevel(level)
43
+ for handler in logger.handlers:
44
+ handler.setLevel(level)
45
+
46
+
47
+ def configure_logging(
48
+ level: int = logging.INFO,
49
+ format_string: str | None = None,
50
+ date_format: str | None = None,
51
+ ) -> None:
52
+ """Set the level and, optionally, the message format for all WinterSolve loggers."""
53
+ set_log_level(level)
54
+ if format_string is None:
55
+ return
56
+ formatter = logging.Formatter(format_string, datefmt=date_format)
57
+ for logger in _loggers.values():
58
+ for handler in logger.handlers:
59
+ handler.setFormatter(formatter)
wintersolve/models.py ADDED
@@ -0,0 +1,110 @@
1
+ """Result types shared by the Repo Brain report and the analyzers that feed it.
2
+
3
+ Every result is a frozen dataclass so it can be rendered as text, Markdown, or
4
+ JSON without surprises. Analyzers build these; they never print.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import asdict, dataclass
11
+ from typing import Any
12
+
13
+ # Bump when the shape of the JSON report changes in a way integrations must
14
+ # know about. Additive fields do not require a bump.
15
+ BRAIN_SCHEMA_VERSION = 1
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ProjectIdentity:
20
+ name: str
21
+ path: str
22
+ exists: bool
23
+ offline_mode: bool
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class CommandCandidate:
28
+ """A setup, build, test, or run command inferred from project files."""
29
+
30
+ name: str
31
+ command: str
32
+ source: str
33
+ confidence: str
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ArchitectureSection:
38
+ """A top-level area of the repository and what it is probably for."""
39
+
40
+ name: str
41
+ path: str
42
+ purpose: str
43
+ notable_files: list[str]
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SecurityFinding:
48
+ """One thing the security scan wants a human to look at.
49
+
50
+ ``category`` is one of ``secret``, ``code-pattern``, or ``bandit`` and is
51
+ the field integrations should branch on; ``kind`` is the human label.
52
+ """
53
+
54
+ path: str
55
+ line: int
56
+ category: str
57
+ kind: str
58
+ severity: str
59
+ evidence: str
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class SecuritySummary:
64
+ status: str
65
+ offline_by_default: bool
66
+ files_checked: int
67
+ findings: list[SecurityFinding]
68
+ notes: list[str]
69
+
70
+ @staticmethod
71
+ def empty() -> SecuritySummary:
72
+ return SecuritySummary(
73
+ status="not checked",
74
+ offline_by_default=True,
75
+ files_checked=0,
76
+ findings=[],
77
+ notes=["Project path does not exist."],
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class BrainReport:
83
+ """The full Repo Brain result: everything WinterSolve learned about a project."""
84
+
85
+ identity: ProjectIdentity
86
+ languages: list[tuple[str, int]]
87
+ stack: list[str]
88
+ source_paths: list[str]
89
+ test_paths: list[str]
90
+ docs_health: list[str]
91
+ architecture: list[ArchitectureSection]
92
+ commands: list[CommandCandidate]
93
+ security: SecuritySummary
94
+ risks: list[str]
95
+ recommendations: list[str]
96
+ next_actions: list[str]
97
+
98
+ def to_dict(self) -> dict[str, Any]:
99
+ """Convert to plain JSON-friendly data with a stable, documented shape."""
100
+ data = asdict(self)
101
+ data["schema_version"] = BRAIN_SCHEMA_VERSION
102
+ data["languages"] = [{"name": name, "files": count} for name, count in self.languages]
103
+ for finding in data["security"]["findings"]:
104
+ finding["evidence"] = strip_control_characters(finding["evidence"])
105
+ return data
106
+
107
+
108
+ def strip_control_characters(text: str) -> str:
109
+ """Remove control characters so evidence snippets stay clean in JSON and terminals."""
110
+ return re.sub(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]", "", text)
@@ -0,0 +1,5 @@
1
+ """Analyzers, one developer workflow each.
2
+
3
+ Every module exposes a function that takes explicit inputs and returns a
4
+ frozen dataclass. Modules never print and never touch the network.
5
+ """
@@ -0,0 +1,79 @@
1
+ """Architecture map: what each top-level area of a repository is probably for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from wintersolve.models import ArchitectureSection
8
+ from wintersolve.project import walk_project
9
+
10
+ # Conventional directory names and the role they usually play. Anything else
11
+ # gets a neutral description rather than a guess.
12
+ PURPOSE_BY_NAME = {
13
+ "src": "Application or library source code",
14
+ "app": "Application entrypoints and routes",
15
+ "apps": "Applications in a monorepo",
16
+ "packages": "Packages in a monorepo",
17
+ "lib": "Shared library code",
18
+ "pkg": "Shared library code",
19
+ "cmd": "Command-line entrypoints",
20
+ "internal": "Private application code",
21
+ "api": "API layer",
22
+ "server": "Server-side code",
23
+ "client": "Client-side code",
24
+ "web": "Web frontend",
25
+ "frontend": "Web frontend",
26
+ "backend": "Backend services",
27
+ "services": "Service implementations",
28
+ "tests": "Automated tests",
29
+ "test": "Automated tests",
30
+ "spec": "Automated tests",
31
+ "__tests__": "Automated tests",
32
+ "docs": "Project documentation",
33
+ "doc": "Project documentation",
34
+ "examples": "Example inputs, outputs, or sample projects",
35
+ "scripts": "Developer and automation scripts",
36
+ "tools": "Developer tooling",
37
+ "templates": "Reusable templates and prompts",
38
+ "config": "Configuration files",
39
+ "configs": "Configuration files",
40
+ "infra": "Infrastructure definitions",
41
+ "deploy": "Deployment configuration",
42
+ "migrations": "Database migrations",
43
+ "public": "Static assets served as-is",
44
+ "static": "Static assets",
45
+ "assets": "Design and media assets",
46
+ "data": "Data files and fixtures",
47
+ "notebooks": "Jupyter notebooks",
48
+ ".github": "GitHub community and automation files",
49
+ }
50
+
51
+ MAX_SECTIONS = 20
52
+ MAX_NOTABLE_FILES = 8
53
+
54
+
55
+ def map_architecture(root: Path) -> list[ArchitectureSection]:
56
+ """Group files by top-level directory and describe each group."""
57
+ tree = walk_project(root)
58
+ files_by_area: dict[str, list[str]] = {}
59
+ for project_file in tree.files:
60
+ top_level, separator, _ = project_file.relative_path.partition("/")
61
+ if not separator:
62
+ continue # Files at the root are listed elsewhere; not an "area".
63
+ files_by_area.setdefault(top_level, []).append(project_file.relative_path)
64
+
65
+ sections = [
66
+ ArchitectureSection(
67
+ name=name,
68
+ path=name,
69
+ purpose=PURPOSE_BY_NAME.get(name, "Project area detected from repository structure"),
70
+ notable_files=_notable(files),
71
+ )
72
+ for name, files in sorted(files_by_area.items())
73
+ ]
74
+ return sections[:MAX_SECTIONS]
75
+
76
+
77
+ def _notable(files: list[str]) -> list[str]:
78
+ # Shallow files describe an area better than deeply nested ones.
79
+ return sorted(files, key=lambda path: (path.count("/"), path))[:MAX_NOTABLE_FILES]
@@ -0,0 +1,74 @@
1
+ """Repo Brain: compose every analyzer into one project intelligence report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from wintersolve.models import BrainReport, ProjectIdentity, SecuritySummary
8
+ from wintersolve.modules.architecture import map_architecture
9
+ from wintersolve.modules.command_detector import detect_commands
10
+ from wintersolve.modules.docs_assistant import suggest_docs
11
+ from wintersolve.modules.recommendations import (
12
+ build_brain_recommendations,
13
+ build_brain_risks,
14
+ build_docs_health,
15
+ build_next_actions,
16
+ )
17
+ from wintersolve.modules.scanner import scan_project
18
+ from wintersolve.modules.security import analyze_security
19
+
20
+
21
+ def build_brain_report(root: Path, *, run_bandit: bool = True) -> BrainReport:
22
+ """Analyze ``root`` with every module and assemble the combined report.
23
+
24
+ ``run_bandit=False`` skips the optional Bandit pass, which is the slow part
25
+ on large Python projects.
26
+ """
27
+ scan = scan_project(root)
28
+ if not scan.exists:
29
+ return _missing_project_report(root, scan.risks, scan.recommendations)
30
+
31
+ docs = suggest_docs(root, scan)
32
+ commands = detect_commands(root)
33
+ security = analyze_security(root, run_bandit=run_bandit)
34
+ architecture = map_architecture(root)
35
+ risks = build_brain_risks(scan, security, len(commands))
36
+
37
+ return BrainReport(
38
+ identity=ProjectIdentity(name=root.name, path=str(root), exists=True, offline_mode=True),
39
+ languages=scan.languages,
40
+ stack=scan.frameworks,
41
+ source_paths=scan.likely_source_paths,
42
+ test_paths=scan.likely_test_paths,
43
+ docs_health=build_docs_health(docs.missing_sections, scan.missing_recommended_files),
44
+ architecture=architecture,
45
+ commands=commands,
46
+ security=security,
47
+ risks=risks,
48
+ recommendations=build_brain_recommendations(scan, security, len(commands)),
49
+ next_actions=build_next_actions(
50
+ security=security,
51
+ command_count=len(commands),
52
+ has_architecture=bool(architecture),
53
+ risk_count=len(risks),
54
+ ),
55
+ )
56
+
57
+
58
+ def _missing_project_report(
59
+ root: Path, risks: list[str], recommendations: list[str]
60
+ ) -> BrainReport:
61
+ return BrainReport(
62
+ identity=ProjectIdentity(name=root.name, path=str(root), exists=False, offline_mode=True),
63
+ languages=[],
64
+ stack=[],
65
+ source_paths=[],
66
+ test_paths=[],
67
+ docs_health=["Project path does not exist, so documentation health could not be checked."],
68
+ architecture=[],
69
+ commands=[],
70
+ security=SecuritySummary.empty(),
71
+ risks=risks,
72
+ recommendations=recommendations,
73
+ next_actions=["Point WinterSolve at an existing project directory."],
74
+ )