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.
- wintersolve/__init__.py +13 -0
- wintersolve/__main__.py +6 -0
- wintersolve/cli.py +324 -0
- wintersolve/logging_config.py +59 -0
- wintersolve/models.py +110 -0
- wintersolve/modules/__init__.py +5 -0
- wintersolve/modules/architecture.py +79 -0
- wintersolve/modules/brain.py +74 -0
- wintersolve/modules/command_detector.py +242 -0
- wintersolve/modules/debugger.py +165 -0
- wintersolve/modules/docs_assistant.py +162 -0
- wintersolve/modules/explainer.py +198 -0
- wintersolve/modules/recommendations.py +138 -0
- wintersolve/modules/reviewer.py +130 -0
- wintersolve/modules/scanner.py +271 -0
- wintersolve/modules/security.py +329 -0
- wintersolve/project.py +289 -0
- wintersolve/providers/__init__.py +21 -0
- wintersolve/providers/base.py +31 -0
- wintersolve/providers/examples.py +112 -0
- wintersolve/py.typed +1 -0
- wintersolve/report.py +252 -0
- wintersolve/workflows/__init__.py +5 -0
- wintersolve/workflows/registry.py +25 -0
- wintersolve-0.3.0.dist-info/METADATA +263 -0
- wintersolve-0.3.0.dist-info/RECORD +30 -0
- wintersolve-0.3.0.dist-info/WHEEL +5 -0
- wintersolve-0.3.0.dist-info/entry_points.txt +2 -0
- wintersolve-0.3.0.dist-info/licenses/LICENSE +22 -0
- wintersolve-0.3.0.dist-info/top_level.txt +1 -0
wintersolve/__init__.py
ADDED
|
@@ -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"]
|
wintersolve/__main__.py
ADDED
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,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
|
+
)
|