stackthread 0.1.0.dev2__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.

Potentially problematic release.


This version of stackthread might be problematic. Click here for more details.

Files changed (40) hide show
  1. stackthread/__init__.py +6 -0
  2. stackthread/__main__.py +3 -0
  3. stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
  4. stackthread/_vendor/extract.mjs +15 -0
  5. stackthread/adapters.py +130 -0
  6. stackthread/cli.py +342 -0
  7. stackthread/config.py +409 -0
  8. stackthread/detect.py +150 -0
  9. stackthread/lang/__init__.py +1 -0
  10. stackthread/lang/js/__init__.py +1 -0
  11. stackthread/lang/js/aliases.py +133 -0
  12. stackthread/lang/js/build.py +455 -0
  13. stackthread/lang/js/worker.py +186 -0
  14. stackthread/lang/python/__init__.py +1 -0
  15. stackthread/lang/python/build.py +249 -0
  16. stackthread/lang/python/extract.py +494 -0
  17. stackthread/lang/python/frameworks.py +700 -0
  18. stackthread/lang/python/orm.py +300 -0
  19. stackthread/lang/python/resolve.py +448 -0
  20. stackthread/lineage.py +965 -0
  21. stackthread/link.py +130 -0
  22. stackthread/logs.py +314 -0
  23. stackthread/model.py +161 -0
  24. stackthread/paths.py +173 -0
  25. stackthread/project.py +414 -0
  26. stackthread/report.py +203 -0
  27. stackthread/rules.py +48 -0
  28. stackthread/server/__init__.py +5 -0
  29. stackthread/server/app.py +696 -0
  30. stackthread/server/edit.py +108 -0
  31. stackthread/server/security.py +73 -0
  32. stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
  33. stackthread/static/app.css +1 -0
  34. stackthread/static/app.js +29 -0
  35. stackthread/static/index.html +16 -0
  36. stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
  37. stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
  38. stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
  39. stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  40. stackthread-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
