tree2guide 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.
- tree2guide/__init__.py +50 -0
- tree2guide/cli.py +147 -0
- tree2guide/ignore.py +92 -0
- tree2guide/llm.py +211 -0
- tree2guide/renderers/__init__.py +7 -0
- tree2guide/renderers/html.py +218 -0
- tree2guide/renderers/json_renderer.py +48 -0
- tree2guide/renderers/llm.py +81 -0
- tree2guide/renderers/markdown.py +39 -0
- tree2guide/renderers/text.py +29 -0
- tree2guide/renderers/yaml_renderer.py +64 -0
- tree2guide/scanner.py +149 -0
- tree2guide-1.0.0.dist-info/METADATA +475 -0
- tree2guide-1.0.0.dist-info/RECORD +18 -0
- tree2guide-1.0.0.dist-info/WHEEL +5 -0
- tree2guide-1.0.0.dist-info/entry_points.txt +2 -0
- tree2guide-1.0.0.dist-info/licenses/LICENSE +21 -0
- tree2guide-1.0.0.dist-info/top_level.txt +1 -0
tree2guide/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide — see your project structure clearly.
|
|
3
|
+
|
|
4
|
+
Public API:
|
|
5
|
+
build_node_tree(root, matcher, options=None) -> TreeNode
|
|
6
|
+
build_tree(root, matcher, options=None) -> list[str] # backward compat
|
|
7
|
+
TreeNode, TreeOptions
|
|
8
|
+
ExcludeMatcher, GitignoreRule, load_exclude_patterns
|
|
9
|
+
analyze(node) -> LlmSummary
|
|
10
|
+
render_markdown / render_text / render_json / render_yaml / render_html / render_llm
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from tree2guide.ignore import (
|
|
14
|
+
DEFAULT_EXCLUDES,
|
|
15
|
+
EXCLUDE_FILENAME,
|
|
16
|
+
ExcludeMatcher,
|
|
17
|
+
GitignoreRule,
|
|
18
|
+
load_exclude_patterns,
|
|
19
|
+
)
|
|
20
|
+
from tree2guide.llm import LlmSummary, analyze
|
|
21
|
+
from tree2guide.renderers.html import render_html
|
|
22
|
+
from tree2guide.renderers.json_renderer import render_json
|
|
23
|
+
from tree2guide.renderers.llm import render_llm
|
|
24
|
+
from tree2guide.renderers.markdown import render_markdown
|
|
25
|
+
from tree2guide.renderers.text import render_text
|
|
26
|
+
from tree2guide.renderers.yaml_renderer import render_yaml
|
|
27
|
+
from tree2guide.scanner import TreeNode, TreeOptions, build_node_tree, build_tree
|
|
28
|
+
|
|
29
|
+
__version__ = "1.0.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"__version__",
|
|
33
|
+
"DEFAULT_EXCLUDES",
|
|
34
|
+
"EXCLUDE_FILENAME",
|
|
35
|
+
"ExcludeMatcher",
|
|
36
|
+
"GitignoreRule",
|
|
37
|
+
"load_exclude_patterns",
|
|
38
|
+
"TreeNode",
|
|
39
|
+
"TreeOptions",
|
|
40
|
+
"build_node_tree",
|
|
41
|
+
"build_tree",
|
|
42
|
+
"LlmSummary",
|
|
43
|
+
"analyze",
|
|
44
|
+
"render_markdown",
|
|
45
|
+
"render_text",
|
|
46
|
+
"render_json",
|
|
47
|
+
"render_yaml",
|
|
48
|
+
"render_html",
|
|
49
|
+
"render_llm",
|
|
50
|
+
]
|
tree2guide/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.cli — argument parsing and the `tree2guide` command entry point.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from tree2guide.ignore import EXCLUDE_FILENAME, ExcludeMatcher, load_exclude_patterns
|
|
10
|
+
from tree2guide.renderers.html import render_html
|
|
11
|
+
from tree2guide.renderers.json_renderer import render_json
|
|
12
|
+
from tree2guide.renderers.llm import render_llm
|
|
13
|
+
from tree2guide.renderers.markdown import render_markdown
|
|
14
|
+
from tree2guide.renderers.text import render_text
|
|
15
|
+
from tree2guide.renderers.yaml_renderer import render_yaml
|
|
16
|
+
from tree2guide.scanner import TreeOptions, build_node_tree
|
|
17
|
+
|
|
18
|
+
_FORMAT_EXTENSIONS = {
|
|
19
|
+
"markdown": ".md",
|
|
20
|
+
"text": ".txt",
|
|
21
|
+
"json": ".json",
|
|
22
|
+
"yaml": ".yaml",
|
|
23
|
+
"html": ".html",
|
|
24
|
+
"llm": ".txt",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="tree2guide",
|
|
31
|
+
description="Generate a structured tree view of a folder in multiple formats.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument("target", help="Path to the target folder")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-o", "--output", default=None,
|
|
36
|
+
help="Output file path (default: <folder>_tree.<ext> based on --format)",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--exclude-file", default=None,
|
|
40
|
+
help=f"Path to exclude file (default: <target>/{EXCLUDE_FILENAME})",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--title", default=None, help="Optional title above the tree")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--no-footer", action="store_true",
|
|
45
|
+
help="Omit the author/license footer (markdown and html only)",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--stdout", action="store_true",
|
|
49
|
+
help="Print to stdout instead of writing a file",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--format",
|
|
53
|
+
choices=["markdown", "text", "json", "yaml", "html", "llm"],
|
|
54
|
+
default="markdown",
|
|
55
|
+
help="Output format (default: markdown). Use 'llm' for an AI-friendly summary.",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--llm", action="store_true",
|
|
59
|
+
help="Shorthand for --format llm (AI-friendly project summary)",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--max-depth", type=int, default=None, metavar="N",
|
|
63
|
+
help="Limit recursion depth",
|
|
64
|
+
)
|
|
65
|
+
group = parser.add_mutually_exclusive_group()
|
|
66
|
+
group.add_argument("--dirs-only", action="store_true", help="Only show directories")
|
|
67
|
+
group.add_argument("--files-only", action="store_true", help="Only show files")
|
|
68
|
+
parser.add_argument("--no-hidden", action="store_true", help="Skip dotfiles/dotfolders")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--sort",
|
|
71
|
+
choices=["dirs-first", "files-first", "alpha"],
|
|
72
|
+
default="dirs-first",
|
|
73
|
+
help="Sort order within each folder (default: dirs-first)",
|
|
74
|
+
)
|
|
75
|
+
return parser
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _render(fmt: str, tree, title: str | None, include_footer: bool) -> str:
|
|
79
|
+
if fmt == "markdown":
|
|
80
|
+
return render_markdown(tree, title=title, include_footer=include_footer)
|
|
81
|
+
if fmt == "text":
|
|
82
|
+
return render_text(tree, title=title)
|
|
83
|
+
if fmt == "json":
|
|
84
|
+
return render_json(tree)
|
|
85
|
+
if fmt == "yaml":
|
|
86
|
+
return render_yaml(tree)
|
|
87
|
+
if fmt == "html":
|
|
88
|
+
return render_html(tree, title=title, include_footer=include_footer)
|
|
89
|
+
if fmt == "llm":
|
|
90
|
+
return render_llm(tree, title=title)
|
|
91
|
+
raise ValueError(f"Unknown format: {fmt}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv: list[str] | None = None) -> int:
|
|
95
|
+
parser = build_parser()
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
|
|
98
|
+
# --llm is a shorthand for --format llm
|
|
99
|
+
fmt = "llm" if args.llm else args.format
|
|
100
|
+
|
|
101
|
+
root = Path(args.target).resolve()
|
|
102
|
+
if not root.is_dir():
|
|
103
|
+
print(f"Error: '{root}' is not a valid directory.", file=sys.stderr)
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
exclude_file = (
|
|
107
|
+
Path(args.exclude_file).resolve() if args.exclude_file else root / EXCLUDE_FILENAME
|
|
108
|
+
)
|
|
109
|
+
patterns = load_exclude_patterns(exclude_file)
|
|
110
|
+
matcher = ExcludeMatcher(patterns)
|
|
111
|
+
|
|
112
|
+
options = TreeOptions(
|
|
113
|
+
max_depth=args.max_depth,
|
|
114
|
+
dirs_only=args.dirs_only,
|
|
115
|
+
files_only=args.files_only,
|
|
116
|
+
no_hidden=args.no_hidden,
|
|
117
|
+
sort=args.sort,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
tree = build_node_tree(root, matcher, options)
|
|
121
|
+
output = _render(fmt, tree, title=args.title, include_footer=not args.no_footer)
|
|
122
|
+
|
|
123
|
+
if args.stdout:
|
|
124
|
+
sys.stdout.buffer.write(output.encode("utf-8"))
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
ext = _FORMAT_EXTENSIONS[fmt]
|
|
128
|
+
# llm output gets a distinct suffix so it doesn't clobber a plain text file
|
|
129
|
+
suffix = "_llm" if fmt == "llm" else "_tree"
|
|
130
|
+
output_path = (
|
|
131
|
+
Path(args.output).resolve()
|
|
132
|
+
if args.output
|
|
133
|
+
else Path.cwd() / f"{root.name}{suffix}{ext}"
|
|
134
|
+
)
|
|
135
|
+
output_path.write_text(output, encoding="utf-8")
|
|
136
|
+
|
|
137
|
+
print(f"✅ Tree written to: {output_path}")
|
|
138
|
+
if exclude_file.is_file():
|
|
139
|
+
print(f" Used exclude file: {exclude_file}")
|
|
140
|
+
else:
|
|
141
|
+
print(f" No exclude file found at {exclude_file} (only defaults applied)")
|
|
142
|
+
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
tree2guide/ignore.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.ignore — gitignore-compatible pattern matching, zero dependencies.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULT_EXCLUDES: list[str] = [
|
|
9
|
+
".git",
|
|
10
|
+
".tree2ignore",
|
|
11
|
+
"__pycache__",
|
|
12
|
+
"*.pyc",
|
|
13
|
+
".DS_Store",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
EXCLUDE_FILENAME = ".tree2ignore"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_exclude_patterns(exclude_file: Path) -> list[str]:
|
|
20
|
+
patterns = list(DEFAULT_EXCLUDES)
|
|
21
|
+
if exclude_file.is_file():
|
|
22
|
+
for line in exclude_file.read_text(encoding="utf-8").splitlines():
|
|
23
|
+
stripped = line.strip()
|
|
24
|
+
if not stripped or stripped.startswith("#"):
|
|
25
|
+
continue
|
|
26
|
+
patterns.append(line.rstrip("\n"))
|
|
27
|
+
return patterns
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GitignoreRule:
|
|
31
|
+
def __init__(self, raw: str):
|
|
32
|
+
pattern = raw
|
|
33
|
+
self.negate = pattern.startswith("!")
|
|
34
|
+
if self.negate:
|
|
35
|
+
pattern = pattern[1:]
|
|
36
|
+
self.dir_only = pattern.endswith("/")
|
|
37
|
+
if self.dir_only:
|
|
38
|
+
pattern = pattern[:-1]
|
|
39
|
+
self.anchored = pattern.startswith("/")
|
|
40
|
+
if self.anchored:
|
|
41
|
+
pattern = pattern[1:]
|
|
42
|
+
has_slash = "/" in pattern
|
|
43
|
+
if not has_slash and not self.anchored:
|
|
44
|
+
pattern = "**/" + pattern
|
|
45
|
+
self.regex = re.compile(self._translate(pattern))
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _translate(pattern: str) -> str:
|
|
49
|
+
segments = pattern.split("/")
|
|
50
|
+
parts = []
|
|
51
|
+
for i, segment in enumerate(segments):
|
|
52
|
+
if segment == "**":
|
|
53
|
+
if i == 0 and i == len(segments) - 1:
|
|
54
|
+
parts.append(".*")
|
|
55
|
+
elif i == 0:
|
|
56
|
+
parts.append("(?:.*/)?")
|
|
57
|
+
elif i == len(segments) - 1:
|
|
58
|
+
parts.append("(?:/.*)?" if parts else ".*")
|
|
59
|
+
else:
|
|
60
|
+
parts.append("(?:[^/]+/)*")
|
|
61
|
+
continue
|
|
62
|
+
seg_regex = "".join(
|
|
63
|
+
"[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
|
|
64
|
+
for c in segment
|
|
65
|
+
)
|
|
66
|
+
if parts and segments[i - 1] != "**":
|
|
67
|
+
parts.append("/")
|
|
68
|
+
parts.append(seg_regex)
|
|
69
|
+
body = "".join(parts)
|
|
70
|
+
return f"^{body}$"
|
|
71
|
+
|
|
72
|
+
def matches(self, rel_path: str, is_dir: bool) -> bool:
|
|
73
|
+
parts = rel_path.split("/")
|
|
74
|
+
if not (self.dir_only and not is_dir):
|
|
75
|
+
if self.regex.match(rel_path):
|
|
76
|
+
return True
|
|
77
|
+
for k in range(1, len(parts)):
|
|
78
|
+
if self.regex.match("/".join(parts[:k])):
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ExcludeMatcher:
|
|
84
|
+
def __init__(self, patterns: list[str]):
|
|
85
|
+
self.rules: list[GitignoreRule] = [GitignoreRule(p) for p in patterns]
|
|
86
|
+
|
|
87
|
+
def is_excluded(self, rel_path: str, is_dir: bool) -> bool:
|
|
88
|
+
excluded = False
|
|
89
|
+
for rule in self.rules:
|
|
90
|
+
if rule.matches(rel_path, is_dir):
|
|
91
|
+
excluded = not rule.negate
|
|
92
|
+
return excluded
|
tree2guide/llm.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.llm — rule-based project heuristics for --llm mode.
|
|
3
|
+
|
|
4
|
+
Zero dependencies, zero network calls. All detection is done by inspecting
|
|
5
|
+
the names present in the TreeNode tree that the scanner already built —
|
|
6
|
+
no second filesystem pass is needed.
|
|
7
|
+
|
|
8
|
+
Produces a structured LlmSummary dataclass that the LLM renderer uses to
|
|
9
|
+
build its output.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from tree2guide.scanner import TreeNode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Detection tables
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
# Maps a filename/pattern to (language_or_framework, display_label)
|
|
22
|
+
_STACK_SIGNALS: list[tuple[str, str]] = [
|
|
23
|
+
# Python
|
|
24
|
+
("pyproject.toml", "Python (pyproject.toml)"),
|
|
25
|
+
("setup.py", "Python (setup.py)"),
|
|
26
|
+
("requirements.txt", "Python (requirements.txt)"),
|
|
27
|
+
("Pipfile", "Python (Pipfile)"),
|
|
28
|
+
("poetry.lock", "Python / Poetry"),
|
|
29
|
+
# JavaScript / TypeScript
|
|
30
|
+
("package.json", "Node.js / JavaScript"),
|
|
31
|
+
("tsconfig.json", "TypeScript"),
|
|
32
|
+
("next.config.js", "Next.js"),
|
|
33
|
+
("next.config.ts", "Next.js (TypeScript)"),
|
|
34
|
+
("nuxt.config.ts", "Nuxt.js"),
|
|
35
|
+
("nuxt.config.js", "Nuxt.js"),
|
|
36
|
+
("vite.config.ts", "Vite"),
|
|
37
|
+
("vite.config.js", "Vite"),
|
|
38
|
+
("angular.json", "Angular"),
|
|
39
|
+
("svelte.config.js", "SvelteKit"),
|
|
40
|
+
# Go
|
|
41
|
+
("go.mod", "Go"),
|
|
42
|
+
# Rust
|
|
43
|
+
("Cargo.toml", "Rust / Cargo"),
|
|
44
|
+
# Ruby
|
|
45
|
+
("Gemfile", "Ruby"),
|
|
46
|
+
("Rakefile", "Ruby / Rake"),
|
|
47
|
+
# Java / Kotlin / JVM
|
|
48
|
+
("pom.xml", "Java / Maven"),
|
|
49
|
+
("build.gradle", "Java / Gradle"),
|
|
50
|
+
("build.gradle.kts", "Kotlin / Gradle"),
|
|
51
|
+
# C / C++
|
|
52
|
+
("CMakeLists.txt", "C/C++ / CMake"),
|
|
53
|
+
("Makefile", "C/C++ / Make"),
|
|
54
|
+
# PHP
|
|
55
|
+
("composer.json", "PHP / Composer"),
|
|
56
|
+
# Swift / iOS
|
|
57
|
+
("Package.swift", "Swift / SPM"),
|
|
58
|
+
# Dart / Flutter
|
|
59
|
+
("pubspec.yaml", "Dart / Flutter"),
|
|
60
|
+
# .NET
|
|
61
|
+
("*.csproj", ".NET / C#"),
|
|
62
|
+
("*.fsproj", ".NET / F#"),
|
|
63
|
+
("*.sln", ".NET Solution"),
|
|
64
|
+
# Elixir
|
|
65
|
+
("mix.exs", "Elixir / Mix"),
|
|
66
|
+
# Haskell
|
|
67
|
+
("stack.yaml", "Haskell / Stack"),
|
|
68
|
+
("*.cabal", "Haskell / Cabal"),
|
|
69
|
+
# Docker / infra
|
|
70
|
+
("Dockerfile", "Docker"),
|
|
71
|
+
("docker-compose.yml", "Docker Compose"),
|
|
72
|
+
("docker-compose.yaml", "Docker Compose"),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# Files/dirs whose presence is worth flagging
|
|
76
|
+
_NOTABLE_FLAGS: list[tuple[str, str]] = [
|
|
77
|
+
(".env.example", "Environment template (.env.example) present"),
|
|
78
|
+
(".env", "Live .env file present (check it's gitignored!)"),
|
|
79
|
+
("tests", "Tests directory present"),
|
|
80
|
+
("test", "Test directory present"),
|
|
81
|
+
("__tests__", "Jest-style __tests__ directory present"),
|
|
82
|
+
("spec", "Spec directory present"),
|
|
83
|
+
(".github", "GitHub Actions / workflows present"),
|
|
84
|
+
(".gitlab-ci.yml", "GitLab CI config present"),
|
|
85
|
+
("Jenkinsfile", "Jenkins CI config present"),
|
|
86
|
+
(".circleci", "CircleCI config present"),
|
|
87
|
+
("azure-pipelines.yml", "Azure Pipelines config present"),
|
|
88
|
+
("LICENSE", "LICENSE file present"),
|
|
89
|
+
("LICENSE.md", "LICENSE file present"),
|
|
90
|
+
("CHANGELOG.md", "CHANGELOG present"),
|
|
91
|
+
("CONTRIBUTING.md", "CONTRIBUTING guide present"),
|
|
92
|
+
("Makefile", "Makefile present (build/task automation)"),
|
|
93
|
+
("docker-compose.yml", "Docker Compose present"),
|
|
94
|
+
("docker-compose.yaml", "Docker Compose present"),
|
|
95
|
+
(".pre-commit-config.yaml", "Pre-commit hooks configured"),
|
|
96
|
+
("renovate.json", "Renovate dependency-update bot configured"),
|
|
97
|
+
(".editorconfig", "EditorConfig present"),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Summary dataclass
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class LlmSummary:
|
|
107
|
+
"""Structured heuristic summary of the project."""
|
|
108
|
+
detected_stack: list[str] = field(default_factory=list)
|
|
109
|
+
file_count: int = 0
|
|
110
|
+
dir_count: int = 0
|
|
111
|
+
notable_flags: list[str] = field(default_factory=list)
|
|
112
|
+
top_level_dirs: list[str] = field(default_factory=list)
|
|
113
|
+
top_level_files: list[str] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Detection logic
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _collect_all_names(node: TreeNode) -> tuple[set[str], set[str]]:
|
|
121
|
+
"""
|
|
122
|
+
Walk the entire TreeNode tree and collect every file name and dir name
|
|
123
|
+
into two flat sets. Used for stack/flag detection without a second
|
|
124
|
+
filesystem pass.
|
|
125
|
+
"""
|
|
126
|
+
files: set[str] = set()
|
|
127
|
+
dirs: set[str] = set()
|
|
128
|
+
|
|
129
|
+
def _walk(n: TreeNode) -> None:
|
|
130
|
+
for child in n.children:
|
|
131
|
+
if child.is_symlink:
|
|
132
|
+
continue
|
|
133
|
+
if child.is_dir:
|
|
134
|
+
dirs.add(child.name)
|
|
135
|
+
_walk(child)
|
|
136
|
+
else:
|
|
137
|
+
files.add(child.name)
|
|
138
|
+
|
|
139
|
+
_walk(node)
|
|
140
|
+
return files, dirs
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _count_entries(node: TreeNode) -> tuple[int, int]:
|
|
144
|
+
"""Return (total_files, total_dirs) recursively."""
|
|
145
|
+
files = dirs = 0
|
|
146
|
+
for child in node.children:
|
|
147
|
+
if child.is_symlink:
|
|
148
|
+
continue
|
|
149
|
+
if child.is_dir:
|
|
150
|
+
dirs += 1
|
|
151
|
+
f, d = _count_entries(child)
|
|
152
|
+
files += f
|
|
153
|
+
dirs += d
|
|
154
|
+
else:
|
|
155
|
+
files += 1
|
|
156
|
+
return files, dirs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _matches_pattern(name: str, pattern: str) -> bool:
|
|
160
|
+
"""Simple glob-style match: supports leading/trailing '*' wildcards only."""
|
|
161
|
+
if pattern.startswith("*") and pattern.endswith("*"):
|
|
162
|
+
return pattern[1:-1] in name
|
|
163
|
+
if pattern.startswith("*"):
|
|
164
|
+
return name.endswith(pattern[1:])
|
|
165
|
+
if pattern.endswith("*"):
|
|
166
|
+
return name.startswith(pattern[:-1])
|
|
167
|
+
return name == pattern
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def analyze(node: TreeNode) -> LlmSummary:
|
|
171
|
+
"""
|
|
172
|
+
Inspect a TreeNode tree and return an LlmSummary with detected stack,
|
|
173
|
+
counts, flags, and top-level structure. Pure rule-based, zero deps,
|
|
174
|
+
no filesystem access beyond the already-built tree.
|
|
175
|
+
"""
|
|
176
|
+
summary = LlmSummary()
|
|
177
|
+
|
|
178
|
+
all_files, all_dirs = _collect_all_names(node)
|
|
179
|
+
all_names = all_files | all_dirs
|
|
180
|
+
|
|
181
|
+
# Stack detection — first match wins per signal, deduplicate labels
|
|
182
|
+
seen_labels: set[str] = set()
|
|
183
|
+
for pattern, label in _STACK_SIGNALS:
|
|
184
|
+
for name in all_names:
|
|
185
|
+
if _matches_pattern(name, pattern) and label not in seen_labels:
|
|
186
|
+
summary.detected_stack.append(label)
|
|
187
|
+
seen_labels.add(label)
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
# Notable flags
|
|
191
|
+
seen_flags: set[str] = set()
|
|
192
|
+
for pattern, message in _NOTABLE_FLAGS:
|
|
193
|
+
for name in all_names:
|
|
194
|
+
if _matches_pattern(name, pattern) and message not in seen_flags:
|
|
195
|
+
summary.notable_flags.append(message)
|
|
196
|
+
seen_flags.add(message)
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
# Counts
|
|
200
|
+
summary.file_count, summary.dir_count = _count_entries(node)
|
|
201
|
+
|
|
202
|
+
# Top-level structure
|
|
203
|
+
for child in node.children:
|
|
204
|
+
if child.is_symlink:
|
|
205
|
+
continue
|
|
206
|
+
if child.is_dir:
|
|
207
|
+
summary.top_level_dirs.append(child.name + "/")
|
|
208
|
+
else:
|
|
209
|
+
summary.top_level_files.append(child.name)
|
|
210
|
+
|
|
211
|
+
return summary
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.renderers — output formatters for a tree built by tree2guide.scanner.
|
|
3
|
+
|
|
4
|
+
Renderers: markdown (default), text, json, yaml, html, llm.
|
|
5
|
+
Each takes a TreeNode and returns a string. Only the llm renderer also
|
|
6
|
+
calls tree2guide.llm.analyze() to attach heuristic metadata.
|
|
7
|
+
"""
|