ai-docs-toolkit 0.1.0b1__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.
@@ -0,0 +1,3 @@
1
+ """AI Docs Toolkit package baseline."""
2
+
3
+ __version__ = "0.1.0b1"
@@ -0,0 +1,4 @@
1
+ from ai_docs_toolkit.cli import main
2
+
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,232 @@
1
+ from ai_docs_toolkit import __version__
2
+ from ai_docs_toolkit.context import (
3
+ context_project,
4
+ format_context_json,
5
+ format_context_markdown,
6
+ )
7
+ from ai_docs_toolkit.graph import GraphFilters, format_graph_json, graph_project
8
+ from ai_docs_toolkit.impact import (
9
+ format_impact_human,
10
+ format_impact_json,
11
+ impact_changed_project,
12
+ impact_project,
13
+ )
14
+ from ai_docs_toolkit.validation import (
15
+ format_human_validation_result,
16
+ format_json_validation_result,
17
+ validate_project_human,
18
+ )
19
+
20
+
21
+ def _help_text() -> str:
22
+ return "\n".join(
23
+ [
24
+ f"ai-docs {__version__}",
25
+ "",
26
+ "Usage:",
27
+ " ai-docs --help",
28
+ " ai-docs --version",
29
+ " ai-docs validate",
30
+ " ai-docs validate --json",
31
+ " ai-docs graph --format json [--type TYPE] [--module MODULE] [--id ID]",
32
+ " ai-docs impact (--id ID | --changed) [--format json]",
33
+ " ai-docs context (--id ID | --module MODULE | --feature FEATURE | --changed) [--format json]",
34
+ ]
35
+ )
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ args = list(argv) if argv is not None else None
40
+
41
+ if args is None:
42
+ import sys
43
+
44
+ args = sys.argv[1:]
45
+
46
+ if not args or "--help" in args or "-h" in args:
47
+ print(_help_text())
48
+ return 0
49
+
50
+ if "--version" in args or "-V" in args:
51
+ print(__version__)
52
+ return 0
53
+
54
+ if args == ["validate"]:
55
+ result = validate_project_human(".")
56
+ print(format_human_validation_result(result))
57
+ return result.exit_code
58
+
59
+ if args == ["validate", "--json"] or args == ["--json", "validate"]:
60
+ result = validate_project_human(".")
61
+ print(format_json_validation_result(result))
62
+ return result.exit_code
63
+
64
+ graph_filters = _parse_graph_args(args)
65
+ if graph_filters is not None:
66
+ result = graph_project(".", filters=graph_filters)
67
+ print(format_graph_json(result))
68
+ return result.exit_code
69
+
70
+ impact_args = _parse_impact_args(args)
71
+ if impact_args is not None:
72
+ impact_mode, impact_source_ids, impact_format = impact_args
73
+ result = (
74
+ impact_changed_project(".")
75
+ if impact_mode == "changed"
76
+ else impact_project(impact_source_ids, ".")
77
+ )
78
+ print(
79
+ format_impact_json(result)
80
+ if impact_format == "json"
81
+ else format_impact_human(result)
82
+ )
83
+ return result.exit_code
84
+
85
+ context_args = _parse_context_args(args)
86
+ if context_args is not None:
87
+ context_mode, context_value, context_format = context_args
88
+ result = context_project(mode=context_mode, value=context_value, project_root=".")
89
+ print(
90
+ format_context_json(result)
91
+ if context_format == "json"
92
+ else format_context_markdown(result)
93
+ )
94
+ return result.exit_code
95
+
96
+ if args and args[0] == "graph":
97
+ import sys
98
+
99
+ print("Usage: ai-docs graph --format json", file=sys.stderr)
100
+ return 3
101
+
102
+ if args and args[0] == "impact":
103
+ import sys
104
+
105
+ print("Usage: ai-docs impact (--id ID | --changed) [--format json]", file=sys.stderr)
106
+ return 3
107
+
108
+ if args and args[0] == "context":
109
+ import sys
110
+
111
+ print(
112
+ "Usage: ai-docs context (--id ID | --module MODULE | --feature FEATURE | --changed) [--format json]",
113
+ file=sys.stderr,
114
+ )
115
+ return 3
116
+
117
+ import sys
118
+
119
+ print(f"Unknown command or option: {' '.join(args)}", file=sys.stderr)
120
+ print("Run `ai-docs --help` for usage.", file=sys.stderr)
121
+ return 3
122
+
123
+
124
+ def _parse_graph_args(args: list[str]) -> GraphFilters | None:
125
+ if not args or args[0] != "graph":
126
+ return None
127
+
128
+ values = args[1:]
129
+ if len(values) < 2 or values[:2] != ["--format", "json"]:
130
+ return None
131
+
132
+ document_type: str | None = None
133
+ module: str | None = None
134
+ document_id: str | None = None
135
+ index = 2
136
+ while index < len(values):
137
+ option = values[index]
138
+ if index + 1 >= len(values):
139
+ return None
140
+
141
+ value = values[index + 1]
142
+ if option == "--type":
143
+ document_type = value
144
+ elif option == "--module":
145
+ module = value
146
+ elif option == "--id":
147
+ document_id = value
148
+ else:
149
+ return None
150
+ index += 2
151
+
152
+ return GraphFilters(
153
+ document_type=document_type,
154
+ module=module,
155
+ document_id=document_id,
156
+ )
157
+
158
+
159
+ def _parse_impact_args(args: list[str]) -> tuple[str, tuple[str, ...], str] | None:
160
+ if not args or args[0] != "impact":
161
+ return None
162
+
163
+ values = args[1:]
164
+ if len(values) not in (1, 2, 3, 4):
165
+ return None
166
+
167
+ if len(values) == 2 and values[0] == "--id":
168
+ return ("id", (values[1],), "human")
169
+
170
+ if len(values) == 4 and values[0] == "--id" and values[2:] == ["--format", "json"]:
171
+ return ("id", (values[1],), "json")
172
+
173
+ if len(values) == 4 and values[:2] == ["--format", "json"] and values[2] == "--id":
174
+ return ("id", (values[3],), "json")
175
+
176
+ if values == ["--changed", "--format", "json"]:
177
+ return ("changed", (), "json")
178
+
179
+ if values == ["--format", "json", "--changed"]:
180
+ return ("changed", (), "json")
181
+
182
+ if values == ["--changed"]:
183
+ return ("changed", (), "human")
184
+
185
+ return None
186
+
187
+
188
+ def _parse_context_args(args: list[str]) -> tuple[str, str, str] | None:
189
+ if not args or args[0] != "context":
190
+ return None
191
+
192
+ values = args[1:]
193
+ if len(values) not in (1, 2, 3, 4):
194
+ return None
195
+
196
+ if values == ["--changed"]:
197
+ return ("changed", "", "markdown")
198
+
199
+ if values == ["--changed", "--format", "json"]:
200
+ return ("changed", "", "json")
201
+
202
+ if values == ["--format", "json", "--changed"]:
203
+ return ("changed", "", "json")
204
+
205
+ if len(values) == 2:
206
+ option, value = values
207
+ mode = _context_mode(option)
208
+ if mode is not None and value:
209
+ return (mode, value, "markdown")
210
+ return None
211
+
212
+ if values[2:] == ["--format", "json"]:
213
+ mode = _context_mode(values[0])
214
+ if mode is not None and values[1]:
215
+ return (mode, values[1], "json")
216
+
217
+ if values[:2] == ["--format", "json"]:
218
+ mode = _context_mode(values[2])
219
+ if mode is not None and values[3]:
220
+ return (mode, values[3], "json")
221
+
222
+ return None
223
+
224
+
225
+ def _context_mode(option: str) -> str | None:
226
+ if option == "--id":
227
+ return "id"
228
+ if option == "--module":
229
+ return "module"
230
+ if option == "--feature":
231
+ return "feature"
232
+ return None
@@ -0,0 +1,13 @@
1
+ from ai_docs_toolkit.context.markdown_output import (
2
+ ContextBuildResult,
3
+ context_project,
4
+ format_context_json,
5
+ format_context_markdown,
6
+ )
7
+
8
+ __all__ = [
9
+ "ContextBuildResult",
10
+ "context_project",
11
+ "format_context_json",
12
+ "format_context_markdown",
13
+ ]
@@ -0,0 +1,325 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from ai_docs_toolkit.core.changed_files import (
9
+ ChangedFileIssue,
10
+ read_git_changed_files,
11
+ resolve_changed_document_sources,
12
+ )
13
+ from ai_docs_toolkit.core import (
14
+ ContextSelectionResult,
15
+ DocumentRegistry,
16
+ RegistryDocument,
17
+ ToolkitConfig,
18
+ build_document_graph,
19
+ build_document_registry,
20
+ load_project_config,
21
+ parse_markdown_file,
22
+ registry_document_from_parsed,
23
+ scan_markdown_files,
24
+ select_context_by_changed_ids,
25
+ select_context_by_feature,
26
+ select_context_by_ids,
27
+ select_context_by_module,
28
+ )
29
+ from ai_docs_toolkit.validation import ValidationMessage
30
+
31
+ RULE_CHANGED_FILE_NOT_DOCUMENT = "document.context.changed_file_not_document"
32
+ RULE_CHANGED_FILE_OUTSIDE_DOCS = "document.context.changed_file_outside_docs"
33
+ RULE_GIT_CHANGED_FILES_UNAVAILABLE = "document.context.git_changed_files_unavailable"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ContextBuildResult:
38
+ exit_code: int
39
+ selection: ContextSelectionResult | None
40
+ registry: DocumentRegistry | None = None
41
+ warnings: tuple[ValidationMessage, ...] = ()
42
+ errors: tuple[ValidationMessage, ...] = ()
43
+
44
+ @property
45
+ def ok(self) -> bool:
46
+ return self.exit_code == 0
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _ProjectContext:
51
+ root: Path
52
+ config: ToolkitConfig
53
+ documents: tuple[RegistryDocument, ...]
54
+ errors: tuple[ValidationMessage, ...]
55
+
56
+
57
+ def context_project(
58
+ *,
59
+ mode: str,
60
+ value: str,
61
+ project_root: str | Path = ".",
62
+ ) -> ContextBuildResult:
63
+ context_result = _load_project_context(project_root)
64
+ if isinstance(context_result, ContextBuildResult):
65
+ return context_result
66
+
67
+ registry = build_document_registry(context_result.documents)
68
+ errors = [*context_result.errors, *_registry_errors(registry)]
69
+ graph = build_document_graph(registry, project_root=context_result.root)
70
+
71
+ if mode == "id":
72
+ selection = select_context_by_ids(graph, registry, [value])
73
+ elif mode == "module":
74
+ selection = select_context_by_module(graph, registry, value)
75
+ elif mode == "feature":
76
+ selection = select_context_by_feature(graph, registry, value)
77
+ elif mode == "changed":
78
+ changed_result = _git_changed_files(context_result.root)
79
+ errors.extend(_changed_file_errors(changed_result.issues))
80
+ source_result = resolve_changed_document_sources(
81
+ changed_result.changed_files,
82
+ config=context_result.config,
83
+ registry=registry,
84
+ root=context_result.root,
85
+ outside_docs_rule_id=RULE_CHANGED_FILE_OUTSIDE_DOCS,
86
+ not_document_rule_id=RULE_CHANGED_FILE_NOT_DOCUMENT,
87
+ )
88
+ source_ids = source_result.source_ids
89
+ changed_files_by_id = source_result.changed_files_by_id
90
+ warnings = [*_changed_file_warnings((*changed_result.issues, *source_result.issues))]
91
+ selection = select_context_by_changed_ids(
92
+ graph,
93
+ registry,
94
+ {document_id: changed_files_by_id[document_id] for document_id in source_ids},
95
+ )
96
+ errors.extend(_selection_errors(selection))
97
+ warnings.extend(_selection_warnings(selection))
98
+ return ContextBuildResult(
99
+ exit_code=0 if not errors else 1,
100
+ selection=selection,
101
+ registry=registry,
102
+ warnings=tuple(warnings),
103
+ errors=tuple(errors),
104
+ )
105
+ else:
106
+ raise ValueError(f"Unsupported context mode: {mode}")
107
+
108
+ errors.extend(_selection_errors(selection))
109
+ warnings = _selection_warnings(selection)
110
+ return ContextBuildResult(
111
+ exit_code=0 if not errors else 1,
112
+ selection=selection,
113
+ registry=registry,
114
+ warnings=warnings,
115
+ errors=tuple(errors),
116
+ )
117
+
118
+
119
+ def format_context_markdown(result: ContextBuildResult) -> str:
120
+ lines: list[str] = ["# AI Docs Context Bundle", ""]
121
+ lines.append("Status: completed" if result.exit_code == 0 else "Status: failed")
122
+
123
+ selection = result.selection
124
+ sources = selection.sources if selection is not None else ()
125
+ documents = selection.documents if selection is not None else ()
126
+ lines.append(f"Sources: {len(sources)}")
127
+ lines.append(f"Documents: {len(documents)}")
128
+
129
+ if sources:
130
+ lines.append("")
131
+ lines.append("## Sources")
132
+ for source in sources:
133
+ changed = f" changed_file={source.changed_file}" if source.changed_file else ""
134
+ lines.append(
135
+ f"- {source.document_id} ({source.type}) {source.path} "
136
+ f"source={source.source}{changed}"
137
+ )
138
+
139
+ if result.warnings:
140
+ lines.append("")
141
+ lines.append("## Warnings")
142
+ for warning in result.warnings:
143
+ lines.append(f"- {_message_line(warning)}")
144
+
145
+ if result.errors:
146
+ lines.append("")
147
+ lines.append("## Errors")
148
+ for error in result.errors:
149
+ lines.append(f"- {_message_line(error)}")
150
+
151
+ if documents:
152
+ lines.append("")
153
+ lines.append("## Documents")
154
+ for document in documents:
155
+ lines.append("")
156
+ lines.append(f"### {document.document_id}")
157
+ lines.append("")
158
+ lines.append("```yaml")
159
+ lines.append(f"type: {document.type}")
160
+ lines.append(f"path: {document.path}")
161
+ lines.append(f"group: {document.group}")
162
+ lines.append(f"distance: {document.distance}")
163
+ lines.append(f"priority: {document.priority}")
164
+ lines.append(f"context_role: {document.context_role}")
165
+ lines.append("reasons:")
166
+ for reason in document.reasons:
167
+ lines.append(f" - {reason.message}")
168
+ lines.append("```")
169
+ body = _document_body(result.registry, document.document_id)
170
+ if body:
171
+ lines.append("")
172
+ lines.extend(body.splitlines())
173
+
174
+ return "\n".join(lines)
175
+
176
+
177
+ def format_context_json(result: ContextBuildResult) -> str:
178
+ selection = result.selection
179
+ payload = {
180
+ "sources": [asdict(source) for source in selection.sources] if selection else [],
181
+ "documents": [_document_payload(document) for document in selection.documents]
182
+ if selection
183
+ else [],
184
+ "warnings": [_message_payload(message) for message in result.warnings],
185
+ }
186
+ if result.errors:
187
+ payload["errors"] = [_message_payload(message) for message in result.errors]
188
+ return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
189
+
190
+
191
+ def _load_project_context(project_root: str | Path) -> _ProjectContext | ContextBuildResult:
192
+ root = Path(project_root).resolve()
193
+ config_result = load_project_config(root)
194
+ if not config_result.ok or config_result.config is None:
195
+ return ContextBuildResult(
196
+ exit_code=2,
197
+ selection=None,
198
+ errors=tuple(
199
+ ValidationMessage(
200
+ rule_id=error.code,
201
+ message=error.message,
202
+ path=error.path,
203
+ )
204
+ for error in config_result.errors
205
+ ),
206
+ )
207
+
208
+ documents: list[RegistryDocument] = []
209
+ errors: list[ValidationMessage] = []
210
+ for path in scan_markdown_files(config_result.config):
211
+ parse_result = parse_markdown_file(path)
212
+ if parse_result.document is None:
213
+ errors.extend(
214
+ ValidationMessage(
215
+ rule_id=f"frontmatter.{error.code}",
216
+ message=error.message,
217
+ path=error.path,
218
+ )
219
+ for error in parse_result.errors
220
+ )
221
+ continue
222
+
223
+ documents.append(registry_document_from_parsed(parse_result.document))
224
+
225
+ return _ProjectContext(
226
+ root=root,
227
+ config=config_result.config,
228
+ documents=tuple(documents),
229
+ errors=tuple(errors),
230
+ )
231
+
232
+
233
+ def _registry_errors(registry: DocumentRegistry) -> tuple[ValidationMessage, ...]:
234
+ return tuple(
235
+ ValidationMessage(
236
+ rule_id=issue.rule_id,
237
+ message=issue.message,
238
+ path=", ".join(str(path) for path in issue.paths),
239
+ document_id=issue.document_id,
240
+ severity=issue.severity,
241
+ )
242
+ for issue in registry.issues
243
+ )
244
+
245
+
246
+ def _selection_errors(selection: ContextSelectionResult) -> tuple[ValidationMessage, ...]:
247
+ return tuple(
248
+ ValidationMessage(
249
+ rule_id=issue.rule_id,
250
+ message=issue.message,
251
+ document_id=issue.document_id,
252
+ severity=issue.severity,
253
+ )
254
+ for issue in selection.issues
255
+ if issue.severity == "error"
256
+ )
257
+
258
+
259
+ def _selection_warnings(selection: ContextSelectionResult) -> tuple[ValidationMessage, ...]:
260
+ return tuple(
261
+ ValidationMessage(
262
+ rule_id=issue.rule_id,
263
+ message=issue.message,
264
+ document_id=issue.document_id,
265
+ severity=issue.severity,
266
+ )
267
+ for issue in selection.issues
268
+ if issue.severity != "error"
269
+ )
270
+
271
+
272
+ def _changed_file_errors(issues: tuple[ChangedFileIssue, ...]) -> tuple[ValidationMessage, ...]:
273
+ return tuple(_changed_file_message(issue) for issue in issues if issue.severity == "error")
274
+
275
+
276
+ def _changed_file_warnings(issues: tuple[ChangedFileIssue, ...]) -> tuple[ValidationMessage, ...]:
277
+ return tuple(_changed_file_message(issue) for issue in issues if issue.severity != "error")
278
+
279
+
280
+ def _changed_file_message(issue: ChangedFileIssue) -> ValidationMessage:
281
+ return ValidationMessage(
282
+ rule_id=issue.rule_id,
283
+ message=issue.message,
284
+ path=issue.path,
285
+ document_id=issue.document_id,
286
+ severity=issue.severity,
287
+ )
288
+
289
+
290
+ def _document_body(registry: DocumentRegistry | None, document_id: str) -> str:
291
+ if registry is None:
292
+ return ""
293
+ document = registry.get_by_id(document_id)
294
+ if document is None:
295
+ return ""
296
+ return document.body.strip()
297
+
298
+
299
+ def _message_line(message: ValidationMessage) -> str:
300
+ path = f"{message.path} " if message.path else ""
301
+ document = f"[{message.document_id}] " if message.document_id else ""
302
+ return f"{path}{document}{message.rule_id}: {message.message}"
303
+
304
+
305
+ def _document_payload(document: object) -> dict[str, object]:
306
+ payload = asdict(document)
307
+ payload["reasons"] = [asdict(reason) for reason in document.reasons]
308
+ return payload
309
+
310
+
311
+ def _message_payload(message: ValidationMessage) -> dict[str, str | None]:
312
+ return {
313
+ "path": message.path,
314
+ "document_id": message.document_id,
315
+ "rule_id": message.rule_id,
316
+ "severity": message.severity,
317
+ "message": message.message,
318
+ }
319
+
320
+
321
+ def _git_changed_files(root: Path):
322
+ return read_git_changed_files(
323
+ root,
324
+ unavailable_rule_id=RULE_GIT_CHANGED_FILES_UNAVAILABLE,
325
+ )