faf-python-sdk 1.2.0__py3-none-any.whl → 1.3.1__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: faf-python-sdk
3
- Version: 1.2.0
3
+ Version: 1.3.1
4
4
  Summary: Persistent project context for Python — parse, validate, and score `.faf` files. The foundation other Python FAF tools (gemini-faf-mcp, custom MCP servers, CI validators) build on. IANA-registered application/vnd.faf+yaml.
5
5
  Project-URL: Homepage, https://faf.one
6
6
  Project-URL: Documentation, https://github.com/Wolfe-Jam/faf-python-sdk
@@ -41,17 +41,32 @@ The foundation other Python FAF tools build on. If you're building MCP servers,
41
41
  [![FAF](https://mcpaas.live/badge/Wolfe-Jam/faf-python-sdk.svg)](https://builder.faf.one)
42
42
  [![PyPI](https://img.shields.io/pypi/v/faf-python-sdk?style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/faf-python-sdk/)
43
43
  [![Downloads](https://img.shields.io/pypi/dm/faf-python-sdk?style=for-the-badge&color=blue)](https://pypi.org/project/faf-python-sdk/)
44
- [![Tests](https://img.shields.io/badge/tests-196%20passing-brightgreen?style=for-the-badge)](https://github.com/Wolfe-Jam/faf-python-sdk)
44
+ [![Tests](https://img.shields.io/badge/tests-213%20passing-brightgreen?style=for-the-badge)](https://github.com/Wolfe-Jam/faf-python-sdk)
45
45
  [![IANA](https://img.shields.io/badge/IANA-registered-informational?style=for-the-badge)](https://www.iana.org/assignments/media-types/application/vnd.faf+yaml)
46
46
 
47
47
  **Media Type:** `application/vnd.faf+yaml` (IANA registered)
48
48
 
49
+ ## What's New in v1.3.1 — The Interop Edition
50
+
51
+ The SDK can now author AI-context files, not just parse and score them.
52
+
53
+ > **v1.3.1** is a copy patch — "generate" removed from external text (module docstring, the blockquote written into every AGENTS.md, README, CHANGELOG). No API change; the `faf_sdk.interop` functions below are unchanged.
54
+
55
+ `faf_sdk.interop` — `generate_agents_md(faf)` and `generate_gemini_md(faf)`, Python ports of faf-cli's `src/interop/agents.ts` + `gemini.ts`, in parity with the canonical TypeScript. Deterministic BETTER-shaped projection: setup (install→build→dev ordered) · tests · layout · conventions · three-tier guardrails · definition of done · security · commit · stack. Human Context (who/why marketing) is intentionally omitted from AGENTS.md — it belongs in the README / .faf DNA, not agent ops.
56
+
57
+ ```python
58
+ from faf_sdk import parse_file, generate_agents_md
59
+
60
+ faf = parse_file("project.faf")
61
+ print(generate_agents_md(faf.data.raw)) # takes the raw dict — carries top-level commands / key_files / security
62
+ ```
63
+
64
+ Any Python FAF tool that authors an AI-context file wraps this now — never hand-roll one. `gemini-faf-mcp` 2.7.0's `faf_agents` / `faf_gemini` are the reference wrappers.
65
+
49
66
  ## What's New in v1.2.0 — The Dart Edition
50
67
 
51
68
  Adds `detect_dart_project()`: content-aware Dart/Flutter detection from a `pubspec.yaml` (Flutter app vs package · Dart MCP / backend / CLI / library), reproducing faf-cli's engine byte-for-byte — 20 shared fixtures, parity-tested.
52
69
 
53
- The SDK's first detection capability — it now reads a `pubspec.yaml` and tells you *what kind* of Dart project it is, not just parse/validate/score. The detection KNOWLEDGE is vendored byte-identical from faf-cli (the single source) and ships in the wheel:
54
-
55
70
  ```python
56
71
  from faf_sdk import detect_dart_project
57
72
 
@@ -1,12 +1,14 @@
1
- faf_sdk/__init__.py,sha256=_rO6yyapx8eswbifcLkxYVOP0FCaBavvzjapi1msdkc,1611
1
+ faf_sdk/__init__.py,sha256=_HepiDZAIejVARbFBkTIK3yXyefmemLZlP9Ao6O29NE,1842
2
2
  faf_sdk/dart_detection.json,sha256=jFtKnyyMVVFsunizteFZ2pLhYC1qC2nCIWQB9SdOqKE,1195
3
3
  faf_sdk/detect.py,sha256=GothQNKChKSEwke74MRy63VyBYLqOzN1j3yzjsSVpJA,6310
4
4
  faf_sdk/discovery.py,sha256=3Jj3lSzPAj9l0_VjJneLAwo1dMXvhneFhcVN4m2ejOw,8643
5
+ faf_sdk/interop.py,sha256=V2gdR4Y2w8R2mUtRBPq-pSlu4glzpeFuWfpQazvPOGI,13450
5
6
  faf_sdk/mk4.py,sha256=dA8o3YB0W19Mt_bqcR67HAw4S08zxj6IzEmubTy-pQY,5708
6
7
  faf_sdk/parser.py,sha256=lJJysj52X0Q_aGhl4PMXY2PuMqASFHdVRiSZFDdampk,4925
7
8
  faf_sdk/types.py,sha256=sm1ezSzCc-93bszr4ite31_xZRKjwqf2utlqN6_0QuM,6615
8
9
  faf_sdk/validator.py,sha256=6uneOwar4GYUF52BnAQTu159kE4mh4RWRgG6onVbiG4,5730
9
- faf_python_sdk-1.2.0.dist-info/METADATA,sha256=igeZU2Gia6yzOAQHAj0Xg49B6UciWl36uD6p7aR_OXk,8008
10
- faf_python_sdk-1.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
- faf_python_sdk-1.2.0.dist-info/licenses/LICENSE,sha256=ARScF5tFhbQnYO2V5QAuCwhDHcxKdOWTOV81Pxx_j7U,1065
12
- faf_python_sdk-1.2.0.dist-info/RECORD,,
10
+ faf_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ faf_python_sdk-1.3.1.dist-info/METADATA,sha256=df6Rn6Y5FFupYES2dA_98R-0K6kb6vtQJJQow510RHM,8991
12
+ faf_python_sdk-1.3.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
13
+ faf_python_sdk-1.3.1.dist-info/licenses/LICENSE,sha256=ARScF5tFhbQnYO2V5QAuCwhDHcxKdOWTOV81Pxx_j7U,1065
14
+ faf_python_sdk-1.3.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
faf_sdk/__init__.py CHANGED
@@ -25,6 +25,7 @@ from .validator import validate, ValidationResult
25
25
  from .mk4 import score_faf, Mk4Result, SlotState, LicenseTier
26
26
  from .discovery import find_faf_file, find_project_root, load_fafignore
27
27
  from .detect import detect_dart_project, DartProject
28
+ from .interop import generate_agents_md, generate_gemini_md, faf_meta_tag
28
29
  from .types import (
29
30
  FafData,
30
31
  ProjectInfo,
@@ -35,7 +36,7 @@ from .types import (
35
36
  AIScoring
36
37
  )
37
38
 
38
- __version__ = "1.2.0"
39
+ __version__ = "1.3.1"
39
40
  __all__ = [
40
41
  # Parser
41
42
  "parse",
@@ -57,6 +58,10 @@ __all__ = [
57
58
  # Detection (Dart/Flutter — A+B hybrid, parity with faf-cli)
58
59
  "detect_dart_project",
59
60
  "DartProject",
61
+ # Interop — AGENTS.md / GEMINI.md generators (parity with faf-cli src/interop)
62
+ "generate_agents_md",
63
+ "generate_gemini_md",
64
+ "faf_meta_tag",
60
65
  # Types
61
66
  "FafData",
62
67
  "ProjectInfo",
faf_sdk/interop.py ADDED
@@ -0,0 +1,403 @@
1
+ """
2
+ AI-context file authoring — AGENTS.md and GEMINI.md from .faf data.
3
+
4
+ Python port of faf-cli's `src/interop/agents.ts` + `gemini.ts`, kept in
5
+ parity with the canonical TypeScript. Deterministic projection from curated
6
+ truth — facts, not freewritten prose. Human Context (who/why marketing) is
7
+ intentionally omitted from AGENTS.md; it belongs in the README / .faf DNA,
8
+ not agent ops.
9
+
10
+ Both take the raw parsed .faf dict (`FafFile.data.raw`) — the .faf format
11
+ carries top-level `commands` / `key_files` / `security` that the typed model
12
+ doesn't surface.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ __all__ = ["generate_agents_md", "generate_gemini_md", "faf_meta_tag", "title_label", "slot_label"]
20
+
21
+ # --- shared helpers ----------------------------------------------------------
22
+
23
+ _ACRONYMS = {
24
+ "API", "CI", "CD", "MCP", "CLI", "SDK", "UI", "UX", "AI", "ML", "DB", "ORM",
25
+ "OS", "HTTP", "HTTPS", "REST", "RPC", "JSON", "YAML", "XML", "SQL", "CSS",
26
+ "HTML", "AWS", "GCP", "CDN", "DNS", "JWT", "ID", "IP", "URL", "URI", "TS", "JS",
27
+ }
28
+
29
+ # Canonical .faf slot labels (from faf-cli src/core/slots.ts).
30
+ _SLOT_LABELS = {
31
+ "stack.frontend": "Framework", "stack.css_framework": "CSS",
32
+ "stack.ui_library": "UI Library", "stack.state_management": "State",
33
+ "stack.backend": "Backend", "stack.api_type": "API", "stack.runtime": "Runtime",
34
+ "stack.database": "Database", "stack.connection": "Connection",
35
+ "stack.hosting": "Hosting", "stack.build": "Build", "stack.cicd": "CI/CD",
36
+ "stack.monorepo_tool": "Monorepo", "stack.package_manager": "Package Manager",
37
+ "stack.workspaces": "Workspaces", "stack.admin": "Admin", "stack.cache": "Cache",
38
+ "stack.search": "Search", "stack.storage": "Storage",
39
+ }
40
+
41
+ # Preference keys that describe human<->assistant interaction, not repo conventions.
42
+ _HUMAN_PREF = {
43
+ "commit_style", "communication", "response_style", "explanation_level",
44
+ "explanations", "documentation", "code_first",
45
+ }
46
+
47
+ # Stack keys that are context/marketing, not actual stack.
48
+ _NON_STACK = {"target_user", "core_problem", "mission_purpose"}
49
+
50
+
51
+ def _present(v: Any) -> bool:
52
+ """Non-empty, not 'slotignored', non-empty list."""
53
+ if v is None or v == "" or v == "slotignored":
54
+ return False
55
+ if isinstance(v, (list, tuple)) and len(v) == 0:
56
+ return False
57
+ return True
58
+
59
+
60
+ def _filled(v: Any) -> bool:
61
+ """A string slot value carrying real content."""
62
+ return isinstance(v, str) and v.strip() not in ("", "slotignored")
63
+
64
+
65
+ def _fmt_val(v: Any) -> str:
66
+ if isinstance(v, (list, tuple)):
67
+ return ", ".join(str(x) for x in v)
68
+ return str(v)
69
+
70
+
71
+ def title_label(key: str) -> str:
72
+ """snake_case -> Title Case, acronym-aware. `api_type` -> 'API Type'."""
73
+ out: list[str] = []
74
+ for w in key.split("_"):
75
+ if not w:
76
+ out.append(w)
77
+ elif w.upper() in _ACRONYMS:
78
+ out.append(w.upper())
79
+ else:
80
+ out.append(w[0].upper() + w[1:])
81
+ return " ".join(out)
82
+
83
+
84
+ def slot_label(path: str) -> str:
85
+ """Canonical display label for a .faf slot path; falls back to title_label."""
86
+ if path in _SLOT_LABELS:
87
+ return _SLOT_LABELS[path]
88
+ key = path.rsplit(".", 1)[-1] if "." in path else path
89
+ return title_label(key)
90
+
91
+
92
+ def faf_meta_tag(faf: dict) -> str:
93
+ """The two-line `<!-- faf: ... -->` metastamp every faf-authored file opens with."""
94
+ proj = faf.get("project") or {}
95
+ name = str(proj.get("name") or "").strip()
96
+ lang = str(proj.get("main_language") or "").strip()
97
+ typ = str(proj.get("type") or "").strip()
98
+ desc = str(proj.get("goal") or "").strip()
99
+ line1 = f"<!-- faf: {' | '.join([name, lang, typ, desc])} -->"
100
+ kv = ["claim=project.faf", "family=FAF"]
101
+ line2 = f"<!-- faf: {' | '.join(kv)} -->"
102
+ return f"{line1}\n{line2}"
103
+
104
+
105
+ # --- command classification (shared by both) --------------------------------
106
+
107
+ def _classify_commands(faf: dict) -> dict:
108
+ commands = faf.get("commands") or {}
109
+ entries = [(k, v) for k, v in commands.items() if _present(v)]
110
+ test_cmds = [(k, v) for k, v in entries if "test" in k.lower()]
111
+ lint_cmds = [
112
+ (k, v) for k, v in entries
113
+ if ("lint" in k.lower() or "check" in k.lower()) and "test" not in k.lower()
114
+ ]
115
+ setup_raw = [
116
+ (k, v) for k, v in entries
117
+ if not any(t in k.lower() for t in ("test", "lint", "check"))
118
+ ]
119
+
120
+ def setup_rank(k: str) -> int:
121
+ n = k.lower()
122
+ if "install" in n or "deps" in n:
123
+ return 0
124
+ if "build" in n and "rebuild" not in n:
125
+ return 1
126
+ if n == "dev" or "develop" in n:
127
+ return 2
128
+ if n == "start" or "run" in n:
129
+ return 3
130
+ return 4
131
+
132
+ setup_cmds = sorted(setup_raw, key=lambda kv: (setup_rank(kv[0]), kv[0]))
133
+ verify_cmds = test_cmds + lint_cmds
134
+ return {
135
+ "test": test_cmds, "lint": lint_cmds, "setup": setup_cmds, "verify": verify_cmds,
136
+ }
137
+
138
+
139
+ def _key_files(faf: dict) -> list:
140
+ kf = faf.get("key_files")
141
+ if not kf:
142
+ ic = faf.get("instant_context") or {}
143
+ kf = ic.get("key_files")
144
+ return kf or []
145
+
146
+
147
+ # --- AGENTS.md -------------------------------------------------------------
148
+
149
+ def generate_agents_md(faf: dict) -> str:
150
+ """Author a BETTER-shaped AGENTS.md from raw .faf data. Deterministic."""
151
+ lines: list[str] = []
152
+
153
+ def push(s: str = "") -> None:
154
+ lines.append(s)
155
+
156
+ proj = faf.get("project") or {}
157
+ ai = faf.get("ai_instructions") or {}
158
+ prefs = faf.get("preferences") or {}
159
+ security = faf.get("security") or {}
160
+ branch = str(proj["default_branch"]) if _present(proj.get("default_branch")) else "main"
161
+
162
+ cmds = _classify_commands(faf)
163
+ setup_cmds, test_cmds, lint_cmds, verify_cmds = (
164
+ cmds["setup"], cmds["test"], cmds["lint"], cmds["verify"],
165
+ )
166
+ test_cmd = test_cmds[0][1] if test_cmds else None
167
+ build_cmd = next((v for k, v in setup_cmds if "build" in k.lower()), None)
168
+ key_files = _key_files(faf)
169
+
170
+ push(faf_meta_tag(faf))
171
+ push()
172
+ push(f"# AGENTS.md — {proj.get('name') or 'Project'}")
173
+ push()
174
+
175
+ # 1 - orientation
176
+ bits: list[str] = []
177
+ if proj.get("main_language"):
178
+ bits.append(str(proj["main_language"]))
179
+ if _present(proj.get("type")):
180
+ bits.append(f"type: {proj['type']}")
181
+ if _present(proj.get("version")):
182
+ bits.append(f"v{proj['version']}")
183
+ orientation = str(proj["goal"]).strip() if proj.get("goal") else ""
184
+ if bits:
185
+ orientation += (" — " if orientation else "") + " · ".join(bits)
186
+ if orientation:
187
+ push(orientation)
188
+ push()
189
+ push(
190
+ "> Authored by faf — do not edit the managed block; refresh with "
191
+ "`faf export --agents` or the `faf_agents` MCP tool. Hand-written "
192
+ "content outside it is preserved."
193
+ )
194
+ push()
195
+
196
+ # 2 - setup & build
197
+ if setup_cmds:
198
+ push("## Setup & build")
199
+ push()
200
+ push("```bash")
201
+ for k, v in setup_cmds:
202
+ push(f"{v} # {k}")
203
+ push("```")
204
+ push()
205
+
206
+ # 3 - run the tests
207
+ if verify_cmds:
208
+ push("## Run the tests")
209
+ push()
210
+ push("```bash")
211
+ for _, v in verify_cmds:
212
+ push(v)
213
+ push("```")
214
+ push()
215
+
216
+ # 4 - where things live
217
+ if key_files:
218
+ push("## Where things live")
219
+ push()
220
+ rows = []
221
+ for f in key_files:
222
+ s = str(f)
223
+ at = s.find(" — ")
224
+ rows.append((s[:at], s[at + 3:]) if at > 0 else (s, ""))
225
+ if any(role for _, role in rows):
226
+ push("| Path | Role |")
227
+ push("|------|------|")
228
+ for path, role in rows:
229
+ push(f"| `{path}` | {role} |")
230
+ else:
231
+ for path, _ in rows:
232
+ push(f"- `{path}`")
233
+ push()
234
+
235
+ # 5 - conventions
236
+ conventions: dict[str, str] = {}
237
+
238
+ def collect(obj: Any) -> None:
239
+ if not isinstance(obj, dict):
240
+ return
241
+ for k, v in obj.items():
242
+ if k in _HUMAN_PREF or not _present(v):
243
+ continue
244
+ label = title_label(k)
245
+ conventions.setdefault(label, _fmt_val(v))
246
+
247
+ collect(ai.get("working_style"))
248
+ collect(prefs)
249
+ detected_conv = faf.get("conventions") or []
250
+ if conventions or detected_conv:
251
+ push("## Conventions")
252
+ push()
253
+ for label, val in conventions.items():
254
+ push(f"- **{label}:** {val}")
255
+ for c in detected_conv:
256
+ if _present(c):
257
+ push(f"- {c}")
258
+ push()
259
+
260
+ # 6 - guardrails
261
+ warnings = [w for w in (ai.get("warnings") or []) if _present(w)]
262
+ always = ["read the tree"]
263
+ if test_cmd:
264
+ always.append(f"run the tests (`{test_cmd}`)")
265
+ if build_cmd:
266
+ always.append("build the project")
267
+ for _, v in lint_cmds[:1]:
268
+ always.append(f"`{v}`")
269
+ push("## Guardrails")
270
+ push()
271
+ for w in warnings:
272
+ push(f"- {w}")
273
+ push(f"- **Always OK:** {' · '.join(dict.fromkeys(always))}.")
274
+ push("- **Ask first:** dependency installs, deletions, migrations, schema changes, publish/release.")
275
+ push(
276
+ f"- **Never:** force-push · push straight to `{branch}` (branch and open a PR) · commit secrets."
277
+ )
278
+ push()
279
+
280
+ # 7 - definition of done
281
+ dod: list[str] = []
282
+ for _, v in lint_cmds:
283
+ dod.append(f"`{v}` exits 0")
284
+ for _, v in test_cmds:
285
+ dod.append(f"`{v}` passes")
286
+ dod.append("changes committed with a conventional message")
287
+ push("## Definition of Done")
288
+ push()
289
+ push(f"Done when: {' · '.join(dict.fromkeys(dod))}.")
290
+ push()
291
+
292
+ # 8 - when stuck
293
+ push("## When stuck")
294
+ push()
295
+ push(
296
+ "Ask a clarifying question, propose a short plan, or open a draft PR with "
297
+ f"notes — do not push large speculative changes to `{branch}`."
298
+ )
299
+ push()
300
+
301
+ # 9 - security & secrets
302
+ if security and (_present(security.get("secrets")) or (security.get("never") or [])):
303
+ push("## Security & secrets")
304
+ push()
305
+ if _present(security.get("secrets")):
306
+ ex = f" (see `{security['example']}`)" if _present(security.get("example")) else ""
307
+ push(f"- Secrets live in `{security['secrets']}`{ex}. Never read or commit them.")
308
+ for n in security.get("never") or []:
309
+ if _present(n):
310
+ push(f"- Never read or commit `{n}`.")
311
+ push()
312
+
313
+ # 10 - commit & PR
314
+ push("## Commit & PR")
315
+ push()
316
+ if _present(prefs.get("commit_style")):
317
+ push(f"- Commit style: {_fmt_val(prefs['commit_style'])}")
318
+ else:
319
+ push("- Conventional Commits preferred (`feat:`, `fix:`, `chore:`, …).")
320
+ push(f"- Branch off `{branch}` and open a PR — never commit to `{branch}` directly.")
321
+ push("- If build/test scripts or layout change, refresh this file in the **same PR**.")
322
+ push()
323
+
324
+ # stack (reference)
325
+ stack = faf.get("stack") or {}
326
+ if isinstance(stack, dict):
327
+ rendered = [
328
+ f"- **{slot_label(f'stack.{k}')}:** {v.strip()}"
329
+ for k, v in stack.items()
330
+ if k not in _NON_STACK and _filled(v)
331
+ ]
332
+ if rendered:
333
+ push("## Stack")
334
+ push()
335
+ lines.extend(rendered)
336
+ push()
337
+
338
+ gen = faf.get("generated")
339
+ if _present(gen):
340
+ push(f"*Context authored: {gen}*")
341
+
342
+ return "\n".join(lines)
343
+
344
+
345
+ # --- GEMINI.md ------------------------------------------------------------
346
+
347
+ def generate_gemini_md(faf: dict) -> str:
348
+ """GEMINI.md — Gemini CLI's own convention (hierarchical, @file-importable)."""
349
+ lines: list[str] = []
350
+ proj = faf.get("project") or {}
351
+ cmds = _classify_commands(faf)
352
+ setup_cmds, verify_cmds = cmds["setup"], cmds["verify"]
353
+ key_files = _key_files(faf)
354
+
355
+ lines.append(faf_meta_tag(faf))
356
+ lines.append("")
357
+ lines.append(f"# GEMINI.md — {proj.get('name') or 'Project'}")
358
+ lines.append("")
359
+ lines.append("> Authored from project.faf — refresh with the `faf_gemini` MCP tool or `faf export --gemini`.")
360
+ lines.append("")
361
+
362
+ if proj.get("name"):
363
+ lines.append(f"Project: {proj['name']}")
364
+ if proj.get("goal"):
365
+ lines.append(f"Goal: {proj['goal']}")
366
+ if proj.get("main_language"):
367
+ lines.append(f"Language: {proj['main_language']}")
368
+
369
+ if setup_cmds:
370
+ lines += ["", "## Setup & build", "", "```bash"]
371
+ for k, v in setup_cmds:
372
+ lines.append(f"{v} # {k}")
373
+ lines.append("```")
374
+
375
+ if verify_cmds:
376
+ lines += ["", "## Test & verify", "", "```bash"]
377
+ for _, v in verify_cmds:
378
+ lines.append(v)
379
+ lines.append("```")
380
+
381
+ if key_files:
382
+ lines += ["", "## Where things live", ""]
383
+ for f in key_files:
384
+ lines.append(f"- `{f}`")
385
+
386
+ stack = faf.get("stack") or {}
387
+ if isinstance(stack, dict):
388
+ rendered = [
389
+ f"- {slot_label(f'stack.{k}')}: {v.strip()}"
390
+ for k, v in stack.items()
391
+ if k not in _NON_STACK and _filled(v)
392
+ ]
393
+ if rendered:
394
+ lines += ["", "## Stack"]
395
+ lines.extend(rendered)
396
+
397
+ lines += [
398
+ "", "## Before changing things", "",
399
+ "- Ask first: dependency installs, deletions, migrations, schema changes, publish/release.",
400
+ "- Never: force-push · push straight to `main` · commit secrets.",
401
+ "",
402
+ ]
403
+ return "\n".join(lines)
faf_sdk/py.typed ADDED
File without changes