faf-python-sdk 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.
- faf_python_sdk-1.0.0.dist-info/METADATA +224 -0
- faf_python_sdk-1.0.0.dist-info/RECORD +9 -0
- faf_python_sdk-1.0.0.dist-info/WHEEL +4 -0
- faf_python_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- faf_sdk/__init__.py +55 -0
- faf_sdk/discovery.py +378 -0
- faf_sdk/parser.py +194 -0
- faf_sdk/types.py +211 -0
- faf_sdk/validator.py +213 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: faf-python-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for FAF (Foundational AI-context Format) - IANA-registered application/vnd.faf+yaml
|
|
5
|
+
Project-URL: Homepage, https://faf.one
|
|
6
|
+
Project-URL: Documentation, https://github.com/Wolfe-Jam/faf-python-sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/Wolfe-Jam/faf-python-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/Wolfe-Jam/faf-python-sdk/issues
|
|
9
|
+
Author-email: wolfejam <wolfejam@faf.one>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-context,claude,faf,grok,mcp,project-context,yaml
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# faf-sdk
|
|
34
|
+
|
|
35
|
+
Python SDK for **FAF (Foundational AI-context Format)** - the IANA-registered format for AI project context.
|
|
36
|
+
|
|
37
|
+
**Media Type:** `application/vnd.faf+yaml`
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install faf-sdk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from faf_sdk import parse, validate, find_faf_file
|
|
49
|
+
|
|
50
|
+
# Find and parse project.faf
|
|
51
|
+
path = find_faf_file()
|
|
52
|
+
if path:
|
|
53
|
+
with open(path) as f:
|
|
54
|
+
faf = parse(f.read())
|
|
55
|
+
|
|
56
|
+
print(f"Project: {faf.project_name}")
|
|
57
|
+
print(f"Score: {faf.score}%")
|
|
58
|
+
print(f"Stack: {faf.data.instant_context.tech_stack}")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Core Functions
|
|
62
|
+
|
|
63
|
+
### Parsing
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from faf_sdk import parse, parse_file, stringify
|
|
67
|
+
|
|
68
|
+
# Parse from string
|
|
69
|
+
faf = parse(yaml_content)
|
|
70
|
+
|
|
71
|
+
# Parse from file
|
|
72
|
+
faf = parse_file("project.faf")
|
|
73
|
+
|
|
74
|
+
# Access typed data
|
|
75
|
+
print(faf.data.project.name)
|
|
76
|
+
print(faf.data.instant_context.what_building)
|
|
77
|
+
print(faf.data.stack.frontend)
|
|
78
|
+
|
|
79
|
+
# Access raw dict
|
|
80
|
+
print(faf.raw["project"]["goal"])
|
|
81
|
+
|
|
82
|
+
# Convert back to YAML
|
|
83
|
+
yaml_str = stringify(faf)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Validation
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from faf_sdk import validate
|
|
90
|
+
|
|
91
|
+
result = validate(faf)
|
|
92
|
+
|
|
93
|
+
if result.valid:
|
|
94
|
+
print(f"Valid! Score: {result.score}%")
|
|
95
|
+
else:
|
|
96
|
+
print("Errors:", result.errors)
|
|
97
|
+
|
|
98
|
+
print("Warnings:", result.warnings)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### File Discovery
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from faf_sdk import find_faf_file, find_project_root, load_fafignore
|
|
105
|
+
|
|
106
|
+
# Find project.faf (walks up directory tree)
|
|
107
|
+
path = find_faf_file("/path/to/src")
|
|
108
|
+
|
|
109
|
+
# Find project root
|
|
110
|
+
root = find_project_root()
|
|
111
|
+
|
|
112
|
+
# Load ignore patterns
|
|
113
|
+
patterns = load_fafignore(root)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## FAF File Structure
|
|
117
|
+
|
|
118
|
+
A `.faf` file provides instant project context for AI:
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
faf_version: 2.5.0
|
|
122
|
+
ai_score: 85%
|
|
123
|
+
ai_confidence: HIGH
|
|
124
|
+
|
|
125
|
+
project:
|
|
126
|
+
name: my-project
|
|
127
|
+
goal: Build a CLI tool for data processing
|
|
128
|
+
|
|
129
|
+
instant_context:
|
|
130
|
+
what_building: CLI data processing tool
|
|
131
|
+
tech_stack: Python 3.11, Click, Pandas
|
|
132
|
+
key_files:
|
|
133
|
+
- src/cli.py
|
|
134
|
+
- src/processor.py
|
|
135
|
+
|
|
136
|
+
stack:
|
|
137
|
+
frontend: None
|
|
138
|
+
backend: Python
|
|
139
|
+
database: SQLite
|
|
140
|
+
infrastructure: Docker
|
|
141
|
+
|
|
142
|
+
human_context:
|
|
143
|
+
who: Data analysts
|
|
144
|
+
what: Process CSV files efficiently
|
|
145
|
+
why: Current tools are slow
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Type Definitions
|
|
149
|
+
|
|
150
|
+
The SDK provides typed access to all FAF sections:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from faf_sdk import (
|
|
154
|
+
FafData,
|
|
155
|
+
ProjectInfo,
|
|
156
|
+
StackInfo,
|
|
157
|
+
InstantContext,
|
|
158
|
+
ContextQuality,
|
|
159
|
+
HumanContext
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# All fields are optional except faf_version and project.name
|
|
163
|
+
faf = parse(content)
|
|
164
|
+
|
|
165
|
+
# Typed access
|
|
166
|
+
project: ProjectInfo = faf.data.project
|
|
167
|
+
stack: StackInfo = faf.data.stack
|
|
168
|
+
context: InstantContext = faf.data.instant_context
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Integration Example
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from faf_sdk import find_faf_file, parse_file, validate
|
|
175
|
+
|
|
176
|
+
def get_project_context():
|
|
177
|
+
"""Load project context for AI processing"""
|
|
178
|
+
path = find_faf_file()
|
|
179
|
+
if not path:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
faf = parse_file(path)
|
|
183
|
+
result = validate(faf)
|
|
184
|
+
|
|
185
|
+
if not result.valid:
|
|
186
|
+
raise ValueError(f"Invalid FAF: {result.errors}")
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
"name": faf.data.project.name,
|
|
190
|
+
"goal": faf.data.project.goal,
|
|
191
|
+
"stack": faf.data.instant_context.tech_stack if faf.data.instant_context else None,
|
|
192
|
+
"key_files": faf.data.instant_context.key_files if faf.data.instant_context else [],
|
|
193
|
+
"score": faf.score,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# Use in AI context
|
|
197
|
+
context = get_project_context()
|
|
198
|
+
if context:
|
|
199
|
+
print(f"Working on: {context['name']}")
|
|
200
|
+
print(f"Goal: {context['goal']}")
|
|
201
|
+
print(f"Tech: {context['stack']}")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Why FAF?
|
|
205
|
+
|
|
206
|
+
Every AI conversation starts from zero. No memory of your project. No understanding of your stack. Just vibes.
|
|
207
|
+
|
|
208
|
+
FAF solves this with a single, IANA-registered file that gives AI instant project context:
|
|
209
|
+
|
|
210
|
+
- **One file, one read, full understanding**
|
|
211
|
+
- **19ms average execution**
|
|
212
|
+
- **Zero setup friction**
|
|
213
|
+
- **MIT licensed, works everywhere**
|
|
214
|
+
|
|
215
|
+
## Links
|
|
216
|
+
|
|
217
|
+
- **Spec:** [github.com/Wolfe-Jam/faf](https://github.com/Wolfe-Jam/faf)
|
|
218
|
+
- **Site:** [faf.one](https://faf.one)
|
|
219
|
+
- **MCP Server:** [claude-faf-mcp](https://github.com/modelcontextprotocol/servers/tree/main/src/faf)
|
|
220
|
+
- **IANA Registration:** `application/vnd.faf+yaml`
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
faf_sdk/__init__.py,sha256=HIxYtzcWZ3NcO0FoxB2PGStw1R0ZAybBrMVcsdxpRII,1105
|
|
2
|
+
faf_sdk/discovery.py,sha256=3Jj3lSzPAj9l0_VjJneLAwo1dMXvhneFhcVN4m2ejOw,8643
|
|
3
|
+
faf_sdk/parser.py,sha256=mlG5XG5T-JBhgE8udXleKxUt-rb2MOebGkGmCieuNKY,4920
|
|
4
|
+
faf_sdk/types.py,sha256=B0r80EjGGo941u0b_RNyqOmqODEajf-pJ1sWtZ6ew74,6536
|
|
5
|
+
faf_sdk/validator.py,sha256=6uneOwar4GYUF52BnAQTu159kE4mh4RWRgG6onVbiG4,5730
|
|
6
|
+
faf_python_sdk-1.0.0.dist-info/METADATA,sha256=f4k9JchM1mbZHRzavoKtAa5FEpGq3NnRo_Or8uamDGw,5431
|
|
7
|
+
faf_python_sdk-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
faf_python_sdk-1.0.0.dist-info/licenses/LICENSE,sha256=ARScF5tFhbQnYO2V5QAuCwhDHcxKdOWTOV81Pxx_j7U,1065
|
|
9
|
+
faf_python_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 wolfejam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
faf_sdk/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FAF Python SDK - Foundational AI-context Format
|
|
3
|
+
|
|
4
|
+
IANA-registered: application/vnd.faf+yaml
|
|
5
|
+
https://faf.one
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from faf_sdk import parse, validate, find_faf_file, FafFile
|
|
9
|
+
|
|
10
|
+
# Parse a .faf file
|
|
11
|
+
faf = parse(content)
|
|
12
|
+
|
|
13
|
+
# Validate structure
|
|
14
|
+
errors, warnings = validate(faf)
|
|
15
|
+
|
|
16
|
+
# Find project.faf in directory tree
|
|
17
|
+
path = find_faf_file("/path/to/project")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .parser import parse, parse_file, stringify, FafFile
|
|
21
|
+
from .validator import validate, ValidationResult
|
|
22
|
+
from .discovery import find_faf_file, find_project_root, load_fafignore
|
|
23
|
+
from .types import (
|
|
24
|
+
FafData,
|
|
25
|
+
ProjectInfo,
|
|
26
|
+
StackInfo,
|
|
27
|
+
InstantContext,
|
|
28
|
+
ContextQuality,
|
|
29
|
+
HumanContext,
|
|
30
|
+
AIScoring
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__version__ = "1.0.0"
|
|
34
|
+
__all__ = [
|
|
35
|
+
# Parser
|
|
36
|
+
"parse",
|
|
37
|
+
"parse_file",
|
|
38
|
+
"stringify",
|
|
39
|
+
"FafFile",
|
|
40
|
+
# Validator
|
|
41
|
+
"validate",
|
|
42
|
+
"ValidationResult",
|
|
43
|
+
# Discovery
|
|
44
|
+
"find_faf_file",
|
|
45
|
+
"find_project_root",
|
|
46
|
+
"load_fafignore",
|
|
47
|
+
# Types
|
|
48
|
+
"FafData",
|
|
49
|
+
"ProjectInfo",
|
|
50
|
+
"StackInfo",
|
|
51
|
+
"InstantContext",
|
|
52
|
+
"ContextQuality",
|
|
53
|
+
"HumanContext",
|
|
54
|
+
"AIScoring",
|
|
55
|
+
]
|
faf_sdk/discovery.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FAF file discovery - find .faf files and project roots
|
|
3
|
+
|
|
4
|
+
Mirrors claude-faf-mcp/src/faf-core/utils/file-utils.ts and fafignore-parser.ts
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import fnmatch
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Default ignore patterns (from TypeScript implementation)
|
|
14
|
+
DEFAULT_IGNORE_PATTERNS = [
|
|
15
|
+
# Dependencies
|
|
16
|
+
"node_modules/",
|
|
17
|
+
"vendor/",
|
|
18
|
+
"bower_components/",
|
|
19
|
+
"__pycache__/",
|
|
20
|
+
"*.pyc",
|
|
21
|
+
".pytest_cache/",
|
|
22
|
+
"venv/",
|
|
23
|
+
".venv/",
|
|
24
|
+
"env/",
|
|
25
|
+
".env/",
|
|
26
|
+
|
|
27
|
+
# Build outputs
|
|
28
|
+
"dist/",
|
|
29
|
+
"build/",
|
|
30
|
+
"out/",
|
|
31
|
+
".next/",
|
|
32
|
+
".nuxt/",
|
|
33
|
+
".svelte-kit/",
|
|
34
|
+
"target/",
|
|
35
|
+
"bin/",
|
|
36
|
+
"obj/",
|
|
37
|
+
|
|
38
|
+
# Version control
|
|
39
|
+
".git/",
|
|
40
|
+
".svn/",
|
|
41
|
+
".hg/",
|
|
42
|
+
|
|
43
|
+
# IDE/Editor
|
|
44
|
+
".vscode/",
|
|
45
|
+
".idea/",
|
|
46
|
+
"*.swp",
|
|
47
|
+
"*.swo",
|
|
48
|
+
"*~",
|
|
49
|
+
|
|
50
|
+
# OS files
|
|
51
|
+
".DS_Store",
|
|
52
|
+
"Thumbs.db",
|
|
53
|
+
|
|
54
|
+
# Logs
|
|
55
|
+
"*.log",
|
|
56
|
+
"logs/",
|
|
57
|
+
"npm-debug.log*",
|
|
58
|
+
"yarn-debug.log*",
|
|
59
|
+
"yarn-error.log*",
|
|
60
|
+
|
|
61
|
+
# Secrets/Config
|
|
62
|
+
".env",
|
|
63
|
+
".env.*",
|
|
64
|
+
"*.key",
|
|
65
|
+
"*.pem",
|
|
66
|
+
"*.p12",
|
|
67
|
+
"credentials.json",
|
|
68
|
+
"secrets/",
|
|
69
|
+
|
|
70
|
+
# Test coverage
|
|
71
|
+
"coverage/",
|
|
72
|
+
".nyc_output/",
|
|
73
|
+
"htmlcov/",
|
|
74
|
+
|
|
75
|
+
# Large media (usually not needed for context)
|
|
76
|
+
"*.jpg",
|
|
77
|
+
"*.jpeg",
|
|
78
|
+
"*.png",
|
|
79
|
+
"*.gif",
|
|
80
|
+
"*.ico",
|
|
81
|
+
"*.svg",
|
|
82
|
+
"*.mp4",
|
|
83
|
+
"*.mp3",
|
|
84
|
+
"*.wav",
|
|
85
|
+
"*.pdf",
|
|
86
|
+
"*.zip",
|
|
87
|
+
"*.tar.gz",
|
|
88
|
+
"*.rar",
|
|
89
|
+
|
|
90
|
+
# Lock files (verbose)
|
|
91
|
+
"package-lock.json",
|
|
92
|
+
"yarn.lock",
|
|
93
|
+
"pnpm-lock.yaml",
|
|
94
|
+
"poetry.lock",
|
|
95
|
+
"Pipfile.lock",
|
|
96
|
+
|
|
97
|
+
# Misc
|
|
98
|
+
".cache/",
|
|
99
|
+
"tmp/",
|
|
100
|
+
"temp/",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find_faf_file(start_dir: Optional[str] = None,
|
|
105
|
+
max_depth: int = 10) -> Optional[str]:
|
|
106
|
+
"""
|
|
107
|
+
Find project.faf or .faf file by walking up directory tree
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
start_dir: Directory to start search (default: cwd)
|
|
111
|
+
max_depth: Maximum parent directories to check
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Absolute path to .faf file, or None if not found
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
>>> path = find_faf_file("/path/to/project/src")
|
|
118
|
+
>>> if path:
|
|
119
|
+
... print(f"Found: {path}")
|
|
120
|
+
"""
|
|
121
|
+
if start_dir is None:
|
|
122
|
+
start_dir = os.getcwd()
|
|
123
|
+
|
|
124
|
+
current = Path(start_dir).resolve()
|
|
125
|
+
|
|
126
|
+
for _ in range(max_depth):
|
|
127
|
+
# Check for modern project.faf (preferred)
|
|
128
|
+
project_faf = current / "project.faf"
|
|
129
|
+
if project_faf.exists():
|
|
130
|
+
return str(project_faf)
|
|
131
|
+
|
|
132
|
+
# Check for legacy .faf
|
|
133
|
+
legacy_faf = current / ".faf"
|
|
134
|
+
if legacy_faf.exists():
|
|
135
|
+
return str(legacy_faf)
|
|
136
|
+
|
|
137
|
+
# Move up to parent
|
|
138
|
+
parent = current.parent
|
|
139
|
+
if parent == current:
|
|
140
|
+
# Reached filesystem root
|
|
141
|
+
break
|
|
142
|
+
current = parent
|
|
143
|
+
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def find_project_root(start_dir: Optional[str] = None,
|
|
148
|
+
max_depth: int = 10) -> Optional[str]:
|
|
149
|
+
"""
|
|
150
|
+
Find project root by looking for common project markers
|
|
151
|
+
|
|
152
|
+
Looks for: package.json, pyproject.toml, Cargo.toml, go.mod, etc.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
start_dir: Directory to start search (default: cwd)
|
|
156
|
+
max_depth: Maximum parent directories to check
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Absolute path to project root, or None if not found
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
>>> root = find_project_root()
|
|
163
|
+
>>> print(f"Project root: {root}")
|
|
164
|
+
"""
|
|
165
|
+
if start_dir is None:
|
|
166
|
+
start_dir = os.getcwd()
|
|
167
|
+
|
|
168
|
+
markers = [
|
|
169
|
+
"package.json",
|
|
170
|
+
"pyproject.toml",
|
|
171
|
+
"setup.py",
|
|
172
|
+
"requirements.txt",
|
|
173
|
+
"Cargo.toml",
|
|
174
|
+
"go.mod",
|
|
175
|
+
"pom.xml",
|
|
176
|
+
"build.gradle",
|
|
177
|
+
"Gemfile",
|
|
178
|
+
".git",
|
|
179
|
+
"project.faf",
|
|
180
|
+
".faf",
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
current = Path(start_dir).resolve()
|
|
184
|
+
|
|
185
|
+
for _ in range(max_depth):
|
|
186
|
+
for marker in markers:
|
|
187
|
+
if (current / marker).exists():
|
|
188
|
+
return str(current)
|
|
189
|
+
|
|
190
|
+
parent = current.parent
|
|
191
|
+
if parent == current:
|
|
192
|
+
break
|
|
193
|
+
current = parent
|
|
194
|
+
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def load_fafignore(project_root: str) -> List[str]:
|
|
199
|
+
"""
|
|
200
|
+
Load .fafignore patterns from project root
|
|
201
|
+
|
|
202
|
+
Falls back to default patterns if no .fafignore exists.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
project_root: Path to project root directory
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of ignore patterns
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
>>> patterns = load_fafignore("/path/to/project")
|
|
212
|
+
>>> for p in patterns[:5]:
|
|
213
|
+
... print(p)
|
|
214
|
+
"""
|
|
215
|
+
fafignore_path = Path(project_root) / ".fafignore"
|
|
216
|
+
|
|
217
|
+
if not fafignore_path.exists():
|
|
218
|
+
return DEFAULT_IGNORE_PATTERNS.copy()
|
|
219
|
+
|
|
220
|
+
patterns = []
|
|
221
|
+
try:
|
|
222
|
+
with open(fafignore_path, 'r', encoding='utf-8') as f:
|
|
223
|
+
for line in f:
|
|
224
|
+
line = line.strip()
|
|
225
|
+
# Skip empty lines and comments
|
|
226
|
+
if line and not line.startswith('#'):
|
|
227
|
+
patterns.append(line)
|
|
228
|
+
except IOError:
|
|
229
|
+
return DEFAULT_IGNORE_PATTERNS.copy()
|
|
230
|
+
|
|
231
|
+
return patterns if patterns else DEFAULT_IGNORE_PATTERNS.copy()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def should_ignore(file_path: str, patterns: List[str]) -> bool:
|
|
235
|
+
"""
|
|
236
|
+
Check if a file path should be ignored based on patterns
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
file_path: Relative file path to check
|
|
240
|
+
patterns: List of ignore patterns
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
True if file should be ignored
|
|
244
|
+
|
|
245
|
+
Example:
|
|
246
|
+
>>> patterns = load_fafignore(root)
|
|
247
|
+
>>> if not should_ignore("src/main.py", patterns):
|
|
248
|
+
... process_file("src/main.py")
|
|
249
|
+
"""
|
|
250
|
+
# Normalize path separators
|
|
251
|
+
file_path = file_path.replace('\\', '/')
|
|
252
|
+
|
|
253
|
+
for pattern in patterns:
|
|
254
|
+
# Handle directory patterns (ending with /)
|
|
255
|
+
if pattern.endswith('/'):
|
|
256
|
+
dir_pattern = pattern.rstrip('/')
|
|
257
|
+
if file_path.startswith(dir_pattern + '/') or file_path == dir_pattern:
|
|
258
|
+
return True
|
|
259
|
+
# Check if any component matches
|
|
260
|
+
parts = file_path.split('/')
|
|
261
|
+
if dir_pattern in parts:
|
|
262
|
+
return True
|
|
263
|
+
else:
|
|
264
|
+
# File pattern
|
|
265
|
+
if fnmatch.fnmatch(file_path, pattern):
|
|
266
|
+
return True
|
|
267
|
+
# Also check basename
|
|
268
|
+
if fnmatch.fnmatch(os.path.basename(file_path), pattern):
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def list_project_files(project_root: str,
|
|
275
|
+
ignore_patterns: Optional[List[str]] = None,
|
|
276
|
+
extensions: Optional[List[str]] = None) -> List[str]:
|
|
277
|
+
"""
|
|
278
|
+
List all project files, respecting .fafignore
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
project_root: Path to project root
|
|
282
|
+
ignore_patterns: Custom ignore patterns (default: load from .fafignore)
|
|
283
|
+
extensions: Filter by extensions (e.g., [".py", ".ts"])
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
List of relative file paths
|
|
287
|
+
|
|
288
|
+
Example:
|
|
289
|
+
>>> files = list_project_files(root, extensions=[".py", ".ts"])
|
|
290
|
+
>>> print(f"Found {len(files)} source files")
|
|
291
|
+
"""
|
|
292
|
+
if ignore_patterns is None:
|
|
293
|
+
ignore_patterns = load_fafignore(project_root)
|
|
294
|
+
|
|
295
|
+
root = Path(project_root)
|
|
296
|
+
files = []
|
|
297
|
+
|
|
298
|
+
for path in root.rglob("*"):
|
|
299
|
+
if not path.is_file():
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
relative = str(path.relative_to(root))
|
|
303
|
+
|
|
304
|
+
# Check ignore patterns
|
|
305
|
+
if should_ignore(relative, ignore_patterns):
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
# Check extensions filter
|
|
309
|
+
if extensions:
|
|
310
|
+
if path.suffix.lower() not in extensions:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
files.append(relative)
|
|
314
|
+
|
|
315
|
+
return sorted(files)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def create_default_fafignore(project_root: str) -> str:
|
|
319
|
+
"""
|
|
320
|
+
Create a default .fafignore file
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
project_root: Path to project root
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Path to created .fafignore file
|
|
327
|
+
|
|
328
|
+
Example:
|
|
329
|
+
>>> path = create_default_fafignore(root)
|
|
330
|
+
>>> print(f"Created: {path}")
|
|
331
|
+
"""
|
|
332
|
+
fafignore_path = Path(project_root) / ".fafignore"
|
|
333
|
+
|
|
334
|
+
content = [
|
|
335
|
+
"# .fafignore - Files to exclude from FAF context",
|
|
336
|
+
"# Similar to .gitignore syntax",
|
|
337
|
+
"",
|
|
338
|
+
"# Dependencies",
|
|
339
|
+
"node_modules/",
|
|
340
|
+
"__pycache__/",
|
|
341
|
+
"venv/",
|
|
342
|
+
".venv/",
|
|
343
|
+
"",
|
|
344
|
+
"# Build outputs",
|
|
345
|
+
"dist/",
|
|
346
|
+
"build/",
|
|
347
|
+
".next/",
|
|
348
|
+
"",
|
|
349
|
+
"# Version control",
|
|
350
|
+
".git/",
|
|
351
|
+
"",
|
|
352
|
+
"# IDE",
|
|
353
|
+
".vscode/",
|
|
354
|
+
".idea/",
|
|
355
|
+
"",
|
|
356
|
+
"# Secrets",
|
|
357
|
+
".env",
|
|
358
|
+
".env.*",
|
|
359
|
+
"*.key",
|
|
360
|
+
"*.pem",
|
|
361
|
+
"",
|
|
362
|
+
"# Large files",
|
|
363
|
+
"*.jpg",
|
|
364
|
+
"*.png",
|
|
365
|
+
"*.mp4",
|
|
366
|
+
"*.pdf",
|
|
367
|
+
"*.zip",
|
|
368
|
+
"",
|
|
369
|
+
"# Lock files",
|
|
370
|
+
"package-lock.json",
|
|
371
|
+
"yarn.lock",
|
|
372
|
+
"",
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
with open(fafignore_path, 'w', encoding='utf-8') as f:
|
|
376
|
+
f.write('\n'.join(content))
|
|
377
|
+
|
|
378
|
+
return str(fafignore_path)
|
faf_sdk/parser.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core FAF parser - YAML parsing with validation
|
|
3
|
+
|
|
4
|
+
Mirrors the TypeScript fix-once/yaml.ts implementation for cross-language compatibility.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from typing import Any, Dict, Optional, Union
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .types import FafData
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FafParseError(Exception):
|
|
15
|
+
"""Raised when FAF parsing fails"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FafFile:
|
|
21
|
+
"""
|
|
22
|
+
Parsed FAF file with both raw and typed access
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
data: Typed FafData object with all sections
|
|
26
|
+
raw: Raw dictionary from YAML parsing
|
|
27
|
+
path: Optional file path if loaded from disk
|
|
28
|
+
"""
|
|
29
|
+
data: FafData
|
|
30
|
+
raw: Dict[str, Any]
|
|
31
|
+
path: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def project_name(self) -> str:
|
|
35
|
+
"""Quick access to project name"""
|
|
36
|
+
return self.data.project.name
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def score(self) -> Optional[int]:
|
|
40
|
+
"""Quick access to AI score"""
|
|
41
|
+
return self.data.ai_score
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def version(self) -> str:
|
|
45
|
+
"""Quick access to FAF version"""
|
|
46
|
+
return self.data.faf_version
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse(content: Union[str, None], path: Optional[str] = None) -> FafFile:
|
|
50
|
+
"""
|
|
51
|
+
Parse FAF content from string
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
content: YAML string content of .faf file
|
|
55
|
+
path: Optional file path for error messages
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
FafFile object with parsed data
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
FafParseError: If content is invalid
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> content = open("project.faf").read()
|
|
65
|
+
>>> faf = parse(content)
|
|
66
|
+
>>> print(faf.project_name)
|
|
67
|
+
'my-project'
|
|
68
|
+
"""
|
|
69
|
+
# Handle null/empty content
|
|
70
|
+
if content is None:
|
|
71
|
+
raise FafParseError("Content is null or undefined")
|
|
72
|
+
|
|
73
|
+
if not isinstance(content, str):
|
|
74
|
+
raise FafParseError(f"Content must be string, got {type(content).__name__}")
|
|
75
|
+
|
|
76
|
+
content = content.strip()
|
|
77
|
+
if not content:
|
|
78
|
+
raise FafParseError("Content is empty")
|
|
79
|
+
|
|
80
|
+
# Parse YAML
|
|
81
|
+
try:
|
|
82
|
+
data = yaml.safe_load(content)
|
|
83
|
+
except yaml.YAMLError as e:
|
|
84
|
+
location = f" in {path}" if path else ""
|
|
85
|
+
raise FafParseError(f"Invalid YAML syntax{location}: {e}")
|
|
86
|
+
|
|
87
|
+
# Validate structure
|
|
88
|
+
if data is None:
|
|
89
|
+
raise FafParseError("YAML parsed to null - file may be empty or all comments")
|
|
90
|
+
|
|
91
|
+
if not isinstance(data, dict):
|
|
92
|
+
raise FafParseError(
|
|
93
|
+
f"FAF must be a YAML object/dictionary, got {type(data).__name__}. "
|
|
94
|
+
"Arrays and primitives are not valid FAF files."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Convert to typed structure
|
|
98
|
+
try:
|
|
99
|
+
faf_data = FafData.from_dict(data)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
raise FafParseError(f"Failed to parse FAF structure: {e}")
|
|
102
|
+
|
|
103
|
+
return FafFile(
|
|
104
|
+
data=faf_data,
|
|
105
|
+
raw=data,
|
|
106
|
+
path=path
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_file(filepath: str) -> FafFile:
|
|
111
|
+
"""
|
|
112
|
+
Parse FAF from file path
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
filepath: Path to .faf file
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
FafFile object with parsed data
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
FafParseError: If file cannot be read or parsed
|
|
122
|
+
FileNotFoundError: If file doesn't exist
|
|
123
|
+
|
|
124
|
+
Example:
|
|
125
|
+
>>> faf = parse_file("project.faf")
|
|
126
|
+
>>> print(faf.data.instant_context.tech_stack)
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
130
|
+
content = f.read()
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
raise FileNotFoundError(f"FAF file not found: {filepath}")
|
|
133
|
+
except IOError as e:
|
|
134
|
+
raise FafParseError(f"Failed to read {filepath}: {e}")
|
|
135
|
+
|
|
136
|
+
return parse(content, path=filepath)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def stringify(data: Union[Dict[str, Any], FafFile, FafData],
|
|
140
|
+
default_flow_style: bool = False) -> str:
|
|
141
|
+
"""
|
|
142
|
+
Convert FAF data back to YAML string
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
data: Dictionary, FafFile, or FafData to serialize
|
|
146
|
+
default_flow_style: Use flow style for collections
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
YAML string
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
>>> yaml_str = stringify(faf.raw)
|
|
153
|
+
>>> with open("output.faf", "w") as f:
|
|
154
|
+
... f.write(yaml_str)
|
|
155
|
+
"""
|
|
156
|
+
if isinstance(data, FafFile):
|
|
157
|
+
data = data.raw
|
|
158
|
+
elif isinstance(data, FafData):
|
|
159
|
+
data = data.raw
|
|
160
|
+
|
|
161
|
+
return yaml.dump(
|
|
162
|
+
data,
|
|
163
|
+
default_flow_style=default_flow_style,
|
|
164
|
+
allow_unicode=True,
|
|
165
|
+
sort_keys=False,
|
|
166
|
+
indent=2
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_field(faf: FafFile, *keys: str, default: Any = None) -> Any:
|
|
171
|
+
"""
|
|
172
|
+
Safely get nested field from FAF raw data
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
faf: Parsed FafFile
|
|
176
|
+
*keys: Path to field (e.g., "project", "name")
|
|
177
|
+
default: Default value if not found
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Field value or default
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
>>> name = get_field(faf, "project", "name")
|
|
184
|
+
>>> stack = get_field(faf, "stack", "frontend", default="None")
|
|
185
|
+
"""
|
|
186
|
+
value = faf.raw
|
|
187
|
+
for key in keys:
|
|
188
|
+
if isinstance(value, dict):
|
|
189
|
+
value = value.get(key)
|
|
190
|
+
else:
|
|
191
|
+
return default
|
|
192
|
+
if value is None:
|
|
193
|
+
return default
|
|
194
|
+
return value
|
faf_sdk/types.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type definitions for FAF (Foundational AI-context Format)
|
|
3
|
+
|
|
4
|
+
These mirror the TypeScript definitions in faf-cli for cross-language compatibility.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ProjectInfo:
|
|
13
|
+
"""Core project metadata"""
|
|
14
|
+
name: str
|
|
15
|
+
goal: Optional[str] = None
|
|
16
|
+
main_language: Optional[str] = None
|
|
17
|
+
approach: Optional[str] = None
|
|
18
|
+
version: Optional[str] = None
|
|
19
|
+
license: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class StackInfo:
|
|
24
|
+
"""Technical stack breakdown"""
|
|
25
|
+
frontend: Optional[str] = None
|
|
26
|
+
backend: Optional[str] = None
|
|
27
|
+
database: Optional[str] = None
|
|
28
|
+
infrastructure: Optional[str] = None
|
|
29
|
+
build_tool: Optional[str] = None
|
|
30
|
+
testing: Optional[str] = None
|
|
31
|
+
cicd: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class InstantContext:
|
|
36
|
+
"""Quick context for AI understanding"""
|
|
37
|
+
what_building: Optional[str] = None
|
|
38
|
+
tech_stack: Optional[str] = None
|
|
39
|
+
deployment: Optional[str] = None
|
|
40
|
+
key_files: List[str] = field(default_factory=list)
|
|
41
|
+
commands: Dict[str, str] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ContextQuality:
|
|
46
|
+
"""Quality metrics for the FAF file"""
|
|
47
|
+
slots_filled: Optional[str] = None
|
|
48
|
+
confidence: Optional[str] = None
|
|
49
|
+
handoff_ready: bool = False
|
|
50
|
+
missing_context: List[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class HumanContext:
|
|
55
|
+
"""The 6 W's - human-readable context"""
|
|
56
|
+
who: Optional[str] = None # Target users
|
|
57
|
+
what: Optional[str] = None # Core problem
|
|
58
|
+
why: Optional[str] = None # Mission/purpose
|
|
59
|
+
how: Optional[str] = None # Approach
|
|
60
|
+
where: Optional[str] = None # Deployment
|
|
61
|
+
when: Optional[str] = None # Timeline
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class AIScoring:
|
|
66
|
+
"""AI-readiness scoring system"""
|
|
67
|
+
score: Optional[int] = None # 0-100
|
|
68
|
+
confidence: Optional[str] = None # LOW, MEDIUM, HIGH
|
|
69
|
+
version: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class AIInstructions:
|
|
74
|
+
"""Instructions for AI assistants"""
|
|
75
|
+
working_style: Optional[str] = None
|
|
76
|
+
quality_bar: Optional[str] = None
|
|
77
|
+
warnings: List[str] = field(default_factory=list)
|
|
78
|
+
focus_areas: List[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Preferences:
|
|
83
|
+
"""Development preferences"""
|
|
84
|
+
quality_bar: Optional[str] = None
|
|
85
|
+
testing: Optional[str] = None
|
|
86
|
+
documentation: Optional[str] = None
|
|
87
|
+
code_style: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class State:
|
|
92
|
+
"""Project state tracking"""
|
|
93
|
+
phase: Optional[str] = None
|
|
94
|
+
version: Optional[str] = None
|
|
95
|
+
focus: Optional[str] = None
|
|
96
|
+
milestones: List[str] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class FafData:
|
|
101
|
+
"""
|
|
102
|
+
Complete FAF file structure
|
|
103
|
+
|
|
104
|
+
Represents the full parsed content of a .faf file.
|
|
105
|
+
All fields are optional except faf_version and project.
|
|
106
|
+
"""
|
|
107
|
+
faf_version: str
|
|
108
|
+
project: ProjectInfo
|
|
109
|
+
|
|
110
|
+
# Optional sections
|
|
111
|
+
ai_score: Optional[int] = None
|
|
112
|
+
ai_confidence: Optional[str] = None
|
|
113
|
+
ai_tldr: Optional[Dict[str, str]] = None
|
|
114
|
+
instant_context: Optional[InstantContext] = None
|
|
115
|
+
context_quality: Optional[ContextQuality] = None
|
|
116
|
+
stack: Optional[StackInfo] = None
|
|
117
|
+
human_context: Optional[HumanContext] = None
|
|
118
|
+
ai_instructions: Optional[AIInstructions] = None
|
|
119
|
+
preferences: Optional[Preferences] = None
|
|
120
|
+
state: Optional[State] = None
|
|
121
|
+
tags: List[str] = field(default_factory=list)
|
|
122
|
+
|
|
123
|
+
# Raw data for unrecognized fields
|
|
124
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_dict(cls, data: Dict[str, Any]) -> "FafData":
|
|
128
|
+
"""Create FafData from parsed YAML dictionary"""
|
|
129
|
+
project_data = data.get("project", {})
|
|
130
|
+
if isinstance(project_data, str):
|
|
131
|
+
project_data = {"name": project_data}
|
|
132
|
+
|
|
133
|
+
project = ProjectInfo(
|
|
134
|
+
name=project_data.get("name", "unknown"),
|
|
135
|
+
goal=project_data.get("goal"),
|
|
136
|
+
main_language=project_data.get("main_language"),
|
|
137
|
+
approach=project_data.get("approach"),
|
|
138
|
+
version=project_data.get("version"),
|
|
139
|
+
license=project_data.get("license")
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Parse instant_context
|
|
143
|
+
instant_ctx = None
|
|
144
|
+
if "instant_context" in data:
|
|
145
|
+
ic = data["instant_context"]
|
|
146
|
+
instant_ctx = InstantContext(
|
|
147
|
+
what_building=ic.get("what_building"),
|
|
148
|
+
tech_stack=ic.get("tech_stack"),
|
|
149
|
+
deployment=ic.get("deployment"),
|
|
150
|
+
key_files=ic.get("key_files", []),
|
|
151
|
+
commands=ic.get("commands", {})
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Parse stack
|
|
155
|
+
stack = None
|
|
156
|
+
if "stack" in data:
|
|
157
|
+
s = data["stack"]
|
|
158
|
+
stack = StackInfo(
|
|
159
|
+
frontend=s.get("frontend"),
|
|
160
|
+
backend=s.get("backend"),
|
|
161
|
+
database=s.get("database"),
|
|
162
|
+
infrastructure=s.get("infrastructure"),
|
|
163
|
+
build_tool=s.get("build_tool"),
|
|
164
|
+
testing=s.get("testing"),
|
|
165
|
+
cicd=s.get("cicd")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Parse context_quality
|
|
169
|
+
ctx_quality = None
|
|
170
|
+
if "context_quality" in data:
|
|
171
|
+
cq = data["context_quality"]
|
|
172
|
+
ctx_quality = ContextQuality(
|
|
173
|
+
slots_filled=cq.get("slots_filled"),
|
|
174
|
+
confidence=cq.get("confidence"),
|
|
175
|
+
handoff_ready=cq.get("handoff_ready", False),
|
|
176
|
+
missing_context=cq.get("missing_context", [])
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Parse human_context
|
|
180
|
+
human_ctx = None
|
|
181
|
+
if "human_context" in data:
|
|
182
|
+
hc = data["human_context"]
|
|
183
|
+
human_ctx = HumanContext(
|
|
184
|
+
who=hc.get("who"),
|
|
185
|
+
what=hc.get("what"),
|
|
186
|
+
why=hc.get("why"),
|
|
187
|
+
how=hc.get("how"),
|
|
188
|
+
where=hc.get("where"),
|
|
189
|
+
when=hc.get("when")
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Parse AI score
|
|
193
|
+
ai_score = data.get("ai_score")
|
|
194
|
+
if isinstance(ai_score, str) and ai_score.endswith("%"):
|
|
195
|
+
ai_score = int(ai_score.rstrip("%"))
|
|
196
|
+
|
|
197
|
+
return cls(
|
|
198
|
+
faf_version=data.get("faf_version", "2.5.0"),
|
|
199
|
+
project=project,
|
|
200
|
+
ai_score=ai_score,
|
|
201
|
+
ai_confidence=data.get("ai_confidence"),
|
|
202
|
+
ai_tldr=data.get("ai_tldr"),
|
|
203
|
+
instant_context=instant_ctx,
|
|
204
|
+
context_quality=ctx_quality,
|
|
205
|
+
stack=stack,
|
|
206
|
+
human_context=human_ctx,
|
|
207
|
+
preferences=None, # Add parsing if needed
|
|
208
|
+
state=None, # Add parsing if needed
|
|
209
|
+
tags=data.get("tags", []),
|
|
210
|
+
raw=data
|
|
211
|
+
)
|
faf_sdk/validator.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FAF validation - check structure and completeness
|
|
3
|
+
|
|
4
|
+
Mirrors claude-faf-mcp/src/faf-core/commands/validate.ts
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Tuple, Union
|
|
9
|
+
|
|
10
|
+
from .parser import FafFile, parse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ValidationResult:
|
|
15
|
+
"""
|
|
16
|
+
Result of FAF validation
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
valid: True if no errors (warnings OK)
|
|
20
|
+
errors: List of critical errors
|
|
21
|
+
warnings: List of non-critical warnings
|
|
22
|
+
score: Calculated completeness score (0-100)
|
|
23
|
+
"""
|
|
24
|
+
valid: bool
|
|
25
|
+
errors: List[str] = field(default_factory=list)
|
|
26
|
+
warnings: List[str] = field(default_factory=list)
|
|
27
|
+
score: int = 0
|
|
28
|
+
|
|
29
|
+
def __bool__(self) -> bool:
|
|
30
|
+
return self.valid
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate(faf: Union[FafFile, Dict[str, Any], str]) -> ValidationResult:
|
|
34
|
+
"""
|
|
35
|
+
Validate FAF file structure and completeness
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
faf: FafFile, raw dict, or YAML string to validate
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
ValidationResult with errors, warnings, and score
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> result = validate(faf)
|
|
45
|
+
>>> if not result.valid:
|
|
46
|
+
... print("Errors:", result.errors)
|
|
47
|
+
>>> print(f"Score: {result.score}%")
|
|
48
|
+
"""
|
|
49
|
+
errors: List[str] = []
|
|
50
|
+
warnings: List[str] = []
|
|
51
|
+
|
|
52
|
+
# Parse if string
|
|
53
|
+
if isinstance(faf, str):
|
|
54
|
+
try:
|
|
55
|
+
faf = parse(faf)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
return ValidationResult(
|
|
58
|
+
valid=False,
|
|
59
|
+
errors=[f"Parse error: {e}"],
|
|
60
|
+
score=0
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Get raw data
|
|
64
|
+
if isinstance(faf, FafFile):
|
|
65
|
+
data = faf.raw
|
|
66
|
+
else:
|
|
67
|
+
data = faf
|
|
68
|
+
|
|
69
|
+
# Required fields
|
|
70
|
+
if "faf_version" not in data:
|
|
71
|
+
errors.append("Missing required field: faf_version")
|
|
72
|
+
|
|
73
|
+
if "project" not in data:
|
|
74
|
+
errors.append("Missing required field: project")
|
|
75
|
+
else:
|
|
76
|
+
project = data["project"]
|
|
77
|
+
if isinstance(project, dict):
|
|
78
|
+
if "name" not in project:
|
|
79
|
+
errors.append("Missing required field: project.name")
|
|
80
|
+
elif not isinstance(project, str):
|
|
81
|
+
errors.append("project must be object or string")
|
|
82
|
+
|
|
83
|
+
# Recommended sections
|
|
84
|
+
if "instant_context" not in data:
|
|
85
|
+
warnings.append("Missing recommended section: instant_context")
|
|
86
|
+
else:
|
|
87
|
+
ic = data["instant_context"]
|
|
88
|
+
if isinstance(ic, dict):
|
|
89
|
+
if "what_building" not in ic:
|
|
90
|
+
warnings.append("Missing instant_context.what_building")
|
|
91
|
+
if "tech_stack" not in ic:
|
|
92
|
+
warnings.append("Missing instant_context.tech_stack")
|
|
93
|
+
|
|
94
|
+
if "stack" not in data:
|
|
95
|
+
warnings.append("Missing recommended section: stack")
|
|
96
|
+
|
|
97
|
+
# Optional but useful
|
|
98
|
+
if "human_context" not in data:
|
|
99
|
+
warnings.append("Missing section: human_context (the 6 W's)")
|
|
100
|
+
|
|
101
|
+
if "ai_instructions" not in data:
|
|
102
|
+
warnings.append("Missing section: ai_instructions")
|
|
103
|
+
|
|
104
|
+
# Type validations
|
|
105
|
+
if "tags" in data and not isinstance(data["tags"], list):
|
|
106
|
+
errors.append("tags must be an array")
|
|
107
|
+
|
|
108
|
+
if "ai_score" in data:
|
|
109
|
+
score_val = data["ai_score"]
|
|
110
|
+
if isinstance(score_val, str):
|
|
111
|
+
if not score_val.endswith("%"):
|
|
112
|
+
warnings.append("ai_score should end with % (e.g., '85%')")
|
|
113
|
+
elif not isinstance(score_val, (int, float)):
|
|
114
|
+
errors.append("ai_score must be number or percentage string")
|
|
115
|
+
|
|
116
|
+
# Calculate completeness score
|
|
117
|
+
score = _calculate_score(data)
|
|
118
|
+
|
|
119
|
+
return ValidationResult(
|
|
120
|
+
valid=len(errors) == 0,
|
|
121
|
+
errors=errors,
|
|
122
|
+
warnings=warnings,
|
|
123
|
+
score=score
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _calculate_score(data: Dict[str, Any]) -> int:
|
|
128
|
+
"""
|
|
129
|
+
Calculate completeness score based on filled sections
|
|
130
|
+
|
|
131
|
+
Scoring breakdown:
|
|
132
|
+
- Required fields (30 points)
|
|
133
|
+
- Core sections (40 points)
|
|
134
|
+
- Extended sections (30 points)
|
|
135
|
+
"""
|
|
136
|
+
score = 0
|
|
137
|
+
max_score = 100
|
|
138
|
+
|
|
139
|
+
# Required fields (30 points)
|
|
140
|
+
if "faf_version" in data:
|
|
141
|
+
score += 10
|
|
142
|
+
if "project" in data:
|
|
143
|
+
score += 10
|
|
144
|
+
project = data["project"]
|
|
145
|
+
if isinstance(project, dict):
|
|
146
|
+
if project.get("name"):
|
|
147
|
+
score += 5
|
|
148
|
+
if project.get("goal"):
|
|
149
|
+
score += 5
|
|
150
|
+
|
|
151
|
+
# Core sections (40 points)
|
|
152
|
+
if "instant_context" in data:
|
|
153
|
+
ic = data["instant_context"]
|
|
154
|
+
score += 5
|
|
155
|
+
if isinstance(ic, dict):
|
|
156
|
+
if ic.get("what_building"):
|
|
157
|
+
score += 5
|
|
158
|
+
if ic.get("tech_stack"):
|
|
159
|
+
score += 5
|
|
160
|
+
if ic.get("key_files"):
|
|
161
|
+
score += 5
|
|
162
|
+
|
|
163
|
+
if "stack" in data:
|
|
164
|
+
score += 10
|
|
165
|
+
stack = data["stack"]
|
|
166
|
+
if isinstance(stack, dict) and len(stack) > 2:
|
|
167
|
+
score += 5
|
|
168
|
+
|
|
169
|
+
if "context_quality" in data:
|
|
170
|
+
score += 5
|
|
171
|
+
|
|
172
|
+
# Extended sections (30 points)
|
|
173
|
+
if "human_context" in data:
|
|
174
|
+
score += 10
|
|
175
|
+
|
|
176
|
+
if "ai_instructions" in data:
|
|
177
|
+
score += 5
|
|
178
|
+
|
|
179
|
+
if "preferences" in data:
|
|
180
|
+
score += 5
|
|
181
|
+
|
|
182
|
+
if "state" in data:
|
|
183
|
+
score += 5
|
|
184
|
+
|
|
185
|
+
if data.get("tags"):
|
|
186
|
+
score += 5
|
|
187
|
+
|
|
188
|
+
return min(score, max_score)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def validate_quick(content: str) -> Tuple[bool, str]:
|
|
192
|
+
"""
|
|
193
|
+
Quick validation returning simple pass/fail with message
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
content: YAML string to validate
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Tuple of (valid, message)
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> valid, msg = validate_quick(yaml_content)
|
|
203
|
+
>>> if not valid:
|
|
204
|
+
... print(f"Invalid: {msg}")
|
|
205
|
+
"""
|
|
206
|
+
result = validate(content)
|
|
207
|
+
|
|
208
|
+
if not result.valid:
|
|
209
|
+
return False, f"Invalid: {'; '.join(result.errors)}"
|
|
210
|
+
elif result.warnings:
|
|
211
|
+
return True, f"Valid with warnings: {'; '.join(result.warnings[:2])}"
|
|
212
|
+
else:
|
|
213
|
+
return True, f"Valid (score: {result.score}%)"
|