stackthread/config.py ADDED
@@ -0,0 +1,409 @@
1
+ """stackthread.toml (or [tool.stackthread] in pyproject.toml): loading, defaults and strict validation (design §6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ CONFIG_FILE = "stackthread.toml"
12
+ LANGS = {
13
+ "python": "python",
14
+ "py": "python",
15
+ "typescript": "typescript",
16
+ "ts": "typescript",
17
+ "javascript": "typescript",
18
+ "js": "typescript",
19
+ }
20
+ ROLES = ("backend", "frontend")
21
+ EDITORS = ("vscode", "cursor", "windsurf", "zed", "idea", "pycharm", "webstorm", "none")
22
+ PY_FRAMEWORKS = ("auto", "fastapi", "django", "ninja", "drf", "django-orm", "sqlalchemy", "mongo")
23
+ JS_FRAMEWORKS = ("auto", "react-router", "tanstack-router")
24
+ DEFAULT_DENY = [
25
+ ".env",
26
+ ".env.*",
27
+ "**/.env",
28
+ "**/.env.*",
29
+ "**/*.pem",
30
+ "**/*.key",
31
+ "**/*.p12",
32
+ "**/*.lock",
33
+ "**/package-lock.json",
34
+ "**/pnpm-lock.yaml",
35
+ "**/yarn.lock",
36
+ "**/uv.lock",
37
+ "**/poetry.lock",
38
+ ".git/**",
39
+ "**/secrets.*",
40
+ "**/*secret*.json",
41
+ ]
42
+
43
+
44
+ class ConfigError(Exception):
45
+ def __init__(self, problems: list[str], path: Path | None = None) -> None:
46
+ self.problems = problems
47
+ self.path = path
48
+ where = f" in {path}" if path else ""
49
+ super().__init__(f"{len(problems)} problem(s){where}:\n" + "\n".join(f" - {p}" for p in problems))
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class SourceConfig:
54
+ name: str
55
+ lang: str
56
+ role: str
57
+ root: str = "."
58
+ include: list[str] = field(default_factory=list)
59
+ exclude: list[str] = field(default_factory=list)
60
+ frameworks: list[str] = field(default_factory=lambda: ["auto"])
61
+ alias: dict[str, str] = field(default_factory=dict)
62
+
63
+ @property
64
+ def is_python(self) -> bool:
65
+ return self.lang == "python"
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class HttpLink:
70
+ frm: str
71
+ to: str
72
+ client_prefix: str = ""
73
+ server_prefix: str = ""
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class HttpConfig:
78
+ links: list[HttpLink] = field(default_factory=list)
79
+ clients: list[str] = field(default_factory=list)
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class View:
84
+ group: str
85
+ name: str
86
+ path: str
87
+ note: str = ""
88
+ source: str = "config"
89
+
90
+ def to_json(self) -> dict[str, str]:
91
+ return {"group": self.group, "name": self.name, "path": self.path, "note": self.note, "source": self.source}
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class ConceptFind:
96
+ file: str
97
+ pattern: str
98
+
99
+
100
+ @dataclass(slots=True)
101
+ class Concept:
102
+ attribute: str
103
+ label: str = "{value}"
104
+ find: list[ConceptFind] = field(default_factory=list)
105
+ mentions: bool = True
106
+
107
+
108
+ @dataclass(slots=True)
109
+ class ServerConfig:
110
+ port: int = 7341
111
+ editor: str = "vscode"
112
+ edit: bool = True
113
+ edit_deny: list[str] = field(default_factory=lambda: list(DEFAULT_DENY))
114
+ format: dict[str, list[str]] = field(default_factory=dict)
115
+ open_browser: bool = True
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class LayerRule:
120
+ name: str
121
+ order: list[str]
122
+ source: str = ""
123
+
124
+
125
+ @dataclass(slots=True)
126
+ class LogConfig:
127
+ level: str = "debug" # what the log file records
128
+ size_kb: int = 1024 # one file's size before it is rotated
129
+ keep: int = 5 # rotated files kept
130
+
131
+
132
+ @dataclass(slots=True)
133
+ class Config:
134
+ root: Path
135
+ name: str = ""
136
+ sources: list[SourceConfig] = field(default_factory=list)
137
+ http: HttpConfig = field(default_factory=HttpConfig)
138
+ page_params: dict[str, str] = field(default_factory=dict)
139
+ views: list[View] = field(default_factory=list)
140
+ concepts: list[Concept] = field(default_factory=list)
141
+ server: ServerConfig = field(default_factory=ServerConfig)
142
+ layer_rules: list[LayerRule] = field(default_factory=list)
143
+ adapters: list[str] = field(default_factory=list)
144
+ log: LogConfig = field(default_factory=LogConfig)
145
+ path: Path | None = None
146
+ detected: bool = False
147
+
148
+ def source(self, name: str) -> SourceConfig | None:
149
+ return next((s for s in self.sources if s.name == name), None)
150
+
151
+ def source_root(self, src: SourceConfig) -> Path:
152
+ return (self.root / src.root).resolve()
153
+
154
+ @property
155
+ def state_dir(self) -> Path:
156
+ return self.root / ".stackthread"
157
+
158
+
159
+ # ---------------------------------------------------------------- loading
160
+
161
+
162
+ def find_config(start: Path) -> Path | None:
163
+ """The nearest stackthread.toml, or pyproject.toml with [tool.stackthread], walking up from `start`."""
164
+ for d in [start, *start.parents]:
165
+ if (d / CONFIG_FILE).is_file():
166
+ return d / CONFIG_FILE
167
+ pyproject = d / "pyproject.toml"
168
+ if pyproject.is_file():
169
+ try:
170
+ if "stackthread" in tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}):
171
+ return pyproject
172
+ except tomllib.TOMLDecodeError:
173
+ continue
174
+ return None
175
+
176
+
177
+ def load(path: Path) -> Config:
178
+ try:
179
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
180
+ except tomllib.TOMLDecodeError as e:
181
+ raise ConfigError([f"not valid TOML: {e}"], path) from None
182
+ if path.name == "pyproject.toml":
183
+ data = data.get("tool", {}).get("stackthread", {})
184
+ return parse(data, path.parent, path)
185
+
186
+
187
+ def parse(data: dict[str, Any], base: Path, path: Path | None = None) -> Config:
188
+ v = _Validator()
189
+ v.keys("", data, {"project", "sources", "http", "pages", "views", "concepts", "server", "rules", "adapters", "log"})
190
+ project = v.table("project", data.get("project", {}))
191
+ v.keys("project", project, {"name", "root"})
192
+ root = (base / v.string("project.root", project.get("root", "."))).resolve()
193
+ cfg = Config(root=root, name=v.string("project.name", project.get("name", "")) or root.name, path=path)
194
+
195
+ seen: set[str] = set()
196
+ for i, raw in enumerate(v.array("sources", data.get("sources", []))):
197
+ where = f"sources[{i}]"
198
+ s = v.table(where, raw)
199
+ v.keys(where, s, {"name", "lang", "role", "root", "include", "exclude", "frameworks", "alias"})
200
+ lang = LANGS.get(str(s.get("lang", "")).lower())
201
+ if lang is None:
202
+ v.problem(f"{where}.lang must be one of python, typescript (or javascript); got {s.get('lang')!r}")
203
+ continue
204
+ role = v.choice(f"{where}.role", s.get("role", "backend" if lang == "python" else "frontend"), ROLES)
205
+ name = v.string(f"{where}.name", s.get("name", "")) or f"{role}{i}"
206
+ if name in seen:
207
+ v.problem(f"{where}.name {name!r} is used twice")
208
+ seen.add(name)
209
+ known = PY_FRAMEWORKS if lang == "python" else JS_FRAMEWORKS
210
+ frameworks = v.strings(f"{where}.frameworks", s.get("frameworks", ["auto"]))
211
+ for fw in frameworks:
212
+ if fw not in known:
213
+ v.problem(f"{where}.frameworks: unknown {fw!r}; known: {', '.join(known)}")
214
+ alias = v.table(f"{where}.alias", s.get("alias", {}))
215
+ cfg.sources.append(
216
+ SourceConfig(
217
+ name=name,
218
+ lang=lang,
219
+ role=role,
220
+ root=v.string(f"{where}.root", s.get("root", ".")),
221
+ include=v.strings(f"{where}.include", s.get("include", [])),
222
+ exclude=v.strings(f"{where}.exclude", s.get("exclude", [])),
223
+ frameworks=frameworks,
224
+ alias={str(k): str(val) for k, val in alias.items()},
225
+ )
226
+ )
227
+ if not (cfg.root / cfg.sources[-1].root).is_dir():
228
+ v.problem(f"{where}.root {cfg.sources[-1].root!r} is not a folder under {cfg.root}")
229
+
230
+ http = v.table("http", data.get("http", {}))
231
+ v.keys("http", http, {"links", "clients"})
232
+ for i, raw in enumerate(v.array("http.links", http.get("links", []))):
233
+ link = v.table(f"http.links[{i}]", raw)
234
+ v.keys(f"http.links[{i}]", link, {"from", "to", "client_prefix", "server_prefix"})
235
+ cfg.http.links.append(
236
+ HttpLink(
237
+ frm=v.string(f"http.links[{i}].from", link.get("from", "")),
238
+ to=v.string(f"http.links[{i}].to", link.get("to", "")),
239
+ client_prefix=v.string(f"http.links[{i}].client_prefix", link.get("client_prefix", "")),
240
+ server_prefix=v.string(f"http.links[{i}].server_prefix", link.get("server_prefix", "")),
241
+ )
242
+ )
243
+ cfg.http.clients = v.strings("http.clients", http.get("clients", []))
244
+
245
+ pages = v.table("pages", data.get("pages", {}))
246
+ v.keys("pages", pages, {"params"})
247
+ cfg.page_params = {str(k): str(val) for k, val in v.table("pages.params", pages.get("params", {})).items()}
248
+
249
+ for i, raw in enumerate(v.array("views", data.get("views", []))):
250
+ view = v.table(f"views[{i}]", raw)
251
+ v.keys(f"views[{i}]", view, {"group", "name", "path", "note"})
252
+ path_value = v.string(f"views[{i}].path", view.get("path", ""))
253
+ if not path_value.startswith("/"):
254
+ v.problem(f"views[{i}].path must start with '/'; got {path_value!r}")
255
+ cfg.views.append(
256
+ View(
257
+ group=v.string(f"views[{i}].group", view.get("group", "Views")),
258
+ name=v.string(f"views[{i}].name", view.get("name", path_value)),
259
+ path=path_value,
260
+ note=v.string(f"views[{i}].note", view.get("note", "")),
261
+ )
262
+ )
263
+
264
+ for i, raw in enumerate(v.array("concepts", data.get("concepts", []))):
265
+ c = v.table(f"concepts[{i}]", raw)
266
+ v.keys(f"concepts[{i}]", c, {"attribute", "label", "find", "mentions"})
267
+ finds = []
268
+ for j, f in enumerate(v.array(f"concepts[{i}].find", c.get("find", []))):
269
+ ft = v.table(f"concepts[{i}].find[{j}]", f)
270
+ v.keys(f"concepts[{i}].find[{j}]", ft, {"file", "pattern"})
271
+ pattern = v.string(f"concepts[{i}].find[{j}].pattern", ft.get("pattern", ""))
272
+ if "{value}" not in pattern:
273
+ v.problem(f"concepts[{i}].find[{j}].pattern must contain {{value}}")
274
+ finds.append(
275
+ ConceptFind(file=v.string(f"concepts[{i}].find[{j}].file", ft.get("file", "")), pattern=pattern)
276
+ )
277
+ attribute = v.string(f"concepts[{i}].attribute", c.get("attribute", ""))
278
+ if not attribute.startswith("data-"):
279
+ v.problem(f"concepts[{i}].attribute must be a data-* attribute; got {attribute!r}")
280
+ cfg.concepts.append(
281
+ Concept(
282
+ attribute=attribute,
283
+ label=v.string(f"concepts[{i}].label", c.get("label", "{value}")),
284
+ find=finds,
285
+ mentions=v.boolean(f"concepts[{i}].mentions", c.get("mentions", True)),
286
+ )
287
+ )
288
+
289
+ server = v.table("server", data.get("server", {}))
290
+ v.keys("server", server, {"port", "editor", "edit", "edit_deny", "format", "open_browser"})
291
+ port = server.get("port", 7341)
292
+ if not isinstance(port, int) or not 1024 <= port <= 65535:
293
+ v.problem(f"server.port must be an integer from 1024 to 65535; got {port!r}")
294
+ port = 7341
295
+ fmt = v.table("server.format", server.get("format", {}))
296
+ cfg.server = ServerConfig(
297
+ port=port,
298
+ editor=v.choice("server.editor", server.get("editor", "vscode"), EDITORS),
299
+ edit=v.boolean("server.edit", server.get("edit", True)),
300
+ edit_deny=DEFAULT_DENY + v.strings("server.edit_deny", server.get("edit_deny", [])),
301
+ format={str(k): v.strings(f"server.format.{k}", val) for k, val in fmt.items()},
302
+ open_browser=v.boolean("server.open_browser", server.get("open_browser", True)),
303
+ )
304
+ for lang, command in cfg.server.format.items():
305
+ if lang not in ("python", "typescript"):
306
+ v.problem(f"server.format.{lang}: use python or typescript")
307
+ if "{file}" not in command:
308
+ v.problem(f"server.format.{lang} must contain the argument {{file}}")
309
+
310
+ rules = v.table("rules", data.get("rules", {}))
311
+ v.keys("rules", rules, {"layers"})
312
+ for i, raw in enumerate(v.array("rules.layers", rules.get("layers", []))):
313
+ r = v.table(f"rules.layers[{i}]", raw)
314
+ v.keys(f"rules.layers[{i}]", r, {"name", "order", "source"})
315
+ order = v.strings(f"rules.layers[{i}].order", r.get("order", []))
316
+ if len(order) < 2:
317
+ v.problem(f"rules.layers[{i}].order needs at least two entries")
318
+ cfg.layer_rules.append(
319
+ LayerRule(
320
+ name=v.string(f"rules.layers[{i}].name", r.get("name", f"layers {i + 1}")),
321
+ order=order,
322
+ source=v.string(f"rules.layers[{i}].source", r.get("source", "")),
323
+ )
324
+ )
325
+
326
+ adapters = v.table("adapters", data.get("adapters", {}))
327
+ v.keys("adapters", adapters, {"use"})
328
+ cfg.adapters = v.strings("adapters.use", adapters.get("use", []))
329
+
330
+ log = v.table("log", data.get("log", {}))
331
+ v.keys("log", log, {"level", "size_kb", "keep"})
332
+ size_kb, keep = log.get("size_kb", 1024), log.get("keep", 5)
333
+ if not isinstance(size_kb, int) or not 64 <= size_kb <= 102400:
334
+ v.problem(f"log.size_kb must be an integer from 64 to 102400; got {size_kb!r}")
335
+ size_kb = 1024
336
+ if not isinstance(keep, int) or not 1 <= keep <= 50:
337
+ v.problem(f"log.keep must be an integer from 1 to 50; got {keep!r}")
338
+ keep = 5
339
+ cfg.log = LogConfig(
340
+ level=v.choice("log.level", log.get("level", "debug"), ("debug", "info", "warning", "error")),
341
+ size_kb=size_kb,
342
+ keep=keep,
343
+ )
344
+
345
+ names = {s.name for s in cfg.sources}
346
+ for i, lk in enumerate(cfg.http.links):
347
+ for side, value in (("from", lk.frm), ("to", lk.to)):
348
+ if value not in names:
349
+ v.problem(f"http.links[{i}].{side} names an unknown source {value!r}")
350
+ for i, rule in enumerate(cfg.layer_rules):
351
+ if rule.source and rule.source not in names:
352
+ v.problem(f"rules.layers[{i}].source names an unknown source {rule.source!r}")
353
+
354
+ if v.problems:
355
+ raise ConfigError(v.problems, path)
356
+ return cfg
357
+
358
+
359
+ class _Validator:
360
+ def __init__(self) -> None:
361
+ self.problems: list[str] = []
362
+
363
+ def problem(self, message: str) -> None:
364
+ self.problems.append(message)
365
+
366
+ def keys(self, where: str, table: Any, allowed: set[str]) -> None:
367
+ if not isinstance(table, dict):
368
+ return
369
+ for key in table:
370
+ if key not in allowed:
371
+ hint = difflib.get_close_matches(key, sorted(allowed), n=1)
372
+ suggestion = f"; did you mean {hint[0]!r}?" if hint else f"; allowed: {', '.join(sorted(allowed))}"
373
+ self.problem(f"unknown key {(where + '.' if where else '') + key!r}{suggestion}")
374
+
375
+ def table(self, where: str, value: Any) -> dict[str, Any]:
376
+ if isinstance(value, dict):
377
+ return value
378
+ self.problem(f"{where} must be a table")
379
+ return {}
380
+
381
+ def array(self, where: str, value: Any) -> list[Any]:
382
+ if isinstance(value, list):
383
+ return value
384
+ self.problem(f"{where} must be an array of tables ([[{where}]])")
385
+ return []
386
+
387
+ def string(self, where: str, value: Any) -> str:
388
+ if isinstance(value, str):
389
+ return value
390
+ self.problem(f"{where} must be a string; got {value!r}")
391
+ return ""
392
+
393
+ def strings(self, where: str, value: Any) -> list[str]:
394
+ if isinstance(value, list) and all(isinstance(x, str) for x in value):
395
+ return list(value)
396
+ self.problem(f"{where} must be a list of strings; got {value!r}")
397
+ return []
398
+
399
+ def boolean(self, where: str, value: Any) -> bool:
400
+ if isinstance(value, bool):
401
+ return value
402
+ self.problem(f"{where} must be true or false; got {value!r}")
403
+ return False
404
+
405
+ def choice(self, where: str, value: Any, options: tuple[str, ...]) -> str:
406
+ if value in options:
407
+ return str(value)
408
+ self.problem(f"{where} must be one of {', '.join(options)}; got {value!r}")
409
+ return options[0]
stackthread/detect.py ADDED
@@ -0,0 +1,150 @@
1
+ """Find the sources of a project without a config: React apps on Vite, Django and FastAPI backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from stackthread.config import Config, SourceConfig
12
+ from stackthread.paths import ALWAYS_SKIP
13
+
14
+ MAX_DEPTH = 4
15
+ PY_MARKERS = re.compile(r"\b(fastapi|django|djangorestframework|django-ninja|ninja|sqlalchemy|sqlmodel)\b", re.I)
16
+
17
+
18
+ @dataclass
19
+ class Found:
20
+ sources: list[SourceConfig] = field(default_factory=list)
21
+ notes: list[str] = field(default_factory=list)
22
+
23
+
24
+ def _dirs(root: Path) -> list[Path]:
25
+ out = [root]
26
+ for dirpath, dirnames, _ in os.walk(root):
27
+ depth = len(Path(dirpath).relative_to(root).parts)
28
+ dirnames[:] = sorted(d for d in dirnames if d not in ALWAYS_SKIP and not d.startswith("."))
29
+ if depth >= MAX_DEPTH:
30
+ dirnames[:] = []
31
+ out += [Path(dirpath) / d for d in dirnames]
32
+ return out
33
+
34
+
35
+ def _react_vite(folder: Path) -> bool:
36
+ pkg = folder / "package.json"
37
+ if not pkg.is_file():
38
+ return False
39
+ try:
40
+ data = json.loads(pkg.read_text(encoding="utf-8"))
41
+ except (OSError, ValueError):
42
+ return False
43
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
44
+ has_vite = "vite" in deps or any((folder / f"vite.config.{e}").is_file() for e in ("ts", "js", "mts", "mjs"))
45
+ return "react" in deps and has_vite
46
+
47
+
48
+ def _python_backend(folder: Path) -> tuple[bool, list[str]]:
49
+ frameworks: list[str] = []
50
+ if (folder / "manage.py").is_file():
51
+ frameworks.append("django")
52
+ for name in ("pyproject.toml", "requirements.txt", "requirements.in", "setup.cfg", "Pipfile"):
53
+ file = folder / name
54
+ if file.is_file():
55
+ text = file.read_text(encoding="utf-8", errors="replace")
56
+ for m in PY_MARKERS.findall(text):
57
+ fw = m.lower()
58
+ if fw == "djangorestframework":
59
+ fw = "drf"
60
+ if fw in ("django-ninja", "ninja"):
61
+ fw = "ninja"
62
+ if fw not in frameworks:
63
+ frameworks.append(fw)
64
+ return bool(frameworks), frameworks
65
+
66
+
67
+ def _python_root(folder: Path) -> Path:
68
+ """Where module names start: `src/` in a src layout, else the folder itself."""
69
+ src = folder / "src"
70
+ if src.is_dir() and any((p / "__init__.py").is_file() for p in src.iterdir() if p.is_dir()):
71
+ return src
72
+ return folder
73
+
74
+
75
+ def detect(root: Path) -> Found:
76
+ found = Found()
77
+ names: set[str] = set()
78
+ claimed: list[Path] = []
79
+
80
+ def name_for(folder: Path, fallback: str) -> str:
81
+ base = re.sub(r"[^\w-]", "-", folder.name if folder != root else fallback).strip("-") or fallback
82
+ candidate, i = base, 2
83
+ while candidate in names:
84
+ candidate, i = f"{base}{i}", i + 1
85
+ names.add(candidate)
86
+ return candidate
87
+
88
+ for folder in _dirs(root):
89
+ if any(folder.is_relative_to(c) for c in claimed):
90
+ continue
91
+ if _react_vite(folder):
92
+ rel = folder.relative_to(root).as_posix() if folder != root else "."
93
+ include = ["src/**/*"] if (folder / "src").is_dir() else []
94
+ found.sources.append(
95
+ SourceConfig(
96
+ name=name_for(folder, "web"), lang="typescript", role="frontend", root=rel, include=include
97
+ )
98
+ )
99
+ claimed.append(folder)
100
+ found.notes.append(f"React on Vite in {rel}")
101
+ continue
102
+ ok, frameworks = _python_backend(folder)
103
+ if ok:
104
+ py_root = _python_root(folder)
105
+ rel = py_root.relative_to(root).as_posix() if py_root != root else "."
106
+ exclude = ["**/migrations/**"] if "django" in frameworks or "drf" in frameworks else []
107
+ found.sources.append(
108
+ SourceConfig(name=name_for(folder, "api"), lang="python", role="backend", root=rel, exclude=exclude)
109
+ )
110
+ claimed.append(folder)
111
+ found.notes.append(f"Python backend ({', '.join(frameworks)}) in {rel}")
112
+ return found
113
+
114
+
115
+ def config_from(root: Path) -> Config:
116
+ found = detect(root)
117
+ cfg = Config(root=root.resolve(), name=root.resolve().name, sources=found.sources, detected=True)
118
+ return cfg
119
+
120
+
121
+ def render_toml(root: Path, found: Found) -> str:
122
+ lines = [
123
+ "# stackthread configuration. Every key is optional; see docs/configuration.md.",
124
+ "",
125
+ "[project]",
126
+ f'name = "{root.resolve().name}"',
127
+ "",
128
+ ]
129
+ for s in found.sources:
130
+ lines += ["[[sources]]", f'name = "{s.name}"', f'lang = "{s.lang}"', f'role = "{s.role}"', f'root = "{s.root}"']
131
+ if s.include:
132
+ lines.append("include = [" + ", ".join(f'"{x}"' for x in s.include) + "]")
133
+ if s.exclude:
134
+ lines.append("exclude = [" + ", ".join(f'"{x}"' for x in s.exclude) + "]")
135
+ lines.append("")
136
+ lines += [
137
+ "# [http]",
138
+ '# links = [{ from = "web", to = "api", client_prefix = "/api", server_prefix = "/api" }]',
139
+ "",
140
+ "# Named states of pages, such as workflow stages, wizard steps or tabs:",
141
+ "# [[views]]",
142
+ '# group = "Checkout"',
143
+ '# name = "Payment step"',
144
+ '# path = "/checkout?step=payment"',
145
+ "",
146
+ "[server]",
147
+ 'editor = "vscode" # vscode | cursor | windsurf | zed | idea | pycharm | webstorm | none',
148
+ "",
149
+ ]
150
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+ """Language support: one subpackage per language, each with an extractor (facts per file) and a builder (graph)."""
@@ -0,0 +1 @@
1
+ """JavaScript and TypeScript: facts from the bundled Babel-based extractor (run with node), resolution, graph."""